OIDC spiegato con Zitadel: organizzazioni, progetti, grant e audience

OIDC spiegato con Zitadel: organizzazioni, progetti, grant e audience

Un identity provider self-hosted non è difficile da installare. È difficile da modellare. Ho visto impianti Zitadel funzionanti al primo login e completamente sbagliati nella struttura, con un progetto per ogni applicazione, ruoli duplicati tre volte e un'autorizzazione che nessuno sapeva più dove vivesse. Funzionano finché c'è una sola applicazione. Il giorno in cui ne aggiungi una seconda che deve parlare con la prima, il modello sbagliato presenta il conto, e il conto è sempre lo stesso: una chiamata autenticata che viene respinta con un errore di audience, e nessuno capisce perché, visto che il token è valido, la firma è corretta e l'utente ha appena fatto il login.

Questo è il primo di tredici articoli in cui racconto un'integrazione Zitadel reale, portata in produzione fra sistemi che non si somigliano: un identity provider, un gestionale monolitico in Laravel che fa da resource server e da autorità di autorizzazione, un pannello self-service scritto in Node.js e React che scende su una directory LDAP solo come backend dati. Nei prossimi pezzi entrerò nel codice, nell'installazione nativa su Debian, nell'alta disponibilità, nel filtraggio applicativo che sostituisce le ACL di directory e nella revoca dei permessi. Ma nessuno di quei pezzi ha senso se prima non è chiaro il modello mentale, ed è questo il pezzo che lo pianta. Se lo salti, tutto il resto sembrerà arbitrario.

Ho scritto questo articolo perché funzioni su due tavoli. C'è la parte per chi implementa, con gli scope esatti e i claim che finiscono nel token, e c'è la parte per chi la decisione la firma e la paga, perché il modello di identità è una di quelle scelte che sembrano tecniche e invece determinano cosa potrai fare fra due anni senza rifare tutto. Metto anche le mani avanti su un punto, perché in rete circola molta documentazione vecchia: alla data in cui scrivo la versione corrente è la 4.16.1, e dalla versione 3 Zitadel è distribuito sotto licenza AGPL 3.0, non più Apache 2.0. Molte comparative online riportano ancora la licenza sbagliata. Ci torno alla fine, perché cambia la valutazione, non solo la nota a piè di pagina.

Perché il modello di Zitadel confonde chi arriva da Keycloak o da Auth0?

Perché i nomi coincidono ma i confini no. In Keycloak il contenitore che isola gli utenti si chiama realm, e il client OIDC è l'unità a cui assegni ruoli e mapper: il ragionamento naturale diventa "un client, un'applicazione, un perimetro". In Auth0 hai i tenant e le API registrate, e l'audience che chiedi è l'identificativo dell'API che vuoi chiamare. In Zitadel il contenitore degli utenti si chiama organizzazione, ma l'unità che porta ruoli, autorizzazioni e confine di fiducia non è l'applicazione: è il progetto. L'applicazione è solo un client registrato dentro quel progetto.

La traduzione dei termini, che vale la pena tenere sott'occhio le prime settimane:

ConcettoKeycloakAuth0Zitadel
Contenitore degli utentirealmtenantorganizzazione
Perimetro di ruoli e autorizzazioniclient (o realm)API + ruoli del tenantprogetto
Client OIDC registratoclientapplicationapplicazione
Destinatario dichiarato del tokenclient ididentificativo dell'APIclient id, oppure project id via scope
Delega a un partnergruppi e sotto-realmorganizationsproject grant

È una differenza di una parola e cambia l'architettura. Se traduci meccanicamente "client Keycloak" con "applicazione Zitadel" e poi metti ogni applicazione in un progetto suo, hai costruito tanti perimetri isolati quante sono le applicazioni, e due tuoi servizi non potranno più fidarsi l'uno dell'altro senza aggiungere un segreto condiviso, che è esattamente ciò che OAuth2 esiste per evitare.

I livelli che devi tenere separati in testa

La documentazione ufficiale sulla struttura delle organizzazioni è precisa e vale la pena leggerla nell'ordine giusto, dal contenitore più grande al più piccolo.

L'istanza è il deployment: un binario, un database, un dominio esterno. È il livello a cui esistono le impostazioni globali e il super-amministratore. Se ospiti l'IdP tu, l'istanza è la macchina che gestisci.

