SSO opzionale con Zitadel: OIDC su un prodotto che gira anche standalone
C'è una domanda che va posta all'inizio di ogni integrazione SSO e che nel mio caso è arrivata a metà strada, con conseguenze: questo software gira solo qui? Se la risposta è no, se lo stesso prodotto è installato anche presso clienti che hanno una loro directory e nessun identity provider, allora il single sign-on non è una funzionalità da aggiungere. È una seconda forma del prodotto che deve convivere con la prima nello stesso codice, e il modo in cui si imposta quella convivenza decide se il lavoro sarà manutenibile o se produrrà due versioni divergenti.
Nel caso concreto la scoperta è arrivata in una conversazione, non da un documento: il pannello di gestione della posta su cui stavo lavorando non era un applicativo su misura, era un prodotto installato anche altrove, con directory diverse e senza nessuno dei sistemi con cui lo stavo federando. Se avessi cablato l'identity provider nel nucleo, avrei rotto tutte le altre installazioni al primo aggiornamento.
Negli articoli precedenti ho descritto come si valida il token, dove vive l'autorizzazione, come si ricostruisce il recinto sui dati e come si passa il contesto fra applicazioni. Qui affronto il vincolo che ha determinato la forma di tutti quei pezzi: tutto quel codice non deve essere nemmeno raggiungibile quando il single sign-on è spento.
Perché il single sign-on non può essere un semplice flag?
Perché un flag accende un ramo di codice, mentre qui cambiano tre cose insieme e a livelli diversi dello stack, e due di esse non stanno nemmeno dentro la stessa applicazione.
Cambia da dove viene l'identità: prima da una verifica di credenziali contro la directory, poi da un giro di autorizzazione presso un sistema esterno. Cambia come si accede ai dati: prima connettendosi come l'utente, con le regole della directory a fare da recinto, poi con un account di servizio e un controllo applicativo. Cambia il modello mentale dell'interfaccia: si passa da "sono questa casella" a "sono questo cliente e gestisco le mie risorse", il che significa una pagina di accesso diversa, un selettore che prima non esisteva, testi diversi.
E c'è il fattore che rende la faccenda davvero diversa da un flag: il flusso completo coinvolge due applicazioni. Se solo una delle due ha il single sign-on attivo, il passaggio di contesto fra loro semplicemente non esiste. La modalità effettiva quindi non è una proprietà di una configurazione: è l'intersezione di due configurazioni più la verifica che siano federate fra loro.
| Prima applicazione | Seconda applicazione | Comportamento effettivo |
|---|---|---|
| spento | spento | tutto classico, è il caso delle installazioni presso altri clienti |
| acceso | spento | la prima usa l'SSO per sé, ma verso la seconda mostra le istruzioni classiche |
| spento | acceso | la seconda ha l'SSO ma nessuna autorità che le dica i permessi, quindi degrada |
| acceso | acceso e federati | passaggio trasparente, è l'unico caso in cui il flusso completo esiste |
Da questa tabella discendono due principi che ho applicato ovunque. Ogni applicazione possiede la propria verità tramite configurazione. E la verità dell'altra va scoperta, non assunta, perché un flag statico che dice "il mio interlocutore fa SSO" va in deriva nel momento esatto in cui qualcuno riconfigura l'interlocutore.
Il default è il comportamento di prima, e non è una scelta di comodo
La regola che ha guidato tutto: senza configurazione, il prodotto si comporta esattamente come prima. Un cliente che scarica il codice e non imposta niente non deve sapere che l'identity provider esista.
La configurazione è a tre stati, non a due, e la distinzione conta: c'è l'interruttore che accende il single sign-on, quello che dice se il login classico resta comunque disponibile, e un terzo che slega esplicitamente l'applicazione dal suo interlocutore. Quest'ultimo esiste per una ragione molto pratica emersa sul campo: chi copia un file di configurazione da un ambiente all'altro si porta dietro l'indirizzo dell'interlocutore anche quando non gli serve, e senza un modo di dire "ignoralo" l'applicazione tenterebbe di contattarlo a ogni avvio, pagando un ritardo per niente.
La proprietà che rende l'esclusione reale, però, non sta nella configurazione: sta nel fatto che le rotte non vengono nemmeno montate.
const ssoOn = !!(config.SSO && config.SSO.enabled);
if (ssoOn) {
app.get('/sso/login', ssoLogin);
app.get('/sso/callback', ssoCallback);
}
if (ssoOn || devPossible) {
app.get('/sso/entry', ssoEntry);
app.post('/api/sso/switch_resource', checkAuth, ssoSwitchResource);
// ...
}La differenza fra questo e un controllo dentro il gestore della rotta è sostanziale. Con il montaggio condizionale, in modalità classica quelle rotte non esistono: rispondono come qualunque indirizzo inesistente, non c'è nessun codice da attraversare, non c'è nessuna possibilità che un controllo scritto male le lasci passare. La superficie d'attacco di una funzionalità disattivata è zero, non "protetta".
È il criterio che uso per giudicare se un opt-out è reale o cosmetico: la funzionalità disattivata deve essere irraggiungibile, non respinta. Se rispondere richiede di eseguire del codice, quel codice è superficie d'attacco anche quando dice di no.
La negoziazione: un endpoint invece di due flag
Il meccanismo con cui ogni applicazione scopre l'altra è deliberatamente banale: ognuna espone un piccolo endpoint pubblico e cacheabile che descrive sé stessa. Non è un'idea originale, è lo stesso schema con cui un identity provider si presenta ai propri client attraverso il documento di configurazione previsto da OpenID Connect Discovery: un indirizzo noto che restituisce le capacità del sistema, così che nessuno debba configurarle a mano da entrambe le parti.
res.set('Cache-Control', 'public, max-age=60');
return res.status(200).send({
sso,
legacy_login: !config.SSO || config.SSO.legacyLoginEnabled !== false,
issuer: sso ? config.SSO.authUrl : null,
project_id: sso ? config.SSO.projectId : null,
grant_authority: false, // questa applicazione non concede permessi
entry: sso && ENTRY_SUPPORTED,
prod,
dev_grant: !prod && !!(config.SSO && config.SSO.devSecret),
});Il vantaggio rispetto a due flag statici è che esiste una sola verità per applicazione, ed è la sua configurazione. Nessuno deve ricordarsi di aggiornare la dichiarazione dell'altro lato quando cambia qualcosa: la deriva è impossibile per costruzione, e il sistema si auto-ripara appena una delle due parti viene riconfigurata. Il costo è una richiesta cacheata di tanto in tanto, che è trascurabile.
Un dettaglio che ha richiesto una correzione in revisione e che merita di essere raccontato: l'endpoint deve dire la verità anche su ciò che non è ancora stato costruito. In una versione intermedia dichiarava di saper gestire il passaggio di contesto perché il single sign-on era attivo, mentre la rotta corrispondente non esisteva ancora. L'effetto sarebbe stato che l'altra applicazione avrebbe mostrato un pulsante verso una pagina inesistente. La correzione è stata una costante esplicita che vale finché la funzionalità non c'è. Una dichiarazione di capacità è un contratto, non un'aspirazione.
Il fail-safe direzionale, che è la regola d'oro
Il punto che considero il più importante di tutto l'articolo: quando la scoperta fallisce, il sistema deve collassare verso il basso, mai verso l'alto.
const LEGACY_SAFE = Object.freeze({
sso: false,
legacy_login: true,
grant_authority: false,
entry: false,
issuer: null,
project_id: null,
reachable: false,
prod: true, // fail-closed: se non confermiamo che non è produzione, niente scorciatoie
dev_grant: false,
});Questo oggetto è la risposta a qualunque problema: interlocutore irraggiungibile, timeout, risposta malformata, applicazione non legata a nessuno. Ogni campo è impostato sul valore più prudente possibile, e la cosa interessante è che i valori prudenti non sono tutti false.
Il campo che dichiara il login classico disponibile è vero, perché il degrado deve lasciare una via d'accesso: un fail-safe che chiude tutte le porte non è prudente, è un disservizio. Il campo che dichiara l'ambiente di produzione è vero anch'esso, ed è la riga più sottile del frammento: se non riusciamo a confermare che l'interlocutore non è in produzione, assumiamo che lo sia, e questo disarma automaticamente la scorciatoia di sviluppo di cui parlo fra poco. Il valore prudente non è sempre il valore vuoto: è quello che nega il privilegio.
C'è poi una raffinatezza operativa che consiglio di rubare: le due durate di cache sono diverse. Una risposta confermata si tiene per poco, perché lo stato dell'interlocutore può cambiare e vogliamo accorgercene in fretta. Una risposta degradata si tiene più a lungo, perché se l'interlocutore non c'è tanto vale non chiederglielo ogni trenta secondi. Il compromesso è dichiarato: dopo un problema transitorio, il ritorno alla normalità richiede fino alla durata più lunga. È un prezzo che vale la pena pagare per non trasformare un'installazione classica in una che tenta una connessione inutile a ogni avvio.
Se stai aggiungendo un'integrazione a un prodotto che gira anche in contesti diversi dal tuo e vuoi che resti installabile ovunque, nel mio profilo professionale trovi il percorso su architetture multi-stack, integrazione di sistemi eterogenei e modernizzazione di prodotti in esercizio.
La macchina a stati, ricavata dal codice
Il risultato di tutto questo è che la modalità effettiva è calcolata, non configurata. Sono tre righe che condensano tutto il ragionamento:
const federated = !!(ssoHere && peer && peer.sso && peer.grant_authority
&& peer.issuer === config.SSO.authUrl
&& String(peer.project_id) === String(config.SSO.projectId));
const advanced = federated || devBackdoor;
let mode = 'legacy';
if (ssoHere) mode = federated ? 'sso' : 'sso-degraded';
else if (devBackdoor) mode = 'sso-dev';I cinque stati raggiungibili, con il comportamento che ne consegue:
| Stato | Situazione | Funzioni avanzate | Traffico verso l'interlocutore |
|---|---|---|---|
| classico non legato | installazione presso un altro cliente | no | nessuno |
| federato | entrambe le parti attive e allineate | sì | scoperta cacheata |
| classico legato | l'interlocutore è attivo, questa applicazione no | no | scoperta cacheata |
| degradato | questa applicazione è attiva, l'interlocutore no o non combacia | no | scoperta cacheata |
| sviluppo | entrambe classiche, fuori produzione, con scorciatoia armata | sì | scoperta cacheata |
Vale la pena osservare che questi cinque stati non sono una tassonomia inventata a tavolino: sono il risultato di quelle tre righe di codice, enumerato a posteriori. È una distinzione che conta, perché una macchina a stati disegnata prima del codice tende a contenere stati che il codice non produce mai e a non contenerne altri che produce. Ricavarla dal calcolo effettivo, e tenerla accanto al codice come documentazione, significa che resta vera quando il codice cambia, o quantomeno che la divergenza si nota.
Lo stato degradato è quello che merita attenzione perché è il più utile e il meno ovvio. Significa: questa applicazione crede nel single sign-on, ma l'interlocutore non lo conferma. In quella condizione mostra comunque l'accesso federato, perché la configurazione lo dichiara, ma tiene spente tutte le funzioni che dipendono dall'autorità di autorizzazione. È la traduzione in codice del principio che un sistema in dubbio deve fare meno cose, non di più.
Merita una nota anche il fatto che il primo stato non genera nessun traffico. Un'installazione presso un cliente che non ha niente di tutto questo non tenta nessuna connessione verso l'esterno, e questo non è solo una questione di prestazioni: è una proprietà che consente di installare il prodotto in reti isolate senza dover spiegare perché il software cerca di contattare un indirizzo sconosciuto. In alcuni contesti quella domanda basta a bloccare un'installazione.
Come si collauda un prodotto che ha cinque modalità?
Con una verifica automatica per ciascuna, e con la consapevolezza che quella che si rompe per prima è sempre la stessa. Cinque stati significano cinque configurazioni possibili, e la tentazione naturale è collaudare solo quella su cui si sta lavorando, che è invariabilmente la modalità federata perché è quella nuova e interessante.
Il risultato di quella tentazione è prevedibile: il ramo classico si rompe in silenzio. Nessuno se ne accorge perché nessuno lo esercita, e la scoperta arriva dal cliente che aggiorna dopo tre mesi. È il fallimento più comune di questo tipo di lavoro, e non è tecnico: è di priorità.
La contromisura che ho adottato è una verifica di avvio che esercita gli stati senza rete e senza identity provider, controllando due proprietà per ciascuno. La prima è che la modalità calcolata sia quella attesa data la configurazione. La seconda, più importante, è quali rotte esistono: in modalità classica gli indirizzi del ramo federato devono rispondere come inesistenti, e non c'è modo di verificarlo se non provandoli.
C'è anche una verifica che vale la pena isolare perché è quella che protegge la promessa principale: in configurazione classica e non legata, il numero di connessioni di rete verso l'esterno deve essere zero. È una proprietà facile da rompere aggiungendo un controllo apparentemente innocuo all'avvio, e facile da verificare se ci si pensa.
L'ordine in cui consiglio di scrivere questi collaudi è l'inverso di quello in cui si sviluppa: prima il caso classico, poi il degradato, poi il federato. Il primo è quello che protegge i clienti esistenti, il secondo è quello che si verifica realmente in produzione quando qualcosa non va, e il terzo è quello che collauderai comunque perché è quello che stai costruendo.
La scorciatoia di sviluppo, e i quattro lucchetti
Per poter esercitare le funzioni federate senza avere un identity provider a disposizione esiste un percorso alternativo. È il tipo di cosa che diventa una vulnerabilità in un progetto su tre, quindi vale la pena vedere come l'ho chiusa.
Si arma solo se tutte queste condizioni sono vere insieme: questa applicazione non è in produzione; l'interlocutore dichiara di non essere in produzione; è configurato un segreto dedicato; e l'interlocutore dichiara di esporre il canale corrispondente. Quattro condizioni indipendenti, di cui due sull'ambiente e due sulla configurazione, distribuite su entrambe le applicazioni.
La proprietà che rende accettabile questo meccanismo è che le condizioni sull'ambiente vengono valutate prima di leggere il segreto. Se il segreto finisse per errore nella configurazione di produzione, per una copia sbagliata di un file, il percorso resterebbe comunque irraggiungibile. Combinata con il valore prudente dell'oggetto di degrado, che assume la produzione quando non può confermare il contrario, la conclusione è che la scorciatoia non si arma in produzione neanche per sbaglio.
È una regola che consiglio di adottare in generale: un canale di sviluppo si chiude sull'ambiente, non sulla presenza della credenziale, perché le credenziali migrano fra ambienti per errore molto più spesso di quanto si creda, mentre la variabile che dichiara l'ambiente è quasi sempre corretta.
La domanda che a questo punto è legittimo porsi è se valga la pena avere questa scorciatoia. La risposta onesta è che dipende dall'attrito che rimuove: nel mio caso permetteva di esercitare tutto il ramo federato senza dipendere dalla disponibilità di un identity provider di prova, il che ha significato poter lavorare e collaudare anche quando quel sistema non era raggiungibile. Se invece l'identity provider di prova è sempre disponibile e configurarlo costa dieci minuti, questa scorciatoia è complessità e rischio senza contropartita, e la scelta giusta è non scriverla affatto. Le porte di servizio si giustificano con il tempo che fanno risparmiare, e quel calcolo va fatto invece di darlo per scontato.
Come si tiene separato l'ottanta per cento del prodotto che non deve sapere niente
Fin qui ho parlato del confine. Resta la domanda più pratica: come si evita che la modalità si infili in ogni angolo del codice, trasformando ogni funzione in un condizionale a due rami?
La risposta è isolare la variabilità in tre punti e far consumare a tutto il resto un contesto uniforme. I tre punti sono: da dove viene l'identità, cioè verifica di credenziali contro la directory oppure giro di autorizzazione esterno; come si accede ai dati, cioè connessione come l'utente oppure account di servizio più controllo applicativo; e da dove vengono i permessi, cioè l'unica risorsa a cui l'utente si è connesso oppure l'insieme restituito dall'autorità esterna.
Tutti e tre producono la stessa struttura: chi è l'utente, quali risorse può toccare, su quale sta operando adesso, in che modalità siamo. E il resto dell'applicazione, cioè la gran parte del codice che crea, modifica ed elimina elementi, consuma solo quella struttura e non sa nulla del resto. Non chiede se il single sign-on sia attivo, perché non gli serve saperlo.
Questa proprietà è ciò che rende reale l'opt-out invece che cosmetico. Se le funzioni operative fossero disseminate di condizionali sulla modalità, ogni funzionalità nuova sarebbe un'occasione per dimenticarne uno, e il ramo classico si romperebbe progressivamente senza che nessuno se ne accorga, perché chi sviluppa lavora nel ramo federato.
Vale la pena aggiungere l'errore da non fare, perché l'ho evitato per un pelo: non introdurre l'astrazione prima che serva. In una fase intermedia avrei potuto scrivere subito il livello di adattamento completo per l'accesso ai dati, con la sua bella interfaccia e le due implementazioni. Sarebbe stato codice morto per settimane, e il codice morto viene scritto sulla base di ipotesi che poi si rivelano sbagliate. L'ho introdotto quando il secondo ramo esisteva davvero e aveva requisiti concreti da soddisfare. Un'astrazione con una sola implementazione reale non è un'astrazione, è una scommessa.
Cosa ne guadagna chi il prodotto lo vende
Traduco il lavoro in ciò che significa per chi ha un prodotto installato presso clienti diversi, perché è una situazione più comune di quanto la letteratura tecnica suggerisca.
Una sola base di codice, non due. L'alternativa che nasce spontaneamente quando manca questa disciplina è un ramo separato per il cliente che ha esigenze particolari, e da lì in poi ogni correzione va portata due volte, finché i due rami divergono abbastanza da non poter più essere riconciliati. Il costo di quella divergenza si paga per anni.
L'integrazione diventa un argomento di vendita invece che un vincolo. Un prodotto che funziona sia da solo sia federato si propone a entrambi i tipi di cliente senza distinguo. Il cliente che ha un identity provider ottiene il valore aggiunto, quello che non ce l'ha non paga complessità che non usa.
L'installazione presso il cliente prudente resta possibile. Molti clienti di prodotti installati hanno reti segmentate e regole che vietano connessioni in uscita non giustificate. Un prodotto che, appena avviato, tenta di contattare un indirizzo esterno per capire con chi ha a che fare, si trova a dover giustificare quella connessione davanti a chi amministra la rete, e la conversazione finisce spesso male. La configurazione classica non legata elimina la domanda alla radice, e questo è un argomento commerciale prima che tecnico.
Il rischio di regressione è contenuto. Siccome le rotte del ramo federato non esistono quando è spento, un difetto in quel codice non può manifestarsi presso i clienti che non lo usano. È una garanzia che si può dichiarare in una nota di rilascio, il che rende l'aggiornamento accettabile anche per il cliente prudente.
Il principio generale che porto via da questo lavoro, e che vale ogni volta che si aggiunge un'integrazione a un prodotto già installato altrove, è che il comportamento di prima deve restare il default e il percorso più corto. L'integrazione è un potenziamento che si attiva quando tutte le condizioni sono confermate, e in ogni altro caso si spegne da sola. È l'opposto dell'istinto, che porta a rendere la novità il comportamento normale e il vecchio un caso da gestire, ed è precisamente perché è l'opposto dell'istinto che va deciso all'inizio e scritto da qualche parte. Se stai aggiungendo un pezzo di questo tipo a un prodotto che non gira solo in casa tua e vuoi impostarlo prima che diventi un ramo separato, scrivimi pure: l'ora migliore per prendere questa decisione è prima della prima riga di codice.