Deep-link SSO tra applicazioni con Zitadel senza aprire buchi
La richiesta arriva sempre nella stessa forma, e sembra banale. Nel gestionale c'è un pulsante che apre il pannello di gestione della posta. Oggi porta a una pagina di login, dove l'utente digita di nuovo delle credenziali che ha appena usato, e poi cerca a mano la risorsa da cui era partito. Si può fare in modo che ci arrivi direttamente, già autenticato e già sul contesto giusto?
Sì. E il modo in cui si risponde a questa domanda separa nettamente le integrazioni fatte bene da quelle che aprono una porta laterale. Perché la parte dell'autenticazione la risolve il protocollo, e non è il problema; il problema è il contesto, cioè dire alla seconda applicazione su quale risorsa atterrare. Quel dato viaggia in un URL, e un URL è una cosa che l'utente vede, copia, modifica e inoltra.
Negli articoli precedenti di questa serie ho costruito i pezzi che servono qui: il token che prova chi sta chiamando, l'autorità che dice cosa quella persona può fare e il recinto applicativo su ogni operazione. Questo articolo racconta cosa è successo quando li ho messi insieme per costruire quel pulsante, incluse le due cose che ho sbagliato e la revisione di sicurezza che le ha trovate.
Perché un contesto firmato non risolve il problema?
Perché la firma dimostra l'origine del messaggio, non l'attualità del diritto. La prima implementazione che qualsiasi persona ragionevole propone è proprio un collegamento firmato: il gestionale mette in un token il soggetto, la risorsa e la vista da aprire, lo firma con una chiave che il pannello conosce, e il pannello lo verifica e apre quello che c'è scritto. Era anche il disegno iniziale del mio piano tecnico, con tanto di scelta consapevole della firma asimmetrica per non condividere un segreto.
Il difetto non è crittografico, è concettuale. Un contesto firmato dice che il gestionale ha davvero prodotto quel collegamento, e non dice niente sul fatto che l'utente che lo sta usando in questo momento abbia ancora diritto a quella risorsa. Fra la generazione e l'uso può essere passato tempo, il collegamento può essere stato inoltrato, il permesso può essere stato revocato. Se il pannello si fida di ciò che c'è dentro, hai costruito un'autorizzazione che viaggia in un URL, con tutti i problemi descritti nell'articolo precedente sulla revoca, più quelli che nascono dal fatto che gli URL si copiano.
Il ribaltamento che ho adottato è questo: nessun parametro del collegamento è autorevole. L'identità la prova il token ottenuto dall'identity provider. L'autorizzazione la ricalcola l'autorità di autorizzazione, dal vivo, al momento dell'arrivo. I parametri dell'URL scelgono al massimo cosa mostrare, mai cosa è permesso.
La formulazione che ho scritto nella documentazione interna e che consiglio di scrivere accanto a ogni funzione che tocca questi parametri: nessun parametro del deep-link è load-bearing per l'autorizzazione. Chi aggiunge un parametro deve preservare questa proprietà; se lo usasse per autorizzare senza ri-verificare, aprirebbe un buco.
La conseguenza pratica è che il contesto firmato è diventato superfluo. Non c'è niente da proteggere dalla manomissione, perché non c'è niente di manomettibile che abbia effetto. È un caso raro e istruttivo in cui la soluzione più sicura è anche quella con meno crittografia dentro.
Come si costruisce il collegamento
Il gestionale produce il collegamento solo se il pannello dichiara di saperlo gestire, e questo lo scopre invece di darlo per scontato: interroga un piccolo endpoint pubblico e cacheato del pannello e verifica che abbia il single sign-on attivo, che accetti questo tipo di ingresso, e che punti al nostro stesso identity provider e allo stesso progetto. Se una di queste condizioni manca, il pulsante torna a essere il collegamento semplice di prima.
Questo controllo non è pedanteria. Senza, il giorno in cui qualcuno riconfigura il pannello in modalità classica, il gestionale continuerebbe a mostrare un pulsante che porta a una pagina di errore. Non si offre mai una porta che non si può aprire.
La verifica che identity provider e progetto coincidano merita una riga in più, perché è quella che si tende a saltare ritenendola superflua. Serve contro uno scenario che sembra improbabile e invece capita: due ambienti configurati verso istanze diverse, per esempio un pannello di collaudo che punta all'identity provider di prova mentre il gestionale punta a quello di produzione. Senza il confronto, il gestionale genererebbe un collegamento perfettamente formato verso un sistema che non riconosce i suoi token, e il sintomo sarebbe un fallimento incomprensibile a valle. Confrontare due stringhe di configurazione costa nulla e trasforma un errore di ambiente in un semplice ritorno al comportamento precedente.
Il collegamento porta tre informazioni possibili, e la parte interessante è che sono mutuamente esclusive:
$params = ['resource' => $resource->name];
// restringe a un singolo elemento, ma solo se appartiene alla risorsa
if ($item !== null && str_ends_with(mb_strtolower($item), '@' . mb_strtolower($resource->name))) {
$params['item'] = $item;
}
if ($this->isDevShortcut()) {
$params['ref'] = (string) (Auth::user()?->reference ?? ''); // solo fuori produzione
} elseif ($this->delegation->isActive()) {
$params['as'] = (string) (Auth::user()?->reference ?? ''); // sto agendo per un altro
} else {
$params['login_hint'] = (string) (Auth::user()?->reference ?? '');// suggerimento all'IdP
}
return $this->base() . '/sso/entry?' . http_build_query($params);Il fatto che i tre casi siano rami di un unico condizionale è una scelta difensiva: non esiste nessun collegamento che ne porti due. Ogni combinazione ambigua è impossibile per costruzione, invece che essere gestita da un controllo a valle che qualcuno potrebbe rimuovere.
Da notare anche il controllo sull'elemento singolo: si aggiunge solo se appartiene davvero alla risorsa indicata. Non è una misura di sicurezza, perché il pannello ri-valida comunque; serve a non generare un suggerimento privo di senso, che sarebbe rumore che qualcuno un giorno interpreterebbe.
I quattro vettori, e cosa succede se li provi
Questa è la parte che vale la pena leggere anche se non implementerai mai questa funzionalità, perché il metodo si applica a qualunque parametro che viaggi in un URL. Per ciascun parametro mi sono chiesto: cosa succede se un utente autenticato ma malintenzionato ci mette un valore che non gli spetta?
La risorsa. Un utente mette il nome di una risorsa che non gli appartiene. Il pannello la applica solo se compare nell'insieme che l'autorità di autorizzazione gli ha appena restituito; altrimenti non c'è corrispondenza e resta sulla prima risorsa concessa. Il parametro non allarga niente: al massimo viene ignorato.
L'elemento singolo. Un utente mette un elemento appartenente a una risorsa altrui. Il restringimento avviene solo se l'elemento appartiene alla risorsa attiva concessa; altrimenti viene azzerato. E anche se riuscisse a impostarlo, ogni operazione su quell'elemento passerebbe comunque dal recinto sui dati descritto nell'articolo precedente. È un doppio strato, e il secondo è quello che vale.
L'indicazione di operare per conto di un altro. Questo è il vettore serio, ed è quello che in un sistema mal progettato produrrebbe un'escalation. Il pannello non decide nulla: inoltra la richiesta all'autorità di autorizzazione, con il token del vero attore, e chiede se quella delega sia legittima. Dall'altra parte si applicano il controllo di titolo e il consenso riletto sotto lock. Un identificativo inventato non produce niente, e il rifiuto viene assorbito senza compromettere la sessione che l'utente ha comunque diritto ad avere per sé stesso.
Il suggerimento all'identity provider. Serve a dire all'identity provider quale account riutilizzare quando la persona ne ha più d'uno registrati. È inerte per definizione: l'identity provider richiede comunque una sessione valida o le credenziali per quell'account. Suggerire un'identità non è autenticarsi come quella identità.
La sanitizzazione all'ingresso resta comunque, e non perché serva all'autorizzazione ma perché i valori che entrano nel sistema devono avere la forma che ci si aspetta:
const rawItem = req.query.item || null;
const item = (rawItem && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawItem))
? String(rawItem).toLowerCase() : null;
const rawHint = req.query.login_hint || null;
const loginHint = (rawHint && /^[\w.@+-]{1,128}$/.test(rawHint)) ? rawHint : null;Un valore che non ha la forma attesa viene scartato, non corretto. È la regola generale: si valida per forma all'ingresso e si autorizza per stato al momento dell'uso, e le due cose non si sostituiscono a vicenda.
Se stai costruendo un passaggio di contesto fra due applicazioni e vuoi che qualcuno provi a romperlo prima che lo faccia qualcun altro, nel mio profilo professionale trovi il percorso su sicurezza offensiva, penetration testing e integrazione di sistemi eterogenei.
Il pezzo che rende tutto trasparente: l'autenticazione silenziosa
Perché l'esperienza sia davvero senza attrito, l'utente che ha già una sessione attiva presso l'identity provider non deve vedere nessuna schermata. Il protocollo prevede il modo di chiederlo, ed è descritto nella specifica di OpenID Connect Core: si avvia il giro di autorizzazione dichiarando che non si vuole nessuna interazione, e l'identity provider risponde con l'esito se può, oppure con uno degli errori previsti se servirebbe l'intervento della persona.
Il primo tentativo è quindi silenzioso, e il ritorno gestisce l'esito negativo con un unico nuovo tentativo, questa volta interattivo:
if (error) {
const retriable = ['login_required', 'interaction_required',
'consent_required', 'account_selection_required'];
if (pending && pending.silent
&& String(state) === String(pending.state)
&& retriable.includes(error)) {
req.session.entry_intent = pending.entry || null; // preserva l'intento
return res.redirect(`${appUrl}/sso/login?interactive=1`);
}
return res.redirect(`${appUrl}/login?sso_error=oidc_${error}`);
}Tre dettagli che ho imparato scrivendo questo pezzo. Il ritentativo è uno solo, perché un ciclo di redirect fra due sistemi è un guasto che si manifesta come un browser che si pianta e che è sgradevolissimo da diagnosticare. Il valore anti-falsificazione viene verificato anche sul percorso di errore, altrimenti un errore costruito ad arte da un terzo potrebbe pilotare il tentativo interattivo. E l'intento viene preservato attraverso il giro, altrimenti la persona finisce autenticata ma sulla pagina sbagliata, che è il modo più sicuro di far sembrare rotta una funzionalità che invece funziona.
Sulla forma degli errori vale una nota che non è di sicurezza ma di rispetto per chi usa il sistema: i rifiuti non tornano mai come risposte tecniche grezze. Sono reindirizzamenti verso la pagina di accesso con un codice che l'interfaccia traduce in una frase comprensibile. Chi viene rifiutato legge che l'autorizzazione non è valida e che può rivolgersi all'assistenza, non un oggetto di dati in mezzo alla pagina.
Il collegamento che finisce dove non doveva
C'è uno scenario che vale la pena isolare perché è quello che le persone fanno davvero, senza cattive intenzioni: inoltrano il collegamento. Lo incollano in una chat con un collega, lo mandano all'assistenza allegandolo a una segnalazione, lo salvano nei preferiti e lo riaprono sei mesi dopo.
In un'implementazione basata su un contesto firmato, ognuna di queste azioni è un potenziale incidente. Chi riceve il collegamento potrebbe non avere diritto a quella risorsa, e se il sistema si fida di ciò che c'è dentro, gliela apre. La contromisura classica è una scadenza breve, che riduce il problema ma introduce un attrito nuovo: un collegamento che dopo cinque minuti smette di funzionare confonde chi lo usa in buona fede, e genera segnalazioni di malfunzionamento che malfunzionamenti non sono.
Con l'impostazione descritta qui il problema semplicemente non si pone, ed è la ragione più convincente per adottarla. Chi riceve il collegamento e non ha diritto a quella risorsa viene autenticato come sé stesso e atterra sulle proprie risorse: il parametro non corrisponde a nulla che gli sia stato concesso, quindi viene ignorato. Non c'è nessun accesso indebito e non c'è nessun errore da spiegare. Il collegamento è condivisibile senza conseguenze, e non perché sia protetto: perché non contiene niente da proteggere.
C'è anche un beneficio che non è di sicurezza ed è quello che fa risparmiare più tempo all'assistenza: il collegamento non scade. Salvato nei preferiti, funziona anche il mese prossimo, perché tutto ciò che serve viene ricalcolato al momento dell'uso. Una funzionalità che si comporta in modo prevedibile è una funzionalità che le persone usano, e l'adozione è il vero metro di giudizio di un lavoro come questo.
Il test che prova a barare al posto tuo
La verifica di questa funzionalità non usa la rete e non usa l'identity provider: costruisce una sessione con un insieme noto di risorse concesse, chiama direttamente la funzione che applica l'intento, e verifica il risultato.
I casi che copre sono l'elenco dei tentativi che ho descritto sopra: una risorsa concessa ma diversa dalla prima viene davvero applicata, il che dimostra che il meccanismo funziona e che gli altri casi non passano per caso; una risorsa non concessa non viene applicata; un elemento della risorsa attiva viene messo a fuoco; un elemento di un'altra risorsa non viene messo a fuoco; una combinazione di entrambi i valori falsificati non produce nulla.
La proprietà che rende questo test prezioso è che è velocissimo e non ha dipendenze, quindi gira nella suite ordinaria a ogni modifica. Un controllo di sicurezza che richiede un ambiente completo per essere verificato è un controllo che verrà escluso dalla suite alla prima settimana difficile, e da quel momento non protegge più niente.
C'è un motivo strutturale per cui questo test può fare a meno della rete, e non è una scorciatoia: la funzione che applica l'intento riceve una sessione e un oggetto di parametri, e non sa niente di HTTP. Non legge la richiesta, non parla con l'identity provider, non apre connessioni. Tutto ciò che le serve arriva come argomento. È una proprietà che si ottiene solo se la si progetta, e il beneficio è esattamente questo: la parte che decide diventa verificabile in isolamento.
Il consiglio che ne traggo, e che vale ben oltre il caso specifico, è di separare la decisione dall'esecuzione. Se il codice che stabilisce cosa è permesso è intrecciato con quello che effettua chiamate di rete e legge sessioni, per testarlo servirà un ambiente completo, e siccome servirà un ambiente completo non verrà testato. Isolare la decisione in una funzione pura non è eleganza accademica: è la differenza fra avere e non avere test sulla parte che conta.
Un dettaglio pratico che ho dovuto sistemare e che segnalo perché è il tipo di cosa che nessuno racconta: i dati usati in questi test contenevano nomi reali di clienti, perché erano stati scritti copiando da un ambiente vero. Funzionavano benissimo e sarebbero finiti in un repository. I dati di prova vanno inventati, sempre, anche quando la tentazione di copiare dalla realtà è forte perché è più veloce.
Quanto vale questa funzionalità, in termini che non sono tecnici
Vale la pena tradurre il lavoro descritto finora in ciò che produce, perché è una di quelle funzionalità che si giustificano da sole ma di cui nessuno misura mai il ritorno.
Elimina un punto di abbandono. Ogni schermata di accesso in mezzo a un flusso di lavoro è un punto in cui una persona si ferma, non ricorda la password, rimanda. Nel caso concreto la seconda applicazione aveva credenziali diverse dalla prima, e la conseguenza pratica è quella che si vede in tutti i sistemi con questo difetto: le persone non ci entrano, e chiamano l'assistenza per operazioni che potrebbero fare da sole.
Riduce il numero di credenziali in circolazione, che è un beneficio di sicurezza mascherato da beneficio di comodità. Quando un'applicazione ha una password propria, quella password viene condivisa fra colleghi, scritta da qualche parte, e non viene cambiata quando qualcuno se ne va. Toglierla di mezzo chiude tutti e tre i problemi insieme, ed è un argomento che funziona molto meglio del richiamo generico alla sicurezza.
Rende la delega tracciabile. Nel modello precedente, chi accedeva al pannello per conto di qualcun altro usava le credenziali di quel qualcun altro, quindi i registri non distinguevano. Con il passaggio di contesto autenticato, la persona che agisce è nota, e ciò che fa è attribuito a lei anche mentre opera per conto di un terzo.
Il costo, per essere onesti fino in fondo, è che questa funzionalità lega due applicazioni che prima erano indipendenti: la prima ora conosce l'esistenza della seconda e ne interroga le capacità. Il modo in cui ho contenuto quel costo è che il legame è a senso unico e degradabile: se la seconda non risponde o dichiara di non saper gestire l'ingresso, la prima torna al comportamento precedente senza che nessuno se ne accorga. Un'integrazione che si degrada da sola è un'integrazione che non diventa un vincolo di rilascio.
I compromessi che ho accettato, dichiarati
Un'analisi di sicurezza che non elenca ciò che ha lasciato aperto non è un'analisi, è una brochure.
Il suggerimento all'identity provider espone un identificativo interno nell'URL. Finisce nella barra degli indirizzi, potenzialmente in un'intestazione di provenienza e nei registri di un proxy. Quel valore non è un segreto, è un identificativo che serve a distinguere account, e va verso un sistema che lo conosce già. Toglierlo richiederebbe di firmare il collegamento, cioè reintrodurre la complessità che si era eliminata, per proteggere un dato che non è sensibile. Accettato, con la precisazione che il ragionamento cambierebbe se quel valore fosse indovinabile e desse accesso a qualcosa: qui non lo dà, perché non è autorevole.
Non verifico quale client del progetto stia presentando il token. Il confine di fiducia è il progetto, quindi qualunque applicazione al suo interno può presentare un token valido. Chi lo facesse otterrebbe però solo i propri permessi, o deleghe comunque sottoposte al controllo di titolo. Accettato, con la condizione dichiarata che l'ingresso di un terzo client nel progetto è un evento che richiede di rivalutare questa decisione.
La prima volta non è mai silenziosa, e va spiegato a chi la userà. L'autenticazione senza interazione funziona solo se esiste già una sessione presso l'identity provider. Chi apre il pannello come prima azione della giornata vedrà una schermata di accesso, e la vedrà legittimamente. È un comportamento corretto che però contraddice l'aspettativa creata dal nome della funzionalità, e se nessuno lo spiega diventa una segnalazione di malfunzionamento. Vale la pena scriverlo nella nota di rilascio invece di lasciarlo scoprire.
Sotto delega, il passaggio non è silenzioso. Il suggerimento all'identity provider viene aggiunto solo nel caso in cui la persona opera per sé; quando opera per conto di un altro, l'autenticazione silenziosa può fallire e degradare a una schermata di scelta. È un difetto funzionale, non di sicurezza, e la correzione è nota: passare l'identità di chi agisce davvero invece di quella del soggetto. Non l'ho chiuso perché il caso è raro e la degradazione è benigna, e questa è una scelta di priorità che ho scritto invece di lasciarla implicita.
Il metodo che ho seguito è più importante del risultato, e si riassume in tre passaggi che consiglio a chiunque costruisca un passaggio di contesto fra applicazioni. Elencare i parametri che viaggiano, uno per uno, senza saltarne nessuno perché "quello è innocuo". Chiedersi per ciascuno cosa succede se un utente autenticato ci mette un valore che non gli spetta, e scrivere la risposta, perché una risposta scritta si può contestare mentre una risposta pensata no. Scrivere un test per ciascun caso, perché la risposta di oggi vale finché qualcuno non tocca quel codice. Se hai un passaggio di contesto fra due applicazioni e non sai dire quali dei suoi parametri siano load-bearing per l'autorizzazione, scrivimi e lo guardiamo: nella mia esperienza la risposta sorprende chi lo ha scritto, e il tipo di problema che viene fuori qui non ha mezze misure.