OAuth2 con Zitadel: il token è la credenziale, il progetto è il confine di fiducia

OAuth2 con Zitadel: il token è la credenziale, il progetto è il confine di fiducia

C'è un momento preciso, in ogni integrazione fra due applicazioni, in cui qualcuno propone di mettere una chiave API condivisa in un header. È una proposta ragionevole, si implementa in mezz'ora e funziona. Poi quella chiave finisce in un file di configurazione su due macchine, in un gestore di segreti se va bene, nel repository se va male, e da quel momento la sicurezza dell'integrazione è la sicurezza del posto meno protetto in cui quella stringa è stata copiata. Nell'impianto da cui viene questa serie ho trovato l'evidenza di quanto quel meccanismo invecchi male: esisteva un sistema di chiavi API per utente, previsto e implementato anni prima, con cinque utenti abilitati e zero chiavi effettivamente in uso. Era stato costruito, non era mai stato adottato, ed era rimasto lì.

Nei cinque articoli precedenti ho costruito il contesto: il modello di organizzazioni, progetti e grant, il confronto con Keycloak e quello con i servizi gestiti, poi l'installazione nativa su Debian e l'alta disponibilità con aggiornamenti senza fermo. Da qui in avanti si entra nel codice delle applicazioni, e il primo concetto da mettere a fuoco è quello che rende superfluo il segreto condiviso: il token è la credenziale, e prova chi sta chiamando meglio di qualunque chiave che tu possa distribuire.

Cosa significa esattamente che il token è la credenziale

Significa che l'identità di chi sta chiamando non è più un parametro della richiesta. Nel modello con la chiave condivisa, il chiamante dice "sono io, ecco la chiave, e sto agendo per conto dell'utente 4711": il numero dell'utente è un dato che il chiamante scrive, e il ricevente si fida perché la chiave era giusta. Chiunque abbia la chiave può scrivere qualunque numero.

Nel modello a token, il chiamante presenta l'access token che l'identity provider ha emesso per quell'utente, e il ricevente ne estrae il soggetto dopo aver verificato la firma. Non c'è nessun campo in cui dichiarare per conto di chi stai agendo: l'identità è dentro la struttura firmata, e alterarla richiederebbe la chiave privata dell'identity provider.

La differenza è più profonda di quanto sembri e vale la pena esplicitarla, perché è ciò che convince chi deve approvare il lavoro. Con la chiave condivisa, la compromissione del chiamante è la compromissione di tutti gli utenti: chi ruba la chiave può impersonare chiunque. Con il token, la compromissione del chiamante espone i token che quel chiamante detiene in quel momento, per gli utenti che stanno usando il servizio, e per la durata residua di quei token. È un raggio d'azione limitato nel tempo e nell'ampiezza, e nella valutazione del rischio è una differenza di categoria, non di grado.

La riformulazione che uso quando devo spiegarlo a chi non scrive codice: una chiave API è una password che due sistemi si scambiano, e vale finché qualcuno non la cambia. Un token è un documento firmato con una scadenza, e chi lo riceve può verificarne l'autenticità senza chiedere niente a nessuno.

Perché in questa architettura non esiste nessun client secret?

Perché è stato sostituito da una firma. Il segreto simmetrico del client OAuth2 qui non esiste: al suo posto c'è private_key_jwt, il meccanismo descritto dalla RFC 7523, in cui il client si autentica verso l'identity provider firmando un'asserzione con la propria chiave privata invece di presentare una stringa che entrambi conoscono.

La differenza operativa è che la chiave privata non lascia mai il server. Non viaggia nelle richieste, non finisce nei log di un proxy, non si può copiare guardando una variabile d'ambiente in un dump. L'identity provider conosce solo la parte pubblica.

L'asserzione è un oggetto piccolo e con una vita brevissima. Questa è la costruzione nel gestionale in PHP, e vale la pena leggere i campi uno per uno:

$payload = [
    'iss' => $this->clientId,
    'sub' => $this->clientId,
    'aud' => $audience,                     // scheme://host dell'identity provider
    'jti' => bin2hex(random_bytes(16)),     // identificatore univoco, anti-replay
    'iat' => $now,
    'nbf' => $now,
    'exp' => $now + 300,                    // cinque minuti, non di più
];

$headers = ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => $this->keyId];

return JWT::encode($payload, $privateKey, 'RS256', $this->keyId, $headers);

Emittente e soggetto sono entrambi l'identificativo del client, perché il client sta dichiarando qualcosa su sé stesso. L'audience è l'identity provider, perché è a lui che stai parlando. Il jti casuale e la scadenza a cinque minuti servono insieme: anche se qualcuno intercettasse l'asserzione, avrebbe una finestra brevissima e un identificativo già consumato. Il kid nell'intestazione dice all'identity provider quale delle chiavi registrate per questo client deve usare per verificare, ed è ciò che rende possibile ruotare le chiavi senza fermare niente.

Sul lato Node dell'integrazione la stessa cosa è implementata con il modulo crittografico nativo della piattaforma, senza aggiungere dipendenze, e produce un'asserzione identica:

const header  = { alg: 'RS256', typ: 'JWT', kid: privateKeyId };
const payload = {
  iss: clientId, sub: clientId, aud: audience,
  jti: crypto.randomBytes(16).toString('hex'),
  iat: now, nbf: now, exp: now + 300,
};

const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const signature = crypto.createSign('RSA-SHA256').update(signingInput).sign(privateKey);
return `${signingInput}.${b64url(signature)}`;

Il fatto che due applicazioni scritte in linguaggi diversi producano la stessa cosa in venti righe ciascuna è il miglior argomento a favore di questo meccanismo: non è complicato, è solo poco raccontato.

La richiesta del token, e lo scope che decide tutto

Perché il token serva a chiamare un'altra applicazione, va chiesto con lo scope giusto. È l'unico punto in cui un errore non produce un messaggio comprensibile, e infatti è il punto in cui l'ho sbagliato la prima volta:

const scopeFor = ({ orgId, projectId }) => {
  const scopes = [
    'openid', 'profile', 'email',
    'urn:zitadel:iam:user:metadata',
    'urn:zitadel:iam:user:resourceowner',
    `urn:zitadel:iam:org:id:${orgId}`,
  ];
  if (projectId) scopes.push(`urn:zitadel:iam:org:project:id:${projectId}:aud`);
  return scopes.join(' ');
};

L'ultima riga è quella che conta. Senza di essa il token arriva, è perfettamente valido, l'utente è autenticato e ogni chiamata all'altra applicazione viene respinta con un errore di audience. Il token è giusto per il client che lo ha chiesto ed è sbagliato per il destinatario, e questo dalla parte di chi riceve si legge come un rifiuto secco.

Vale la pena raccontare come l'ho scoperto, perché è istruttivo: avevo copiato la costruzione dello scope dal codice di autenticazione dell'altra applicazione, che quello scope non ne ha bisogno perché non chiama nessuno. Copiare un pezzo di codice corretto in un contesto in cui manca un requisito è un errore che non si vede in revisione, e la lezione che ne ho tratto è finita in un test: quello che va verificato non è solo il token che ricevi, è lo scope che chiedi.

La validazione locale, riga per riga

Dal lato di chi riceve, il lavoro è verificare che il token sia autentico, valido e destinato a lui. Senza chiamare nessuno.

// firma verificata con le chiavi pubbliche dell'IdP; scadenze gestite dalla libreria
try {
    $token = JWT::decode($jwt, JWK::parseKeySet($this->getCachedJwks()));
} catch (\Firebase\JWT\ExpiredException $e) {
    throw new IamAuthenticationException(IamAuthenticationException::TOKEN_EXPIRED, [...]);
} catch (\Exception $e) {
    // riprova UNA volta con le chiavi appena scaricate: potrebbe essere una rotazione
    try {
        $token = JWT::decode($jwt, JWK::parseKeySet($this->getCachedJwks(false)));
    } catch (\Firebase\JWT\ExpiredException $e2) {
        throw new IamAuthenticationException(IamAuthenticationException::TOKEN_EXPIRED, [...]);
    } catch (\Exception $e2) {
        throw new IamAuthenticationException(IamAuthenticationException::SIGNATURE_VERIFICATION_FAILED, [...]);
    }
}

// emittente: confronto sull'host, tollerante alla barra finale
if (!isset($token->iss) || strtolower((string) parse_url($this->authUrl, PHP_URL_HOST))
        !== strtolower((string) parse_url($token->iss, PHP_URL_HOST))) {
    throw new IamAuthenticationException(IamAuthenticationException::INVALID_ISSUER);
}

// audience: deve contenere il projectId, non il clientId
$aud = isset($token->aud) ? (is_array($token->aud) ? $token->aud : [$token->aud]) : [];
if (!in_array((string) $projectId, array_map('strval', $aud), true)) {
    throw new IamAuthenticationException(IamAuthenticationException::INVALID_AUDIENCE, [...]);
}

Quattro dettagli meritano attenzione, perché sono quelli che distinguono una validazione che regge da una che sembra reggere.

Il retry sulla rotazione delle chiavi, che non ritenta mai un token scaduto. Un fallimento di firma può voler dire due cose molto diverse: il token è falso, oppure l'identity provider ha ruotato le chiavi e la copia in cache è vecchia. Riscaricare le chiavi e ritentare una volta risolve il secondo caso senza rendere il sistema fragile. La sottigliezza è la doppia intercettazione della scadenza: un token scaduto non viene mai ritentato, perché sarebbe scaduto anche con le chiavi nuove e l'unico effetto sarebbe una richiesta di rete inutile a ogni token vecchio che arriva. Senza quella distinzione, un client con l'orologio sbagliato diventerebbe un piccolo attacco di amplificazione contro il tuo identity provider.

Il confronto sull'emittente fatto sull'host. Le implementazioni divergono sulla barra finale dell'URL, e un confronto fra stringhe complete produce un rifiuto che nessuno riesce a spiegare. Confrontare l'host è più permissivo di quanto la specifica richieda in senso stretto, ed è una scelta deliberata: la protezione reale contro un emittente ostile è che la firma non tornerebbe comunque.

L'audience normalizzata ad array. Il claim può arrivare come stringa singola o come lista, a seconda di quante audience ci sono. Trattarlo sempre come lista elimina una classe intera di errori che si manifestano solo quando un token ne ha una sola.

Il confronto stretto sul valore. Gli identificativi sono numeri lunghi trasportati come stringhe, e un confronto permissivo fra tipi diversi in PHP è il tipo di cosa che funziona per anni e poi produce una sorpresa. La conversione esplicita a stringa e il confronto stretto costano una riga.

Se stai integrando due applicazioni attraverso un identity provider e vuoi che la validazione dei token la riveda qualcuno che l'ha già scritta per un sistema in produzione, nel mio profilo professionale trovi il percorso su architetture API, OAuth2 e sicurezza applicativa. E se hai un token che ti sta arrivando e non capisci perché venga rifiutato, il mio strumento di audit dei JWT ti mostra aud, iss e le scadenze in due secondi: nella maggior parte dei casi la risposta è lì.

Quali claim guardi davvero, e in che ordine

Un access token in questa architettura arriva con parecchi campi, ma quelli su cui si prendono decisioni sono pochi. Metterli in tabella aiuta chi deve scrivere il controllo e chi deve verificarlo in fase di revisione.

ClaimCosa contienePerché lo guardi
issl'identity provider che ha emesso il tokenrifiuti token emessi da un'altra istanza, anche se firmati bene
audqui l'identificativo del progettoè il controllo che stabilisce che il token era destinato a te
subl'identificativo dell'utente presso l'identity providerè l'unica cosa che usi per risolvere l'utente applicativo
expscadenzala libreria la applica, tu decidi la tolleranza
nbf, iatnon prima di, emesso ilproteggono da token emessi nel futuro per orologi disallineati

Il campo su cui insisto è sub, perché è quello che viene usato male più spesso. È l'identificativo dell'utente presso l'identity provider, non l'indirizzo di posta e non il nome utente. La tentazione di risolvere l'utente locale con l'indirizzo di posta è forte, perché è leggibile e sembra stabile: non lo è. Una persona cambia indirizzo, due persone possono averne uno simile, e un indirizzo può essere riassegnato. Il sub è opaco, brutto da leggere e non cambia mai, che è esattamente ciò che serve a una chiave di collegamento.

Sulla tolleranza vale la pena spendere una riga, perché è una di quelle impostazioni che si mettono a caso. Una piccola tolleranza sulle scadenze, nell'ordine di qualche decina di secondi, assorbe il disallineamento fisiologico degli orologi fra macchine diverse. Alzarla molto è una pessima idea: significa accettare token scaduti, e la finestra che apri è esattamente il valore che hai scritto. La soluzione corretta al problema degli orologi non è alzare la tolleranza, è sincronizzare gli orologi.

Cosa succede quando il token scade, e perché è una buona notizia

Un access token dura poco per progetto. È la caratteristica che rende accettabile il fatto di non poterlo revocare istantaneamente quando lo validi in locale: il danno di un token rubato è limitato dalla sua scadenza, e più è corta più il modello regge.

La conseguenza pratica per chi scrive il client è che va gestito il caso del token scaduto durante l'uso, non solo all'inizio. Il pattern che uso è banale e va scritto una volta sola in un punto solo: la chiamata verso l'altra applicazione riconosce la risposta di non autorizzato, rinnova il token e ritenta una volta. Se il rinnovo fallisce, l'utente torna al login. Quello che non va fatto è ritentare in ciclo, che è il modo più efficace di trasformare una credenziale scaduta in un piccolo attacco contro il proprio identity provider.

C'è una scelta di design che vale la pena dichiarare perché ha conseguenze sulla sicurezza: dove tieni il token. Nell'integrazione da cui viene questa serie il token vive nella sessione lato server dell'applicazione che lo ha ottenuto, e non raggiunge mai il browser. Il browser ha solo un cookie di sessione. È una scelta che chiude in partenza una categoria intera di problemi, quella dei token esfiltrabili con codice iniettato nella pagina, e che si può fare solo perché entrambe le applicazioni hanno un server. Un'applicazione a pagina singola che parla direttamente con l'identity provider non ha questo lusso, ed è la ragione per cui in quel contesto il flusso con verifica del codice diventa obbligatorio invece che consigliato.

Cosa cambia per chi deve approvare questa architettura

Tradotto per chi la decisione la firma, e non la scrive.

La superficie di segreti da custodire si riduce, non aumenta. Al posto di una chiave condivisa fra due sistemi, che vive in due posti e va ruotata in due posti contemporaneamente, ci sono due chiavi private che non vengono mai scambiate e che si ruotano indipendentemente. La rotazione di una non richiede una finestra di manutenzione coordinata, che è il motivo per cui le chiavi condivise nella pratica non vengono quasi mai ruotate.

La revoca cambia natura, e va capita. Con una chiave condivisa, revocare significa cambiare la stringa e riavviare due sistemi. Con i token, la revoca dell'accesso di una persona avviene sull'identity provider e ha effetto entro la scadenza dei token già emessi. Se questa finestra è inaccettabile per un dato tipo di permesso, la risposta corretta non è cambiare il meccanismo di autenticazione: è non mettere quel permesso dentro il token, ed è il tema dell'articolo sulla revoca immediata più avanti in questa serie.

L'audit diventa possibile davvero. Ogni chiamata porta l'identità di una persona verificabile crittograficamente, quindi i registri dicono chi ha fatto cosa. Con una chiave di servizio condivisa i registri dicono che è stato "il sistema", che nella pratica significa che l'audit non esiste. Per chi ha obblighi di tracciabilità sugli accessi, questa riga da sola giustifica il lavoro.

La dipendenza dall'identity provider è reale e va dichiarata. Se l'identity provider è irraggiungibile, i login nuovi non avvengono. Le sessioni già stabilite continuano a funzionare finché i loro token sono validi, proprio perché la validazione è locale, ma il servizio è degradato. È il rovescio della medaglia della centralizzazione, e va messo nel registro dei rischi accanto alla ridondanza che si è scelta per mitigarlo.

Un compromesso che ho accettato, e uno che no

Le due decisioni che meritano di essere dichiarate, perché in un articolo tecnico i compromessi taciuti valgono più delle cose fatte bene.

Sull'id_token la firma non viene verificata. L'oggetto che descrive l'utente dopo il login viene decodificato senza controllo crittografico, fidandosi del fatto che è stato ricevuto direttamente dall'endpoint del token, su TLS, in una risposta a una richiesta che abbiamo fatto noi. È una posizione difendibile nel flusso con codice di autorizzazione, ed è esplicitamente contemplata dalle specifiche in quello scenario. Ma non è gratis, e il modo in cui l'ho reso accettabile è che il codice compensa con altri controlli: emittente, appartenenza all'organizzazione attesa, dominio primario dell'organizzazione, e il valore casuale generato all'inizio del giro che deve tornare indietro identico. Quest'ultimo è la difesa contro il riutilizzo di un id_token catturato altrove.

La distinzione da tenere ferma, e che scrivo perché la vedo confusa spesso: l'access token, quello che viaggia fra le applicazioni, la firma la verifica sempre. Il compromesso vale solo per l'oggetto che descrive l'utente al momento del login e che non viene mai usato per autorizzare niente.

Il client che presenta il token non viene identificato. Il confine di fiducia è il progetto, quindi qualunque client appartenente a quel progetto può presentare un token con la giusta audience. Non controllo quale sia. È una scelta coerente con il modello e la accetto, ma va detto cosa comporta: se domani un terzo client entra nel progetto, entra anche in questo perimetro di fiducia. Il controllo che chiuderebbe la porta esiste, è il claim che indica il client autorizzato, e la ragione per cui non l'ho aggiunto è che avrebbe trasformato una proprietà strutturale in una lista da mantenere. È esattamente il tipo di decisione che va scritta in un commento accanto alla rotta, così che chi aggiunge il terzo client la incontri invece di scoprirla.

I test che dimostrano che i casi sbagliati vengono respinti

Una validazione che non ha test che la attaccano è una speranza. La suite che copre questa parte non usa mock del validatore: genera una coppia di chiavi RSA effimera, inietta le chiavi pubbliche nella cache come se venissero dall'identity provider, e firma token veri per ogni scenario.

I casi che verifica, e ciascuno corrisponde a un modo reale di sbagliare: un token con l'identificativo del progetto nell'audience viene accettato; un token con l'identificativo del client al posto di quello del progetto viene rifiutato, ed è il caso che riproduce l'errore di scope raccontato sopra; un token scaduto viene rifiutato; un token con un emittente diverso viene rifiutato; un token la cui firma è stata manomessa viene rifiutato.

Vale la pena spiegare perché la coppia di chiavi effimera è la scelta giusta invece di firmare con una chiave fissa messa fra i file di test. Una chiave nel repository è un segreto pubblicato, anche se è solo di prova: prima o poi qualcuno la copia in un ambiente vero perché "tanto è quella dei test". Generarla a ogni esecuzione costa qualche millisecondo e toglie il problema alla radice. È lo stesso ragionamento per cui i dati di prova non contengono mai indirizzi reali.

Il valore di questa suite non è che aumenta un numero di copertura. È che il giorno in cui qualcuno tocca la validazione per far passare un caso urgente, i test glielo impediscono. Su un controllo di sicurezza, la protezione contro la fretta futura vale più della verifica del presente: il codice che scrivi oggi lo rivedi lucido, quello che qualcuno modificherà sotto pressione fra otto mesi no, e i test sono l'unica cosa che sarà ancora lì a dire di no.

Accanto a questi c'è una prova che percorre la catena intera senza sostituire nulla: chiedi il token con lo scope corretto, verifica che l'audience risultante sia quella attesa, valida, risolvi l'utente e ottieni i suoi permessi. È il test che avrebbe intercettato lo scope mancante il primo giorno, e la ragione per cui adesso esiste.

Quello che questo articolo lascia aperto è il passo successivo, ed è il più interessante: una volta che il token ha provato chi sta chiamando, resta da stabilire cosa può fare, e quella risposta l'identity provider non ce l'ha. Vive nel gestionale, insieme ai contratti e alle anagrafiche. Come si costruisce quel pezzo, e perché non serve nessun account di servizio per farlo, è il tema del prossimo articolo. Se nel frattempo stai integrando due sistemi e hai in mente di risolverla con una chiave condivisa in un header, scrivimi prima di scriverla: nella maggior parte dei casi c'è già un token che porta la stessa informazione con molte più garanzie, e toglierla dopo costa più che non metterla adesso.

Ultima modifica: