Divertissement con l'OpenID e Access Token

di Andrea Zani, in .NET,

Questa dovrebbe essere la seconda parte dopo il primo post dedicato all'argomento. Questa volta aumenterò di poco la difficoltà inserendo nuovi attori nell'autenticazione/autorizzazione. Sì, perché ora mi sono montato la testa e voglio implementare una soluzione custom utilizzando l'OpenID Connect (OIDC) che si basa a sua volta dal protocollo OAuth 2.0. Custom in parte visto che Net Core mette a disposizione alcune librerie che facilitano il compito. Inoltre sono presenti soluzioni complesse e professionali come IdentityServer4, ma facendo le cose in casa si può capire meglio come funziona il tutto.

In questo mio esempio io tratterò sia l'OpenID Token sia l'Access Token. Qual è la differenza tra i due? Il primo è un Jwt token che dimostra che l'utente è stato autenticato e porta in sé informazioni sull'utente (claims), come il proprio nome, cognome, url con l'immagine di profilo e qualsiasi altra informazione che potrebbe essere utile. L'Access Token è un Jwt token in cui sono salvate informazioni utili per le autorizzazioni che l'utente possiede per accedere a determinate risorse.

Iniziamo dalla fase di autenticazione e controllo i vari metodi presenti nella RFC ufficiale:

https://datatracker.ietf.org/doc/html/rfc6749

https://tools.ietf.org/html/rfc6749#section-4.1

Nel mio caso tralascio la PKCE e userò la Authorization Code Flow il cui schema è il seguente (molto ispirato da un post in cui link lo inserirò a breve):

Sono presenti più metodi per l'autenticazione, e rimando alla lettura di questo post molto completo a riguardo:

https://darutk.medium.com/diagrams-of-all-the-openid-connect-flows-6968e3990660

Si inizia. Prima di tutto mi servono i due attori principali. Creo quindi due web application - Client e Server - che si scambieranno queste informazioni per avere, come risultato finale, i due token: l'OpenID Token e l'Access Token.

Nel client il compito è facilitato perché è presente un componente apposito per l'autenticazione OpenID e le implementazioni da fare sono poche. Ma lato server è tutta un'altra storia: quasi tutto dovrà essere scritto a mano (probabilmente c'è qualche libreria di supporto che mi avrebbe facilitato il lavoro) - per fortuna parte del lavoro per la creazione e gestione del token pubblico e privato sono già fatti e presenti nel post precedente.

Inizio da questo punto perché devo personalizzare in modo corretto le API Rest che restituiscono le informazioni per l'autenticazione (.well-known/openid-configuration):

{
  "issuer": "http://localhost:43201/",
  "authorization_endpoint": "http://localhost:43201/login",
  "token_endpoint": "http://localhost:43201/oauth2/token",
  "jwks_uri": "http://localhost:43201/.well-known/jwks.json",
  "scopes_supported": [
    "openid"
  ],
  "response_types_supported": [
    "code"
  ],
  "code_challenge_methods_supported": [
    "S256"
  ],
  "response_modes_supported": [
    "form_post"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_post"
  ],
  "claims_supported": [
    "aud",
    "auth_time",
    "created_at",
    "email",
    "exp",
    "iat",
    "nickname",
    "sub",
    "picture"
  ],
  "request_uri_parameter_supported": false
}

Per lo scope e response type supported ho messo solo i valori accettati dal mio server così come le claims che saranno restituite, tra cui il nickname, la mail e l'url dell'immagine del profilo che voglio sia mostrata.

Ritornando allo schema qui sopra, ecco come eseguo il punto numero 1.

Lato client devo eseguire un redirect sulla web application server con un url come il seguente:

http://localhost:43201/login?client_id=clientId0001&redirect_uri=http%3A%2F%2Flocalhost%3A43200%2Foauth%2Fcallback&response_type=code&scope=openid&response_mode=form_post&nonce=637742219882142020.Mjk2ZTJjZTktZDZhMy00YzA0LWJlOTctYjIwMmY5ZGI4NjUxYjZhMmYyYzEtNmEyYi00NjM3LTllYmUtMzI3ZGI3MDE2MjYw&state=CfDJ8Jwpo3zrtAVAq5nyXckyWx3qhpMUoKg3myPLv2V3rhhT3A--t3SVvnXG5OrSF4Y9NkTblUMUBtna6YsJVZ5fwwvce-vDSXF1mnCRBMBjurcqcAtmIDGHnh7n6QJY6j0bEb8OMERQkHqQpNqRFxr1DObjCmGyIODv5mxwHHBMrcsgC27kQQFsRsqcJg_mWNMtOy21Jxg6TfjeAT27njlZPafSRtVI0eNnGQV6UmzcTNjvRbzCQZb0TGItZ3nL3YF0OmtU3iqUs-dqlNEWqkLn6O9up5rqXNw1-VOz6Swe7J-Dxv5kFhNg3v8tlQhMfVaLRQ&x-client-SKU=ID_NETSTANDARD2_0&x-client-ver=5.5.0.0

Non mi devo preoccupare di come sarà costruito questo link perché ci penserà il modulo di autenticazione OpenID a crearlo e a richiamare la pagina corretta della mia web application server (ovviamente dovrò dare a questo modulo i parametri corretti, e lo mostrerò in seguito).

Nella querystring:

  • scope: unico valore accettato, OpenID.
  • client_id: stringa che identifica il client: clientId0001
  • response_type: per questa implementazione viene accettato solo code con il quale il client chiederà poi l'OpenID Token e l'Access Token.
  • redirect_uri: uri dove sarà eseguito il redirect in caso di autenticazione avvenuta con successo.
  • state: stringa alfanumerica casuale inviata dal client per verifica al momento della risposta ad avvenuta autenticazione.
  • nonce; stringa alfanumerica casuale inviata dal client per verifica al momento della richiesta del token.

Gli ultimi due parametri devono essere salvati e rinviati al client perché lui possa controllare a sua volta di non essere vittima di un'autenticazione falsata.

Il client_id specifica l'applicazione che desidera l'autenticazione. Lato server posso inserire tutte le applicazioni che voglio identificandole con un client_id e client_secret univoci, nel mio caso è un oggetto statico in cui ho inserito un'unica soluzione, così anche le varie informazioni salvate durante le varie fasi sono tutte salvate in memoria per semplificare il massimo i test:

"Clients": [
    {
        "ClientId": "clientId0001",
        "ClientSecret": "secretId0001",
        "CallBack": "http://localhost:43200/oauth/callback",
        "CallUrl": "http://localhost:43200/",
        "Audience": "abcdef1234567"
    }
]

Al momento della richiesta verifico che esista il client_id in questa lista, inoltre controllo negli header della richiesta il referer in modo che posso controllare che coincida con il CallUrl, infine verifico anche che il redirect_uri contenga lo stesso valore di CallBack. Se solo una di queste voci non coincide blocco l'autenticazione.

Ora visualizzo il classico Form di richiesta autenticazione:

L'autenticazione è fittizia e inserita una qualsiasi stringa si prosegue senza problemi - Fase 2 dello schema iniziale. Dietro le quinte vengono salvate le informazioni riguardati il nonce, l'username e il clientid (queste informazioni saranno riutilizzate per controllare il client quando richiederà l'OpenID Token e l'Access Token). Viene creato anche il code (nel mio caso un Guid casuale) che il client utilizzerà subito dopo per richiedere i token. E' giunto il momento di passare questo code insieme allo state al client che ha richiesto l'autenticazione. Questi valori devono essere inviati al client in modalità POST e per fare questo mi sono creato nella pagina di login una Form apposita che sarà inserita solo ad autenticazione avvenuta - nella documentazione sembra sia possibile inviarle anche in altri modi, ma il modulo OpenID sembra che accetti solo questa modalità POST e non ho trovato modi per modificarla:

<form method="post" action="@Model.RedirectUri" name="openidform" style="visibility:hidden">
  <input type="text" name="code" value="@Model.CodeString" /><br />
  <input type="text" name="state" value="@Model.StateString" /><br />
  <input type="submit" value="CallBack" /><br />
</form>
<script type="text/javascript">
  window.onload = function () {
    document.openidform.submit();
  }
</script>

Il client, ricevuta questa chiamata (Fase 3), controllerà a sua volta che lo state sia quello che mi aveva passato e con il code richiederà, senza nessun intervento da parte mia nel codice, i token alla mia web application server.

Ed ecco la Fase 4. Lato server ho una API Rest che risponde a questo url: http://localhost:43201/oauth2/token che deve accettare i parametri:

  • grant_type: viene accettato solo la stringa authorization_code.
  • code: dev'essere la guid che inviato precedentemente al client.
  • redirect_uri: uri a cui inviare i token.
  • client_id: il client_id dell'applicazione che ha richiesto l'autenticazione iniziale: ecco perché è necessario salvare le informazioni riguardanti il code.
  • refresh_token: non trattato in questa versione dell'applicazione.

Nel codice ora vengono eseguiti i vari controlli inerenti al client_id, code, se il redirect_uri è accettato... E solo infine viene creata la risposta (Fase 5):

var responseObject = new
{
  id_token = openIdToken,
  access_token = accessToken,
  token_type = "Bearer",
  //refresh_token = "Token for refresh" // <- Not implemented
};

return Ok(responseObject);

Risposta che contiene sia l'OpenID Token e sia l'Access Token, e il client può eseguire l'autenticazione collegando all'utente queste informazioni. Ok, ma come fa il client ad eseguire tutto questo? Come già scritto una libreria apposta aiuta nel compito anche se si devono configurare alcune opzioni. Primo passo, aggiungere la libreria Microsoft.AspNetCore.Authentication.OpenIdConnect da NuGet, quindi in Startup, nel metodo ConfigureService:

public void ConfigureServices(IServiceCollection services)
{
  IdentityModelEventSource.ShowPII = true; // Show extra debug info
  var configuration = Configuration.GetSection("Configuration").Get<Configuration>();

  services.AddAuthentication(config =>
  {
    // We check the cookie to confirm that we are authenticated
    config.DefaultAuthenticateScheme = "ClientCookie";
    // When we sign in we will deal out a cookie
    config.DefaultSignInScheme = "ClientCookie";
    // use this to check if we are allowed to do something.
    config.DefaultChallengeScheme = "OurServer";
  })
    .AddCookie("ClientCookie")
    .AddOpenIdConnect("OurServer", options => {
      options.RequireHttpsMetadata = false;
      // Set the authority to your Auth0 domain
      options.Authority = configuration.AuthorizationEndpoint;

      // Configure the Auth0 Client ID and Client Secret
      options.ClientId = configuration.ClientId;
      options.ClientSecret = configuration.ClientSecret;
      //options.RequireHttpsMetadata = false;

      // Set response type to code
      options.ResponseType = OpenIdConnectResponseType.Code;

      // Configure the scope
      options.Scope.Clear();
      options.Scope.Add("openid");
      options.GetClaimsFromUserInfoEndpoint = true;

      options.CallbackPath = configuration.CallbackPath;

      // Configure the Claims Issuer to be Auth0
      options.ClaimsIssuer = "Auth0";
      options.UsePkce = false;
      // Skip check on Nonce or State string
      /*options.ProtocolValidator = new OpenIdConnectProtocolValidator
      {
        RequireNonce = false,
        RequireState = false
      };*/

      options.SaveTokens = true; // <- Save tokens
    });

  ...
  services.AddRazorPages();
}

Quindi nel metodo Configure mi accerto che ci siano entrambi:

app.UseAuthentication();
app.UseAuthorization();

Nelle options ho inserito come Authority il dominio del server; essendo una soluzione locale si diversifica solo dal client dal numero della porta: http://localhost:43201/. In ClientId e ClientSecret ho inserito gli stessi valori configurati all'interno della web application server. ResponseType ho usato OpenIDConnectResponseType.Code perché è l'unico valore che accetto lato server, così come lo scope: OpenID. Anche in callback ho inserito una stringa con lo stesso valore inserito nella configurazione del server. Fine. Di tutti i passaggi visti prima ci penserà per il client la libreria apposita.

Per provare il funzionamento nel template di default per le Razor page aggiungo la pagina Private e alla classe aggiungo l'attributo [Authorize()] in modo che venga richiesta l'autenticazione una volta che l'utente richiede quella pagina. Avviate entrambe le web application (con Visual Studio c'è un'opzione apposita) saranno avviate due istanze del browser con le due pagine:

http://localhost:43200/ per il client.

http://localhost:43201/ per il server (in questa pagina ho inserito anche i link appositi per il Json con le informazioni per la verifica del Jwt Token visto nello scorso post sull'argomento.

Se sul client richiedo la pagina Private sarà eseguito il redirect sulla pagina Login del server e, come scritto sopra, inserito un nome di utente casuale, sarà fatto il redirect sul client con le informazioni che permetteranno al client di richiedere le informazioni dell'utente, contenute nell'OpenID Token, e l'Access Token per richiedere accesso a risorse esterne (tratterò a breve). Quindi visualizzo un riassunto delle informazioni:

Da codice, nella pagina Private, per recuperare i due token è bastato scrivere:

OpenIdTokenString = HttpContext.GetTokenAsync("id_token");
AccessTokenString = HttpContext.GetTokenAsync("access_token");

Inserito l'OpenID Token in jwt.io vedrò le mie informazioni:

Ed ecco l'Access Token in chiaro:

Nella parte inferiore della pagina html sono presenti due pulsanti che, come da testo, richiamano due API Rest senza o con Access Token. Sempre nel progetto incluso, oltre alla web application server e client, ho inserito una terza web application che non è altro che quella che avevo incluso nell'esempio del posto precedente. E' una semplice web application che espone due web Api Rest di cui una accessibile senza restrizioni, mentre la seconda abbisogna dell'Access Token. Per testare il tutto si deve ritornare in Visual Studio e avviare tutte e tre le web application (lo si può fare tranquillamente anche da terminale) con l'opzione specifica, quindi sarà aperta una ulteriore istanza del browser a questo url: http://localhost:43202/api/public

Quei due pulsanti nella web application client richiamano due funzioni in Javascript che con il comando ajax di jQuery richiamano le due API senza e con l'Access Token preso dalla text box. Se l'Access Token è corretto sarà visualizzato il messaggio corretto, altrimenti l'errore. Nell'Access Token è presente il campo aud: questo valore dev'essere presente anche nella web application con le Api Rest, e la stessa stringa, abcdef1234567 è infatti presente nell'application.json in audiance (se il valore fosse differente l'autorizzazione fallirebbe).

Infine qualche nota: il codice qui incluso non è completo né perfetto. Accetta solo un tipo di autenticazione e il controllo della correttezza dei parametri è veramente il minimo. Inoltre la gestione degli utenti e degli applicativi è statico nel codice. Due dettagli importanti mancano nella versione inclusa: la prima è la richiesta, dopo l'autenticazione, dei dati che si vogliono possano essere disponibili nell'OpenID Token (email, immagine, etc.); il secondo punto è la mancanza della gestione del Refresh Token. Per chi non sapesse a cosa serve, con questo codice il client può richiedere nuovi token una volta giusta la scadenza di quelli posseduti senza dover ripassare dalla pagina di login. Ci sarebbero altre cose interessante da scrivere, come la trattazione di role customizzate per l'accesso a determinate risorse solo se l'Access Token contiene scope appositi, oppure la Login centralizzata per più web application, ma già fino a qui la cosa è abbastanza intricata e probabilmente poco comprensibile date la mia poca capacità comunicativa. Basta.

Ecco il link del repository con il codice.

Commenti

Visualizza/aggiungi commenti

| Condividi su: Twitter, Facebook, LinkedIn

Per inserire un commento, devi avere un account.

Fai il login e torna a questa pagina, oppure registrati alla nostra community.

Nella stessa categoria
I più letti del mese