L'organizzazione è il tenant dentro l'istanza. La documentazione la descrive come il recipiente dove vivono i tuoi progetti e i tuoi utenti, paragonabile a un tenant di un SaaS o a una unità organizzativa in una directory. Ogni organizzazione ha il proprio pool di utenti, umani e macchina, e un dominio primario che non è cosmetico: determina il login name mostrato all'utente e finisce asserito dentro gli access token. Un'istanza può contenere molte organizzazioni; per un'azienda singola ne basta una, e usarne di più è utile solo quando servono davvero pool di utenti separati, per esempio quando i dipendenti e i clienti finali non devono nemmeno vedersi come nomi utente.

Il progetto è il livello che conta ed è quello che quasi tutti sottovalutano. È il contenitore di tutti i componenti strettamente correlati fra loro, e stabilisce il contesto di sicurezza di una soluzione software. La conseguenza operativa, dritta dalla documentazione: tutte le applicazioni dentro un progetto condividono le stesse definizioni di ruolo, gli stessi grant e le stesse assegnazioni. Non è un dettaglio di comodità, è la definizione stessa del confine.

L'applicazione è il client: un'app web, un'app mobile, una single-page application, una API. Ha il proprio client_id, il proprio metodo di autenticazione, i propri redirect URI. E ha una cosa sola che conta davvero: appartiene a un progetto.

Il ruolo vive sul progetto, non sull'applicazione. Ha una chiave, un nome leggibile e un gruppo opzionale. L'autorizzazione, che nella console vedi come assegnazione di ruolo, è il legame fra un utente e i ruoli di un progetto.

Regola pratica che uso quando disegno un impianto nuovo: un progetto per ogni dominio di fiducia, non per ogni applicazione. Se due componenti devono potersi chiamare a vicenda presentando un token utente, stanno nello stesso progetto. Se non devono, stanno in progetti diversi, e allora l'isolamento è reale.

Utenti umani, utenti macchina e la login che dalla versione 4 vive fuori dal binario

Dentro un'organizzazione convivono due popolazioni che si comportano in modo diverso. Gli utenti umani hanno una password, eventualmente un secondo fattore, e attraversano il flusso di autorizzazione con il browser. Gli utenti macchina, che la console chiama service user, non hanno un browser: si autenticano con una chiave privata o con un token personale e ottengono token tramite flussi server-to-server. Servono per i lavori pianificati, per il provisioning e per tutto ciò che deve agire senza una persona davanti.

Nel progetto da cui viene questa serie gli utenti macchina hanno avuto un ruolo preciso e poco intuitivo: sono stati lo strumento per verificare le assunzioni prima di scrivere il codice. Prima di implementare qualunque cosa ho creato un utente macchina usa e getta, ho chiesto un token con lo scope di audience di progetto e ho guardato cosa c'era davvero dentro il claim aud. Ci sono voluti dieci minuti e hanno smentito l'assunzione su cui poggiava metà del disegno. Consiglio spassionato: la prima cosa da fare su un IdP nuovo non è scrivere l'integrazione, è farsi emettere un token e leggerlo.

C'è poi un fatto strutturale della versione 4 che tocca direttamente il modello mentale. L'interfaccia di login non è più dentro il binario: è un'applicazione separata che serve il proprio percorso e dialoga con il core via API, autenticandosi essa stessa come utente macchina con un ruolo dedicato. Detto altrimenti, il primo client del tuo identity provider è la sua stessa pagina di login. Chi installa Zitadel oggi installa due componenti, non uno, e il reverse proxy deve smistare le richieste di conseguenza. È il prezzo di una login personalizzabile senza mettere le mani nel core, ed è la ragione per cui in questa serie esiste un pezzo sull'installazione nativa su Debian: la via ufficiale oggi passa da Docker, la strada binaria funziona ancora ma non la documenta più nessuno.

Cosa finisce davvero dentro l'audience del token

Qui sta il punto tecnico che fa la differenza fra un impianto che funziona e uno che ti costa una giornata di debug. Quando un'applicazione chiede un access token, per default l'aud del token contiene il proprio client_id. Va benissimo finché quel token torna solo all'applicazione che lo ha chiesto. Non va più bene nel momento in cui l'applicazione A vuole chiamare un endpoint dell'applicazione B presentando il token dell'utente: B riceve un token la cui audience è il client di A, e se B fa il suo lavoro lo respinge, perché quel token non era destinato a lei.

