Quando si lavora con un'architettura a microservizi, prima o poi ci si trova davanti a una domanda che sembra banale, ma che in realtà è uno dei problemi più importanti da risolvere: come fa un servizio a dimostrare a un altro servizio di essere davvero quello che dichiara di essere?

Immaginiamo di avere un'applicazione composta da dieci microservizi. Abbiamo un ai-service che deve interrogare un search-service, un content-service che deve recuperare informazioni da identity-service e magari un worker che deve elaborare periodicamente alcuni dati. Tutti questi componenti devono comunicare tra loro, ma non possiamo permettere che qualsiasi processo presente nella rete interna possa invocare liberamente qualsiasi endpoint.

La prima soluzione che viene in mente è utilizzare una chiave segreta condivisa, una password applicativa oppure una coppia di credenziali client_id e client_secret. È un sistema che può funzionare, ma quando il numero dei servizi cresce iniziano a emergere problemi di distribuzione, rotazione e gestione delle credenziali. Soprattutto, dobbiamo evitare che la compromissione di un singolo servizio metta a rischio l'identità di tutti gli altri.

La soluzione che preferisco è assegnare a ogni servizio una propria identità crittografica, utilizzando una coppia di chiavi RSA. La chiave privata rimane esclusivamente nel servizio che la possiede, mentre la chiave pubblica viene registrata presso un servizio centrale di identità. Quest'ultimo diventa l'autorità che verifica le identità applicative e rilascia i token utilizzabili per comunicare con gli altri microservizi.

In questo articolo vediamo come funziona il modello, perché non dobbiamo distribuire le chiavi private dei servizi, come vengono generati e verificati i token JWT e come possiamo applicare lo stesso meccanismo anche a job schedulati, worker e processi automatici. Vedremo anche come gestire gli scope, la rotazione delle chiavi e i principali problemi di sicurezza.

L'architettura: un'identità per ogni servizio

Prima di entrare nei dettagli tecnici, voglio chiarire come sono organizzati i componenti e, soprattutto, dove si trovano le chiavi.

Immaginiamo di avere tre microservizi: ai-service, search-service e content-service. Ognuno possiede una propria coppia di chiavi RSA. La chiave privata viene conservata esclusivamente dal servizio che la utilizza, mentre quella pubblica viene registrata nel database di identity-service.

Identity possiede anche una propria coppia di chiavi, utilizzata per firmare gli access token. Le chiavi pubbliche corrispondenti vengono pubblicate attraverso un endpoint JWKS, così che tutti i microservizi possano verificare autonomamente i token ricevuti.

Il diagramma seguente mostra la separazione tra le identità dei servizi e le chiavi utilizzate per firmare gli access token.

flowchart TB
    ID["IDENTITY SERVICE"]

    subgraph REG["Registro dei client"]
        PA["Chiave pubblica AI"]
        PS["Chiave pubblica SEARCH"]
        PC["Chiave pubblica CONTENT"]
    end

    subgraph SIGN["Firma degli access token"]
        PRIV["Chiave privata Identity"]
        JWKS["JWKS: chiavi pubbliche Identity"]
    end

    ID --> REG
    ID --> SIGN
    PRIV -->|"Firma i token"| ID
    JWKS -->|"Pubblica le chiavi"| AI
    JWKS -->|"Pubblica le chiavi"| SE
    JWKS -->|"Pubblica le chiavi"| CO

    subgraph SERVICES["Microservizi"]
        AI["ai-service<br/>Privata AI"]
        SE["search-service<br/>Privata SEARCH"]
        CO["content-service<br/>Privata CONTENT"]
    end

    AI -->|"Assertion firmata con privata AI"| ID
    ID -->|"Verifica con pubblica AI"| PA

    SE -->|"Assertion firmata con privata SEARCH"| ID
    ID -->|"Verifica con pubblica SEARCH"| PS

    CO -->|"Assertion firmata con privata CONTENT"| ID
    ID -->|"Verifica con pubblica CONTENT"| PC

La cosa importante da osservare è che i microservizi non devono conoscere le chiavi private degli altri componenti. ai-service non deve possedere la chiave pubblica di search-service, così come content-service non deve conoscere la chiave privata di Identity.

Ogni servizio conosce soltanto la propria chiave privata e le chiavi pubbliche di Identity necessarie a verificare gli access token.

Identity, invece, conserva il registro delle chiavi pubbliche dei client e utilizza la propria chiave privata per firmare i token. Abbiamo quindi due responsabilità crittografiche distinte: riconoscere chi sta chiedendo un token e permettere ai destinatari di verificare il token emesso.

Il problema: autenticare i servizi senza condividere segreti

Quando parliamo di autenticazione siamo abituati a pensare a un utente che inserisce username e password, riceve un token e lo utilizza per accedere alle risorse. In un'architettura a microservizi, però, non tutte le richieste arrivano da una persona.

Un servizio può dover chiamare un altro servizio per completare un'operazione. Un processo schedulato può dover elaborare dati durante la notte. Un worker può ricevere un messaggio da una coda e invocare un'API interna. In tutti questi casi non esiste un utente che possa effettuare il login: è il componente software stesso che deve dimostrare la propria identità.

Una soluzione semplice consiste nell'assegnare a ogni applicazione un client_id e un client_secret. Il servizio utilizza queste credenziali per autenticarsi presso Identity e ottenere un access token. È il modello OAuth 2.0 Client Credentials, ampiamente utilizzato proprio per le comunicazioni tra applicazioni.

Il problema non è il protocollo in sé, ma il modo in cui gestiamo il segreto. Il client_secret deve essere conservato, distribuito, protetto e ruotato. Se viene compromesso, chi ne entra in possesso può tentare di impersonare il client. Inoltre, se iniziamo a utilizzare lo stesso segreto per più servizi, perdiamo completamente la separazione delle identità.

Preferisco quindi un modello nel quale ogni servizio possiede una propria coppia di chiavi asimmetriche. Non abbiamo più una password condivisa: abbiamo una chiave privata che dimostra il possesso dell'identità e una chiave pubblica che permette a Identity di verificarlo.

Questa distinzione è fondamentale. Non stiamo semplicemente sostituendo una password con un'altra password più complicata. Stiamo utilizzando la crittografia asimmetrica per separare chi dimostra la propria identità da chi deve verificarla.

La coppia di chiavi RSA: privata per firmare, pubblica per verificare

Per ogni microservizio generiamo una coppia di chiavi RSA. La chiave privata viene utilizzata per firmare un'asserzione JWT, mentre la chiave pubblica corrispondente viene registrata presso Identity.

Prendiamo come esempio ai-service. Il servizio possiede la propria chiave privata, che utilizza per firmare digitalmente un documento contenente la propria identità e i dettagli della richiesta.

Identity possiede invece la chiave pubblica corrispondente. Quando riceve l'asserzione, utilizza quella chiave per verificare la firma. Se la verifica riesce, significa che l'asserzione è stata firmata da chi possiede effettivamente la chiave privata associata all'identità registrata.

La chiave privata non deve mai lasciare il servizio che la utilizza. Non viene inviata a Identity, non viene condivisa con gli altri microservizi e non deve essere inserita nel repository Git. La chiave pubblica, invece, può essere distribuita perché non permette di firmare nuove asserzioni: serve esclusivamente a verificare quelle già firmate.

Qui bisogna chiarire un punto che spesso genera confusione. Le chiavi RSA dei microservizi non sono le stesse chiavi utilizzate da Identity per firmare gli access token.

Abbiamo due livelli crittografici distinti:

  • Le chiavi private dei microservizi vengono utilizzate per dimostrare la loro identità a Identity.
  • La chiave privata di Identity viene utilizzata per firmare gli access token che gli altri servizi dovranno verificare.

Questa separazione permette di gestire le identità applicative senza distribuire le chiavi private dei singoli servizi e senza obbligare ogni microservizio a conoscere le credenziali degli altri.

Il flusso di autenticazione: dall'asserzione al token

La comunicazione tra due servizi avviene in due fasi. Nella prima il chiamante dimostra la propria identità a Identity e ottiene un access token. Nella seconda utilizza quel token per chiamare il destinatario.

Vediamo cosa succede concretamente quando ai-service deve interrogare search-service.

Prima fase: il servizio dimostra la propria identità

Quando ai-service deve chiamare search-service, genera una JWT assertion firmata con la propria chiave privata.

Questa asserzione non è ancora il token che verrà utilizzato per accedere all'API. È una dichiarazione firmata che serve a dimostrare a Identity chi sta effettuando la richiesta.

L'asserzione contiene informazioni come l'identità del client, il destinatario della richiesta, il momento di emissione, la scadenza e un identificativo univoco. Quest'ultimo è particolarmente importante perché permette di impedire il riutilizzo della stessa asserzione.

Un payload semplificato potrebbe essere il seguente:

{
  "iss": "ai-service",
  "sub": "ai-service",
  "aud": "https://identity.example.com/oauth2/token",
  "iat": 1793510000,
  "exp": 1793510300,
  "jti": "b9f4d8a1-7e32-4d42-9a11-123456789abc"
}

La firma viene generata utilizzando la chiave privata del servizio. Nell'header JWT possiamo inserire anche il kid, cioè l'identificativo della chiave utilizzata. Questo permette a Identity di individuare la chiave pubblica corretta quando un client dispone temporaneamente di più chiavi durante una rotazione.

Il servizio invia quindi una richiesta all'endpoint OAuth di Identity, utilizzando il grant type Client Credentials e l'autenticazione del client tramite JWT assertion.

La richiesta, in un'implementazione basata su private_key_jwt, ha una struttura simile a questa:

POST /oauth2/token HTTP/1.1
Host: identity.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIs...
&scope=search:read

Identity riceve la richiesta e recupera dal proprio registro la chiave pubblica associata ad ai-service. Utilizza questa chiave per verificare la firma dell'asserzione.

La verifica crittografica è soltanto il primo controllo. Identity deve verificare anche la scadenza, l'audience, l'identità del client, l'identificativo univoco e gli scope richiesti. Non deve essere possibile chiedere autorizzazioni superiori a quelle assegnate durante la registrazione del client.

Se tutti i controlli hanno esito positivo, Identity rilascia un access token JWT firmato con la propria chiave privata.

Seconda fase: il servizio chiama il destinatario

A questo punto ai-service possiede un access token valido e può utilizzarlo per chiamare search-service.

La richiesta contiene il token nell'header Authorization:

POST /internal/research HTTP/1.1
Host: search-service
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/json

Il destinatario non deve conoscere la chiave privata di ai-service e non deve neppure contattare Identity a ogni richiesta per chiedere se il token è valido.

Può verificare autonomamente il token utilizzando la chiave pubblica di Identity, normalmente pubblicata attraverso un endpoint JWKS. Una volta ottenuta la chiave, il servizio può conservarla in cache e riutilizzarla per verificare i token successivi.

Il destinatario controlla la firma, la scadenza, l'issuer, l'audience e le autorizzazioni. Soltanto se tutti i controlli sono superati permette l'esecuzione dell'operazione richiesta.

Questo è il punto centrale dell'architettura: Identity è responsabile dell'emissione dei token, mentre ogni microservizio è responsabile della verifica dei token che riceve.

Non abbiamo bisogno di una chiamata sincrona a Identity per ogni singola richiesta applicativa. Questo riduce la dipendenza operativa dal servizio centrale e permette ai microservizi di verificare localmente le credenziali ricevute.

Il diagramma di sequenza

Adesso che abbiamo visto i due passaggi, possiamo rappresentare l'intera comunicazione con un diagramma di sequenza. È importante osservare che la chiave privata del chiamante viene utilizzata soltanto nella prima fase, mentre il token restituito da Identity viene verificato dal destinatario con una chiave completamente diversa.

sequenceDiagram
    autonumber
    participant AI as ai-service
    participant ID as identity-service
    participant SE as search-service

    Note over AI: Possiede la chiave privata AI
    Note over ID: Registro delle chiavi pubbliche dei client

    AI->>AI: Genera JWT assertion
    AI->>AI: Firma con la chiave privata AI
    AI->>ID: POST /oauth2/token + assertion
    ID->>ID: Recupera la chiave pubblica AI
    ID->>ID: Verifica firma, exp, aud, jti e scope
    ID->>ID: Firma access token con la chiave privata Identity
    ID-->>AI: Access token JWT (5 minuti)

    Note over AI,SE: Il token viene riutilizzato fino alla scadenza

    AI->>SE: POST /internal/research + Bearer token
    SE->>SE: Recupera chiave pubblica Identity da JWKS
    SE->>SE: Verifica firma, exp, aud e scope
    SE-->>AI: Risultato della ricerca

Il token può essere riutilizzato per più chiamate allo stesso destinatario fino a poco prima della scadenza. Non è necessario richiederne uno nuovo per ogni singola richiesta HTTP.

Dieci microservizi: dobbiamo distribuire novanta chiavi?

Questa è probabilmente la domanda più importante quando si ragiona sulla scalabilità del sistema.

Se ogni servizio deve autenticarsi presso gli altri, viene spontaneo pensare che ciascuno debba possedere le chiavi pubbliche di tutti gli altri. Con dieci microservizi avremmo quindi dieci chiavi private e novanta chiavi pubbliche da distribuire tra i componenti.

Un modello del genere diventerebbe rapidamente complicato da gestire.

Ma non è quello che facciamo.

Ogni microservizio possiede soltanto la propria chiave privata e conosce le chiavi pubbliche di Identity. È Identity che conserva le chiavi pubbliche di tutti i client registrati.

Facciamo un esempio:

ComponenteChiavi possedute
ai-servicePrivata AI, pubblica Identity
search-servicePrivata Search, pubblica Identity
content-servicePrivata Content, pubblica Identity
reporting-jobPrivata Reporting, pubblica Identity
identity-servicePrivata Identity, pubbliche AI, Search, Content e Reporting

Il funzionamento è sempre identico. ai-service firma con la propria chiave privata e Identity verifica con la corrispondente chiave pubblica. Successivamente Identity firma il token con la propria chiave privata e search-servicelo verifica con la chiave pubblica di Identity.

Se aggiungiamo un undicesimo servizio, dobbiamo semplicemente generare una nuova coppia di chiavi, registrare la chiave pubblica presso Identity e configurare il nuovo componente.

Non dobbiamo modificare gli altri dieci servizi, distribuire nuove chiavi pubbliche tra i container o aggiornare le configurazioni dei destinatari.

Questo è uno dei vantaggi più importanti del modello: la complessità della gestione delle identità rimane concentrata nell'autorità centrale, mentre i singoli servizi mantengono un insieme minimo di credenziali.

Il registro dei client: dove vengono conservate le identità

Identity deve sapere quali servizi possono autenticarsi e quali autorizzazioni possono richiedere.

Per questo motivo utilizziamo un registro dei client applicativi. Ogni registrazione contiene almeno un identificativo univoco del client, una o più chiavi pubbliche associate, gli scope consentiti e le audience verso cui il client può ottenere token.

Un possibile modello semplificato è il seguente:

Client IDChiave pubblicaScope ammessiAudience ammesse
ai-serviceRSA-AIsearchsearch-service
content-serviceRSA-CONTENTidentity:usersidentity-service
reporting-jobRSA-REPORTreportsreporting-service

Il registro non serve soltanto a riconoscere i servizi. Definisce anche i loro confini operativi.

Per esempio, ai-service può essere autorizzato a richiedere search:read, ma non necessariamente search:admin o identity:users:read.

La richiesta di uno scope non significa che il client ne abbia automaticamente diritto. Identity deve confrontare ogni autorizzazione richiesta con quelle effettivamente registrate.

Questo principio è fondamentale: il client può chiedere un'autorizzazione, ma è Identity a decidere se concederla.

Il registro deve essere amministrato attraverso procedure controllate. L'aggiunta di un client, la sostituzione di una chiave pubblica e la modifica degli scope sono operazioni sensibili e devono essere protette da autorizzazioni amministrative specifiche.

Il token di servizio: audience, scope e durata

Una volta verificata l'identità del client, Identity genera un access token.

Il token contiene le informazioni necessarie al destinatario per stabilire chi sta effettuando la richiesta e quali operazioni può eseguire.

Un esempio semplificato:

{
  "iss": "https://identity.example.com",
  "sub": "ai-service",
  "aud": ["search-service"],
  "token_type": "service",
  "scope": "search:read",
  "iat": 1793510000,
  "exp": 1793510300
}

Il claim iss identifica l'autorità che ha emesso il token. Il claim sub identifica il servizio chiamante. Il claim aud indica il destinatario previsto. Lo scope definisce le operazioni autorizzate, mentre exp stabilisce quando il token non sarà più utilizzabile.

Nel nostro modello utilizziamo anche un identificativo del tipo di token, token_type, per distinguere le credenziali applicative da quelle degli utenti.

Questa separazione è importante. Non vogliamo che un token ottenuto da un utente amministratore possa essere utilizzato per accedere agli endpoint riservati alle comunicazioni interne tra servizi. Allo stesso modo, un token applicativo non deve trasformarsi in una credenziale utilizzabile per le API riservate agli utenti.

Il destinatario deve quindi controllare non soltanto che la firma sia valida, ma anche che il token appartenga alla categoria prevista dall'endpoint.

Perché l'audience è così importante

Supponiamo che ai-service abbia ottenuto un token destinato a search-service.

Se qualcuno riuscisse a sottrarre quel token, non dovrebbe poterlo utilizzare per chiamare anche content-service o identity-service.

Il controllo dell'audience serve proprio a impedire questo comportamento.

Ogni servizio verifica che il valore di aud corrisponda alla propria identità. Un token destinato a search-service viene quindi rifiutato da content-service, anche se la firma è perfettamente valida.

Questo è un concetto che spesso viene sottovalutato: la firma dimostra che il token è stato emesso da un'autorità attendibile, ma non dimostra che sia destinato al servizio che lo sta ricevendo.

Per questo motivo firma, issuer e audience devono essere verificati insieme.

Perché utilizziamo token di cinque minuti

Un token applicativo non deve avere una durata eccessiva.

Se un access token venisse sottratto, chi ne entrasse in possesso potrebbe utilizzarlo fino alla sua scadenza, nei limiti delle autorizzazioni concesse.

Utilizzare token con una durata di cinque minuti riduce la finestra temporale nella quale una credenziale compromessa può essere sfruttata.

Il servizio chiamante può comunque riutilizzare il token per più richieste, evitando di chiedere continuamente nuove credenziali a Identity. Quando il token si avvicina alla scadenza, può richiederne uno nuovo utilizzando una nuova assertion firmata con la propria chiave privata.

Non serve quindi un refresh token come quello utilizzato normalmente nei flussi di autenticazione degli utenti.

Scope e autorizzazioni: autenticare non significa autorizzare

Una delle differenze più importanti tra autenticazione e autorizzazione è che sapere chi sta effettuando una richiesta non significa concedergli automaticamente il permesso di eseguire qualsiasi operazione.

Quando search-service riceve un token valido, sa che la richiesta proviene da ai-service. Ma deve ancora verificare che quel servizio sia autorizzato a invocare lo specifico endpoint.

Per questo utilizziamo gli scope.

Possiamo definire, per esempio, search:read per consentire la consultazione dei dati e search:write per consentire operazioni di modifica.

L'endpoint /internal/research potrebbe richiedere search:read, mentre un endpoint di amministrazione potrebbe richiedere uno scope differente.

In Spring Security possiamo applicare il controllo a livello di metodo utilizzando @PreAuthorize, dopo aver configurato correttamente la conversione degli scope presenti nel JWT in autorità Spring Security.

Un esempio:

@PreAuthorize("hasAuthority('SCOPE_search:read')")
@PostMapping("/internal/research")
public ResearchResponse research(@RequestBody ResearchRequest request) {
    return researchService.research(request);
}

In questo modo il controller non deve conoscere i dettagli crittografici del token. La validazione della firma e dei claim viene eseguita dal Resource Server, mentre il livello di autorizzazione controlla se il chiamante possiede l'autorità richiesta.

La cosa importante è che ogni servizio deve applicare autonomamente le proprie regole. Non possiamo considerare sufficiente il fatto che Identity abbia rilasciato il token: il destinatario deve verificare che quel token sia effettivamente utilizzabile per la risorsa richiesta.

Anche i job schedulati devono avere un'identità

Finora abbiamo utilizzato esempi con microservizi che comunicano direttamente tra loro. Ma cosa succede quando dobbiamo eseguire un job schedulato?

Immaginiamo un processo reporting-job che ogni notte recupera dati da uno o più servizi, elabora statistiche e produce un report.

Non esiste un utente che effettua il login. Non abbiamo una sessione e non abbiamo bisogno di un refresh token. Il processo deve semplicemente autenticarsi come applicazione.

La soluzione è utilizzare esattamente lo stesso meccanismo.

Assegniamo al job un'identità applicativa, generiamo una coppia di chiavi RSA e registriamo la chiave pubblica in Identity. Definiamo gli scope e le audience necessari, senza concedere autorizzazioni che non servono.

Quando parte, il job firma una JWT assertion, la invia a Identity e ottiene un access token. Utilizza quindi il token per invocare le API autorizzate.

Non cambia nulla dal punto di vista del protocollo. Cambia soltanto il tipo di componente che effettua la richiesta.

Questo permette di utilizzare un unico modello di autenticazione per microservizi, worker, processi batch e job schedulati. Tutti sono client applicativi e tutti devono avere una propria identità.

È una scelta che evita di introdurre un secondo sistema di credenziali soltanto perché un componente viene eseguito periodicamente anziché rimanere in ascolto di richieste HTTP.

Perché non utilizziamo il refresh token

Nel caso di autenticazione di un utente, il refresh token permette di ottenere nuovi access token senza obbligare la persona a ripetere il login.

Nel flusso Client Credentials, invece, il client è già in possesso delle proprie credenziali applicative.

Quando l'access token scade, il servizio genera una nuova assertion e richiede un nuovo token a Identity.

Il rinnovo è quindi una nuova autenticazione applicativa, non un'estensione della sessione precedente.

Possiamo implementare una cache locale per conservare il token e riutilizzarlo fino a poco prima della scadenza. È importante che la cache sia organizzata per destinatario, perché un token destinato a search-service non deve essere utilizzato per chiamare content-service.

Se una richiesta restituisce un errore 401 dovuto a un token scaduto, il client può invalidare il token in cache e richiederne uno nuovo. Non bisogna però trasformare qualsiasi errore in un tentativo di rinnovo: un 403, per esempio, indica normalmente un problema di autorizzazione e non si risolve generando un altro token con le stesse credenziali.

JWKS: come i servizi verificano i token senza chiamare Identity ogni volta

Abbiamo visto che ogni microservizio deve verificare la firma degli access token ricevuti.

Ma come può farlo senza conoscere la chiave privata di Identity?

La risposta è JWKS, JSON Web Key Set. Si tratta di un formato standardizzato che permette di pubblicare un insieme di chiavi pubbliche utilizzabili per verificare le firme dei token.

Identity espone un endpoint, generalmente raggiungibile attraverso un percorso come:

GET /.well-known/jwks.json

I servizi utilizzano questo endpoint per recuperare le chiavi pubbliche necessarie alla validazione.

La cosa importante è che non devono effettuare questa richiesta a ogni chiamata applicativa. Possono conservare le chiavi in cache, aggiornandole periodicamente e quando incontrano un identificativo kid che non conoscono.

Quando il destinatario riceve un JWT, legge il kid presente nell'header e seleziona la chiave pubblica corrispondente dal JWKS.

Verifica quindi la firma utilizzando l'algoritmo previsto, controlla issuer, audience, scadenza e autorizzazioni.

Questo meccanismo permette a Identity di ruotare le proprie chiavi di firma senza dover distribuire manualmente nuovi certificati o nuove chiavi private a tutti i microservizi.

È importante distinguere questo JWKS dal registro delle chiavi pubbliche dei client. Il registro utilizzato da Identity per verificare le assertion dei servizi contiene le chiavi pubbliche dei singoli client. Il JWKS pubblicato da Identity contiene invece le chiavi pubbliche utilizzate dai servizi per verificare i token firmati da Identity.

Sono due insiemi di chiavi con finalità differenti.

Rotazione delle chiavi: cosa succede quando dobbiamo sostituirle

Le chiavi private non devono essere considerate credenziali permanenti. Possono essere compromesse, devono essere sostituite periodicamente secondo le politiche di sicurezza e devono poter essere revocate senza interrompere l'intera applicazione.

La rotazione deve essere progettata in modo da non interrompere le comunicazioni tra servizi.

Immaginiamo di dover sostituire la chiave privata di ai-service.

Non dobbiamo semplicemente eliminare la vecchia chiave pubblica e inserire quella nuova nello stesso momento. Potrebbero esserci istanze ancora attive che utilizzano la vecchia chiave, causando errori di autenticazione.

La procedura corretta prevede una fase di sovrapposizione:

  1. Generiamo una nuova coppia di chiavi.
  2. Registriamo la nuova chiave pubblica in Identity, mantenendo temporaneamente quella precedente.
  3. Distribuiamo la nuova chiave privata al servizio.
  4. Verifichiamo che le nuove istanze riescano ad autenticarsi.
  5. Rimuoviamo la vecchia chiave pubblica quando non è più utilizzata.

Durante la fase di transizione Identity riconosce entrambe le chiavi attraverso i rispettivi kid.

Lo stesso principio vale per la rotazione della chiave privata di Identity, con una differenza: i servizi destinatari devono poter recuperare dal JWKS sia la vecchia sia la nuova chiave pubblica durante il periodo di transizione. Identity può iniziare a firmare con la nuova chiave, mantenendo pubblicata la precedente finché i token già emessi non sono scaduti e le cache dei destinatari sono state aggiornate.

La gestione dei kid è quindi fondamentale. Senza identificativi distinti, diventa difficile capire quale chiave debba essere utilizzata per verificare un determinato token.

Sicurezza: cosa dobbiamo proteggere davvero

Un sistema di autenticazione basato su RSA e JWT non diventa automaticamente sicuro soltanto perché utilizza la crittografia asimmetrica.

La sicurezza dipende anche da come conserviamo le chiavi, da come gestiamo le autorizzazioni e da come proteggiamo le comunicazioni.

La prima regola è che le chiavi private non devono essere inserite nel repository. In produzione devono essere conservate in un sistema di gestione dei segreti o in un volume protetto, con permessi di accesso limitati al processo che ne ha bisogno.

Anche gli ambienti di sviluppo devono essere gestiti con attenzione. Le chiavi locali non devono essere condivise attraverso repository pubblici, chat o altri canali non protetti.

La seconda regola riguarda la registrazione dei client. Non deve essere possibile registrare liberamente una chiave pubblica o assegnare scope amministrativi senza un'autorizzazione adeguata. Il registro delle identità applicative è una parte critica dell'infrastruttura.

La terza riguarda la validazione dei token. Ogni servizio deve verificare esplicitamente l'algoritmo ammesso, la firma, l'issuer, l'audience, la scadenza e le autorizzazioni. Non bisogna fidarsi dei claim soltanto perché sono presenti nel payload JWT: il payload è leggibile da chiunque possieda il token e diventa attendibile soltanto dopo la corretta verifica della firma.

Dobbiamo inoltre proteggere il trasporto con HTTPS o TLS anche all'interno della rete privata. Il fatto che due container siano collegati alla stessa rete Docker o che due servizi si trovino nella stessa infrastruttura non significa che le loro comunicazioni siano automaticamente sicure.

Infine, bisogna ragionare sulla revoca. Un access token JWT già emesso può continuare a essere valido fino alla sua scadenza anche se nel frattempo decidiamo di disabilitare il client che lo ha ottenuto. Con token di cinque minuti limitiamo questa finestra, ma se abbiamo bisogno di una revoca immediata dobbiamo introdurre un meccanismo aggiuntivo, per esempio una lista di revoca, un controllo di stato o un sistema di introspezione.

La scelta dipende dal livello di sicurezza richiesto e dal compromesso che vogliamo raggiungere tra autonomia dei servizi e controllo centralizzato.

E il Gateway? Le comunicazioni interne devono passarci?

Un'altra scelta architetturale riguarda il ruolo dell'API Gateway.

Quando un utente accede all'applicazione, normalmente il Gateway rappresenta il punto di ingresso delle richieste esterne. Può verificare i token, applicare regole comuni, gestire il routing e centralizzare alcune politiche di accesso.

Le comunicazioni server-to-server, però, non devono necessariamente seguire lo stesso percorso.

Se abbiamo endpoint interni che devono essere raggiungibili soltanto dai servizi autorizzati, possiamo esporli esclusivamente sulla rete privata e consentire ai chiamanti di raggiungerli direttamente.

Questo non significa rinunciare all'autenticazione. Al contrario, ogni servizio destinatario continua a verificare autonomamente il token ricevuto, senza considerare sufficiente il fatto che la richiesta provenga dalla rete interna.

Il Gateway e l'autenticazione S2S risolvono problemi differenti. Il primo governa principalmente l'accesso alle API esposte verso l'esterno; il secondo permette ai componenti applicativi di dimostrare la propria identità e ottenere autorizzazioni specifiche nelle comunicazioni interne.

Non bisogna quindi trasformare il Gateway in un punto obbligatorio per qualsiasi comunicazione soltanto perché esiste nell'architettura. La decisione deve dipendere dalle esigenze di routing, osservabilità, sicurezza e gestione del traffico.

Conclusioni: un'identità per ogni servizio

Quando il numero dei microservizi aumenta, la gestione delle identità applicative diventa una parte fondamentale dell'architettura.

Distribuire segreti condivisi, riutilizzare credenziali o affidarsi soltanto alla rete interna può sembrare inizialmente più semplice, ma introduce dipendenze e problemi di sicurezza che diventano sempre più difficili da gestire con la crescita del sistema.

Preferisco assegnare a ogni componente una propria identità crittografica, utilizzare una chiave privata per firmare le assertion e affidare a Identity la verifica delle identità e il rilascio degli access token.

Il vantaggio più importante è che non dobbiamo distribuire le chiavi private tra i servizi. Ogni microservizio conosce soltanto la propria chiave privata e le chiavi pubbliche di Identity. È Identity a conservare il registro delle chiavi pubbliche dei client, mentre i destinatari verificano i token attraverso le chiavi pubbliche pubblicate nel JWKS.

Questo modello funziona con tre microservizi come con cento. Quando aggiungiamo un nuovo componente, gli assegniamo una nuova identità, registriamo la sua chiave pubblica e definiamo le autorizzazioni necessarie. Gli altri servizi non devono essere modificati.

La stessa architettura può essere utilizzata per job schedulati, worker e processi batch. Non serve introdurre un sistema di autenticazione differente per ogni tipologia di componente: ogni processo autonomo è un client applicativo e deve possedere una propria identità.

Per me questo è il punto centrale: non dobbiamo fidarci dei servizi perché si trovano nella nostra rete, dobbiamo fidarci perché sono in grado di dimostrare crittograficamente la propria identità e perché possiedono soltanto le autorizzazioni che abbiamo deciso di concedere loro.

È un approccio che richiede un po' più di lavoro iniziale rispetto a una semplice password condivisa, ma che restituisce un'architettura molto più ordinata, scalabile e controllabile.