La risposta di Zitadel è uno scope riservato, documentato nella pagina degli scope OIDC e OAuth:

urn:zitadel:iam:org:project:id:{projectId}:aud

Aggiungendo quello scope alla richiesta di autorizzazione, l'identificativo del progetto viene inserito nell'audience dell'access token. Non il client_id di B: il projectId. Ed è qui che il modello si chiude su sé stesso. Se A e B stanno nello stesso progetto, B può validare il token controllando che nell'aud compaia il proprio projectId, che conosce per configurazione. Le due applicazioni non condividono nessun segreto: ognuna ha la propria relazione con l'IdP, ognuna la propria chiave, e ciò che le lega è l'appartenenza allo stesso progetto.

In un'authorize URL reale lo scope si affianca agli altri e il risultato somiglia a questo, con gli identificativi ovviamente inventati:

scope=openid profile email
      urn:zitadel:iam:user:metadata
      urn:zitadel:iam:user:resourceowner
      urn:zitadel:iam:org:id:284419900112233445
      urn:zitadel:iam:org:project:id:284420011223344556:aud

Vale la pena sapere cosa fanno gli altri tre, perché sono i più utili in un'integrazione seria. Lo scope urn:zitadel:iam:org:id:{id} impone che l'utente appartenga a quella specifica organizzazione: è un controllo lato IdP che ti risparmia un controllo lato applicazione, ed è la difesa più economica contro un utente di un'altra organizzazione che tenta il login sulla tua. Lo scope urn:zitadel:iam:user:resourceowner inserisce nel token l'id, il nome e il dominio primario dell'organizzazione dell'utente. Lo scope urn:zitadel:iam:user:metadata porta i metadati dell'utente dentro il token, con i valori codificati in base64: comodo per trasportare un identificativo interno, ma la codifica non è cifratura, quindi quei metadati vanno trattati come dati di sola lettura gestiti dall'amministratore, mai come qualcosa che l'utente possa modificare. È una distinzione che in una call di progetto ho visto sollevare da un interlocutore non tecnico prima che la sollevasse un tecnico, ed era la domanda giusta.

Un avvertimento che costa caro se lo scopri tardi: perché un'altra applicazione possa validare il token localmente, decodificandolo contro le chiavi pubbliche dell'IdP, il tipo di access token dell'applicazione deve essere JWT e non opaco. È un'impostazione per applicazione. Con un token opaco la validazione locale semplicemente non esiste. Se vuoi verificare cosa contiene davvero un token che ti sta arrivando, prima di scrivere una riga di codice, puoi incollarlo nel mio strumento di audit dei JWT e guardare aud, iss e le scadenze: nove volte su dieci il problema è visibile a occhio nudo in quei tre claim.

Se stai progettando un'integrazione fra due applicazioni che devono fidarsi l'una dell'altra e vuoi che il modello regga anche fra due anni, nel mio profilo professionale trovi il percorso su architetture API, OAuth2 e sistemi multi-tenant da cui arrivano le scelte che descrivo qui.

Validare il token in locale o interrogare l'IdP a ogni richiesta?

In locale, salvo che tu abbia un requisito esplicito di revoca istantanea del token. La scelta è fra due modi di rispondere alla domanda "questo token è valido": decodificarlo e verificarne la firma contro le chiavi pubbliche pubblicate dall'IdP, oppure chiamare l'endpoint di introspection e chiedere all'IdP di rispondere lui. Sembra un dettaglio implementativo e invece è una decisione di architettura con conseguenze misurabili.

La validazione locale non fa traffico di rete nel percorso critico: scarichi le chiavi pubbliche una volta, le tieni in cache, verifichi firma e scadenze in memoria. Costa microsecondi ed è disponibile anche quando l'IdP non lo è, il che significa che un fermo dell'identity provider non propaga immediatamente a tutte le applicazioni che avevano già emesso token validi. L'introspection fa il contrario: una chiamata di rete per ogni richiesta autenticata, latenza aggiunta su ogni singola operazione, e un identity provider che diventa un punto di fallimento sincrono per tutto il sistema.

Il vantaggio dell'introspection è uno solo, ma è reale: risponde alla verità adesso, quindi un token revocato smette di funzionare all'istante, mentre con la validazione locale resta buono fino alla scadenza naturale. La domanda da porsi quindi non è quale delle due sia migliore, ma quanto vale nel tuo dominio la finestra fra la revoca e la scadenza. Nell'integrazione da cui viene questa serie ho scelto la validazione locale con una motivazione precisa: il dato davvero critico e revocabile non era il token, era un consenso applicativo, e quel consenso viene riletto dal database a ogni chiamata sensibile sotto un lock, quindi la freschezza è garantita dove serve senza pagarla dove non serve. È un ragionamento che merita il suo articolo e infatti ce l'ha: è il pezzo su revoca e finestra TOCTOU.

Il criterio generale che ne ricavo, e che uso anche fuori da Zitadel: metti nel token ciò che è stabile per la durata del token, tieni fuori ciò che qualcuno potrebbe voler revocare mentre il token è ancora in corso di validità.

I grant: assegnare un ruolo, delegare un progetto

Il termine grant in Zitadel copre due cose diverse e conviene tenerle distinte fin dall'inizio.

Il primo significato è l'assegnazione di ruolo a un utente su un progetto: dici che l'utente U ha il ruolo amministratore-fatturazione sul progetto P. I ruoli finiscono nel token solo se lo chiedi, e il come dipende da un'impostazione. Nelle impostazioni generali del progetto, l'opzione "Assert Roles on Authentication" fa in modo che i ruoli siano restituiti dall'endpoint userinfo e nei token; per averli dentro l'ID token c'è l'opzione corrispondente nelle impostazioni dell'applicazione. In alternativa li richiedi via scope. Il claim moderno è nella forma urn:zitadel:iam:org:project:{projectId}:roles, mentre la forma senza projectId resta supportata per retrocompatibilità ma è considerata superata: in uno scenario multi-tenant conviene usare quella nuova, perché dice a quale progetto appartiene ciascun ruolo.

C'è poi un'impostazione di progetto che vale la pena conoscere prima di metterla in produzione: "Check Role Assignment on Authentication" limita il login ai soli utenti che possiedono almeno un ruolo assegnato. È potente e ti evita di scrivere quel controllo nell'applicazione, ma trasforma un errore di provisioning in un utente che non riesce più a entrare, quindi va acceso sapendo cosa comporta e avendo deciso chi risponde al telefono quando succede.

Il secondo significato è il project grant: delegare un intero progetto a un'altra organizzazione, che a quel punto gestisce da sola le assegnazioni di ruolo per i propri utenti, eventualmente su un sottoinsieme dei ruoli che tu decidi di esporre. È il meccanismo pensato per gli scenari B2B, quando un partner deve amministrare i propri dipendenti senza che tu debba fare da centralino, e la possibilità di limitare i ruoli esposti lo rende anche uno strumento di segmentazione commerciale: al partner di fascia base concedi tre ruoli, a quello premium sette. Se il tuo caso è un'azienda singola non ti serve; se stai costruendo una piattaforma con rivenditori, è probabilmente la cosa più utile che Zitadel ti offre e che altrove devi costruirti a mano, con settimane di sviluppo e una superficie di bug tutta tua.

Quanto costa accorgersi tardi di aver sbagliato il confine?

Molto più di quanto sembri, e questa è la sezione che riguarda chi la decisione la firma. Il costo non è il tempo di riconfigurare: è che alcune di queste scelte non sono reversibili con una riga di configurazione. Spostare un'applicazione da un progetto a un altro significa cambiare l'identificativo che finisce nell'audience dei token, quindi toccare la configurazione di ogni componente che quei token li valida, ricreare le assegnazioni di ruolo degli utenti e, se hai già delegato il progetto a dei partner, rifare le deleghe. Su un impianto con due applicazioni è una mattinata; su un impianto con otto applicazioni e trenta organizzazioni partner è un progetto con un suo piano di rilascio e una finestra di disservizio.

Tradotto in tre domande che chiunque valuti un impianto IAM dovrebbe fare al proprio fornitore, prima che la prima applicazione sia in produzione:

  • Quali dei nostri componenti devono potersi chiamare a vicenda per conto di un utente? La risposta disegna i progetti. Se nessuno lo ha chiesto, il modello è stato disegnato per la demo, non per il sistema.
  • Dove vive l'autorizzazione, e chi la può cambiare senza toccare l'identity provider? Se la risposta è che ogni permesso di business è un ruolo nell'IdP, ogni modifica al modello commerciale diventa una modifica all'infrastruttura di identità, e la velocità del business si allinea a quella dei rilasci infrastrutturali.
  • Cosa succede se l'identity provider è irraggiungibile per un'ora? La risposta distingue un sistema in cui le sessioni già aperte continuano a funzionare da uno in cui si ferma tutto, e dipende in gran parte dalla scelta fra validazione locale e introspection.

C'è infine una voce di rischio che di solito non compare nelle valutazioni e che invece pesa: un modello di identità sbagliato spinge a inventare scorciatoie. Quando due applicazioni non riescono a fidarsi l'una dell'altra attraverso l'IdP perché stanno in perimetri diversi, la soluzione che nasce spontaneamente in sala macchine è un segreto condiviso in un header, un token statico in un file di configurazione, un parametro nell'URL di cui ci si fida. Sono esattamente i punti in cui poi si aprono gli abusi dei flussi di autorizzazione, quelli che vivono nella logica applicativa e che nessun firewall applicativo intercetta, come ho raccontato parlando di attacchi alla logica che il WAF non vede. Il modello giusto non è un vezzo di eleganza: è ciò che rende non necessarie le scorciatoie.

Come il modello si traduce in una decisione concreta

Nel lavoro di integrazione da cui viene questa serie il cliente è un operatore di posta elettronica gestita multi-tenant, con due applicazioni molto diverse fra loro: un gestionale che conosce i clienti e i loro domini, e un pannello di gestione caselle che conosce solo la directory. La domanda di partenza era banale in apparenza, cioè come far entrare l'utente nel pannello senza un secondo login. La risposta è stata interamente dettata dal modello descritto qui: due applicazioni OIDC distinte, nello stesso progetto, stessa base utenti. Il pannello chiede il token con lo scope project-audience, il gestionale valida che il proprio projectId sia nell'aud e risponde con i domini che quell'utente può gestire.

La conseguenza è che il pannello non autorizza nulla da solo. Autentica tramite l'IdP e poi chiede al gestionale, che è l'unico a conoscere il modello di business, di chi sono i domini. Questa separazione fra chi autentica e chi autorizza è il tema del pezzo dedicato all'autorità applicativa in Laravel, mentre la meccanica completa della validazione locale con la rotazione delle chiavi e l'autenticazione client tramite chiave privata è il tema del pezzo su token e audience di progetto.

Sul fronte licenza, il passaggio ad AGPL 3.0 avvenuto con la versione 3 riguarda il core, la console e la login ospitata, mentre restano sotto Apache 2.0 le definizioni protobuf, le API, gli SDK, la documentazione e i chart di deploy. Per chi si limita a self-hostare l'IdP e a integrarlo via OIDC non cambia nulla di pratico, e vale la pena dirlo chiaramente perché la sigla AGPL fa scattare allarmi spesso ingiustificati nelle valutazioni legali. Diventa invece rilevante se pensi di incorporarlo in un prodotto tuo o di ridistribuirne una versione modificata, e in quel caso esiste una licenza commerciale. È una cosa da sapere prima di scegliere, non dopo, e da mettere per iscritto nella valutazione anziché lasciarla all'assunzione di qualcuno.

Il modello che ho descritto qui è piccolo: cinque livelli, due tipi di grant, uno scope che sposta un identificativo dentro l'audience. Ma è l'ossatura su cui poggia tutto il resto, e sbagliarlo costa una migrazione, non un refactoring, perché l'appartenenza di un'applicazione a un progetto non si cambia con una riga di configurazione. Se ti stai avvicinando adesso a un identity provider self-hosted, il tempo speso a disegnare i confini prima di creare la prima applicazione è il tempo meglio investito dell'intero progetto, e non richiede giorni: richiede di elencare i componenti, dire quali devono fidarsi l'uno dell'altro, e trattare quell'elenco come vincolante. Se invece hai già un impianto che ti dà errori di audience che nessuno sa spiegare, o due applicazioni che si parlano con un segreto condiviso perché il modello non le ha mai messe nello stesso perimetro, scrivimi e vediamolo insieme: quasi sempre è un problema di struttura e non di codice, e si diagnostica guardando un token e la console dell'IdP per venti minuti. Nel prossimo articolo della serie metto Zitadel a confronto con Keycloak, con i criteri veri che ho usato per sceglierlo e senza fare finta che non abbia costi operativi.

Ultima modifica: