Authentification et autorisation
JWT, OAuth2 et OIDC, policies, claims.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 16 min
Une API qui protège ses données répond à deux questions distinctes, dans cet ordre : qui appelle, et cet appelant a-t-il le droit de faire ceci. ASP.NET Core les confie à deux mécanismes séparés — l'authentification fabrique un ClaimsPrincipal à partir de la requête, l'autorisation confronte ce principal à une politique — et la plupart des failles comme des faux diagnostics viennent d'une confusion entre les deux, ou d'un réglage par défaut mal connu. Le cours part d'une API .NET 10 réelle, protégée par des JWT signés localement avec une clé de test, dont les réponses ont été capturées ; il situe ensuite OAuth2 et OpenID Connect, qui fournissent ces jetons en production.
Authentifier, puis autoriser : 401 et 403
L'authentification ne refuse rien. UseAuthentication exécute le schéma par défaut, qui lit l'en-tête Authorization, valide le jeton et remplace HttpContext.User par le principal qu'il décrit ; un jeton absent ou invalide laisse simplement un principal anonyme. C'est l'autorisation qui décide, point de terminaison par point de terminaison, et elle a deux façons de dire non. Si l'appelant n'est pas authentifié, elle déclenche un challenge : le schéma répond 401 et ajoute WWW-Authenticate: Bearer, qui dit au client comment s'authentifier. S'il est authentifié mais que la politique échoue, elle déclenche une interdiction, et JwtBearer répond 403 sans cet en-tête. La RFC 6750 prévoit pourtant un 403 accompagné de error="insufficient_scope" ; l'omettre est un choix du gestionnaire, pas une règle du protocole.
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// Paquet : Microsoft.AspNetCore.Authentication.JwtBearer. Les reponses citees
// dans ce cours ont ete capturees avec la version 10.0.12 (IdentityModel 8.19.2).
//
// Jwt:Cle vient des secrets utilisateur (dotnet user-secrets init, puis
// dotnet user-secrets set "Jwt:Cle" "cle-factice-de-test-a-ne-jamais-deployer").
// Cette valeur est FACTICE : une cle symetrique n'a sa place que sur un banc
// d'essai ou l'on emet et verifie soi-meme ses jetons.
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://auth.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Gestionnaire", politique => politique.RequireRole("gestionnaire"));
// Les services etant enregistres, WebApplication insere lui-meme
// UseAuthentication puis UseAuthorization juste apres le routage.
var app = builder.Build();
app.MapGet("/sante", () => "ok");
// Toute identite authentifiee suffit.
app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireAuthorization();
// Il faut en plus le role gestionnaire.
app.MapDelete("/commandes/{id:int}", (int id) => TypedResults.NoContent())
.RequireAuthorization("Gestionnaire");
app.Run();# API precedente lancee par dotnet run ; en-tetes Date et Server omis.
# Les jetons sont ceux qu'affiche le programme de la section « Anatomie d'un
# JWT » ; JETON_LECTEUR s'obtient en y remplacant "gestionnaire" par "lecteur".
API=http://localhost:5105 # l'adresse qu'affiche dotnet run
curl -i $API/commandes
# HTTP/1.1 401 Unauthorized
# Content-Length: 0
# WWW-Authenticate: Bearer
curl -i -H "Authorization: Bearer $JETON_LECTEUR" $API/commandes
# HTTP/1.1 200 OK
# ["C-1","C-2"]
curl -i -X DELETE -H "Authorization: Bearer $JETON_LECTEUR" $API/commandes/7
# HTTP/1.1 403 Forbidden
# Content-Length: 0
curl -i -X DELETE -H "Authorization: Bearer $JETON_GESTIONNAIRE" $API/commandes/7
# HTTP/1.1 204 No Content
# Un jeton invalide ne ferme rien a lui seul :
curl -i -H "Authorization: Bearer abc.def" $API/sante
# HTTP/1.1 200 OK
# ok Le client doit traiter les deux codes différemment, et c'est pour cela qu'ils existent : un 401 invite à obtenir un jeton valide ; un 403 ne se corrige pas en renvoyant le même jeton, mais en obtenant un droit de plus : un rôle attribué, ou un jeton demandé avec la portée qui manquait. La dernière requête montre l'autre face de la séparation : un point de terminaison sans exigence d'autorisation reste ouvert, quel que soit le jeton reçu. L'ordre de UseAuthentication et UseAuthorization, et ce que coûte son inversion, est traité dans le cours « Construire une API ».
Schémas : qui authentifie, qui répond
Un schéma est un nom associé à un gestionnaire et à ses options. JwtBearerDefaults.AuthenticationScheme vaut "Bearer" ; AddJwtBearer("Partenaires", …) enregistre le même gestionnaire sous un autre nom, avec un autre émetteur et une autre clé. Chaque gestionnaire sait faire trois choses : authentifier, c'est-à-dire produire un principal à partir de la requête ; challenger un anonyme ; interdire à un authentifié. Le schéma par défaut est celui à qui l'on s'adresse quand personne n'en nomme un.
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// Jwt:Cle et Jwt:ClePartenaires : deux cles FACTICES, en secrets utilisateur.
SymmetricSecurityKey Cle(string nom) =>
new(Encoding.UTF8.GetBytes(builder.Configuration[nom]!));
// Deux schemas : deux noms, deux jeux d'options pour le meme gestionnaire
// JwtBearerHandler. L'argument d'AddAuthentication designe le schema par
// defaut : celui qu'UseAuthentication execute a chaque requete, et celui qui
// challenge et interdit quand personne n'en nomme un autre.
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://auth.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = Cle("Jwt:Cle"),
})
.AddJwtBearer("Partenaires", options =>
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://partenaires.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = Cle("Jwt:ClePartenaires"),
});
// Une politique peut nommer ses schemas : l'autorisation les execute alors
// elle-meme, et ignore le principal produit par le schema par defaut.
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Partenaire", politique => politique
.AddAuthenticationSchemes("Partenaires")
.RequireAuthenticatedUser());
var app = builder.Build();
app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireAuthorization();
app.MapGet("/tarifs", () => new[] { "T-1" }).RequireAuthorization("Partenaire");
app.Run();
// /commandes, jeton interne -> 200
// /commandes, jeton partenaire -> 401 error_description="The signature key was not found"
// /tarifs, jeton partenaire -> 200
// /tarifs, jeton interne -> 401 error_description="The signature key was not found"
//
// Avec AddAuthentication() sans argument, les deux schemas restent sans
// defaut. /tarifs marche toujours ; /commandes repond 500, jeton valide ou non :
// System.InvalidOperationException: No authenticationScheme was specified,
// and there was no DefaultChallengeScheme found. Depuis ASP.NET Core 7, un schéma unique devient le schéma par défaut sans qu'on l'écrive. Dès qu'il y en a deux, plus rien n'est choisi : UseAuthentication n'exécute aucun schéma, tout appelant reste anonyme, et le premier challenge lève une InvalidOperationException. La politique qui nomme ses schémas continue de fonctionner, ce qui brouille le diagnostic. Le piège se déclenche donc le jour où l'on ajoute un second schéma à une application qui marchait.
Anatomie d'un JWT
Un JWT (RFC 7519) est fait de trois segments base64url séparés par des points. L'en-tête dit comment le jeton est signé ; la charge porte des revendications, dont certaines sont normalisées : iss l'émetteur, sub le sujet, aud le destinataire, exp, nbf et iat des instants en secondes Unix. La signature est calculée sur les deux premiers segments tels qu'encodés.
using System.Buffers.Text;
using System.Security.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
// Paquet : Microsoft.IdentityModel.JsonWebTokens (8.23.0 pour la sortie
// ci-dessous). Remplacer "gestionnaire" par "lecteur" donne JETON_LECTEUR.
// Cle FACTICE, pour un banc
// d'essai local : une vraie cle ne s'ecrit jamais dans le code. HS256 exige au
// moins 32 octets ; en dessous, CreateToken leve ArgumentOutOfRangeException.
var cle = new SymmetricSecurityKey("cle-factice-de-test-a-ne-jamais-deployer"u8.ToArray());
var jeton = new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{
Issuer = "https://auth.exemple.fr",
Audience = "api-commandes",
Subject = new ClaimsIdentity(
[
new Claim("sub", "alice"),
new Claim("name", "Alice Martin"),
new Claim("role", "gestionnaire"),
]),
Expires = DateTime.UtcNow.AddMinutes(15),
SigningCredentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256),
});
// Le jeton a envoyer dans Authorization: Bearer ...
Console.WriteLine(jeton);
// Trois segments base64url separes par des points : entete.charge.signature.
var segments = jeton.Split('.');
Console.WriteLine(System.Text.Encoding.UTF8.GetString(Base64Url.DecodeFromChars(segments[0])));
Console.WriteLine(System.Text.Encoding.UTF8.GetString(Base64Url.DecodeFromChars(segments[1])));
Console.WriteLine($"signature : {Base64Url.DecodeFromChars(segments[2]).Length} octets");
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhcGktY29tbWFuZGVzIiwiaXNzIjoi...
// (une seule ligne de 300 caracteres, tronquee ici)
// {"alg":"HS256","typ":"JWT"}
// {"aud":"api-commandes","iss":"https://auth.exemple.fr","exp":1790362188,"sub":"alice",
// "name":"Alice Martin","role":"gestionnaire","iat":1790361288,"nbf":1790361288}
// signature : 32 octets Deux conséquences se lisent dans cette sortie. La charge n'est pas chiffrée, seulement encodée : quiconque tient le jeton la lit, et rien de secret n'y a sa place. Et une signature HMAC prouve seulement que le signataire possédait la clé — or quiconque vérifie avec une clé symétrique peut aussi signer. C'est acceptable sur un banc d'essai où l'on émet et vérifie ses propres jetons, pas au-delà : un fournisseur réel signe avec une clé privée (RS256, ES256) et publie les clés publiques, que chaque API récupère sans pouvoir émettre. Le gestionnaire a ajouté de lui-même iat et nbf.
Ce que la validation vérifie
AddJwtBearer vérifie par défaut que le jeton est signé (RequireSignedTokens) par une clé connue, que iss figure parmi les émetteurs attendus, que aud désigne cette API, et que l'instant présent tombe avant exp et après nbf. Chacune de ces vérifications ferme une attaque distincte. La signature prouve qui a émis le jeton, pas pour qui : un fournisseur qui sert plusieurs API, ou plusieurs locataires, les signe toutes avec la même clé. Le contre-exemple classique naît d'une première erreur en test, qu'on fait taire en coupant la vérification qui la produit ; tout jeton du même fournisseur devient alors valable ici.
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
options.TokenValidationParameters = new TokenValidationParameters
{
// En test, sans ValidIssuer ni ValidAudience, tout jeton recevait
// 401 « The audience 'api-commandes' is invalid ». On coupe, et
// « ca marche ». La signature reste verifiee ; elle ne prouve que
// l'emetteur, pas le destinataire.
ValidateIssuer = false,
ValidateAudience = false,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireAuthorization();
app.Run();using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://auth.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
// Par defaut, tout algorithme que la cle permet est accepte. La
// liste epinglee refuse les autres d'emblee.
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
// 5 minutes par defaut : un jeton expire depuis 4 minutes passe.
ClockSkew = TimeSpan.FromSeconds(30),
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireAuthorization();
app.Run(); Le décalage d'horloge mérite qu'on s'y arrête. ClockSkew vaut cinq minutes par défaut (TokenValidationParameters.DefaultClockSkew) : un jeton expiré depuis quatre minutes est encore accepté, ce qui déroute quand on teste une durée de vie courte. Le réduire suppose des horloges synchronisées entre fournisseur et API. ValidAlgorithms répond à un autre défaut : sans lui, tout algorithme que la clé permet est accepté, et c'est l'en-tête du jeton, donc l'émetteur ou un attaquant, qui choisit. Épingler la liste compte surtout avec des clés asymétriques, où une même clé RSA sert aussi bien RS256 que PS512, et où la confusion d'algorithmes est une attaque connue.
# GET /commandes, en Development comme en Production, avec JwtBearer 10.0.12
# (IdentityModel 8.19.2) : les textes de error_description changent d'une
# version d'IdentityModel a l'autre. Jetons signes avec la bonne cle ; seul
# change ce qui est indique.
# aud = api-facturation (meme fournisseur, autre API)
# fausse : 200 OK
# juste : 401 WWW-Authenticate: Bearer error="invalid_token",
# error_description="The audience '(null)' is invalid"
# (avec 10.0.8 et IdentityModel 8.0.1 : 'api-facturation')
# iss = https://autre-locataire.exemple.fr
# fausse : 200 OK
# juste : 401 error_description="The issuer 'https://autre-locataire.exemple.fr' is invalid"
# exp depasse de 2 minutes
# fausse : 200 OK (ClockSkew de 5 minutes)
# juste : 401 error_description="The token expired at '09/25/2026 18:27:53'"
# exp depasse de 6 minutes
# les deux : 401 error_description="The token expired at '09/25/2026 18:23:54'"
# entete {"alg":"none"}, signature vide
# les deux : 401 error_description="The signature is invalid"
# signe avec une autre cle
# les deux : 401 error_description="The signature key was not found"
# signe en HS512, avec une cle factice de 64 octets configuree des deux cotes
# fausse : 200 OK (la cle permet HS512, donc HS512 est accepte)
# juste : 401 error_description="The signature key was not found" Deux réglages trompent par leur nom. ValidateIssuerSigningKey, faux par défaut, ne désactive pas la vérification de signature — le jeton signé avec une autre clé est bien refusé ci-dessus — : il commande une validation supplémentaire de la clé elle-même, utile quand le jeton transporte son propre certificat. Et les messages error_description partent aussi en Production, parce que IncludeErrorDetails vaut vrai par défaut ; le passer à faux réduit l'en-tête à WWW-Authenticate: Bearer.
OAuth2 et OpenID Connect
OAuth2 est un protocole de délégation : il permet à une application cliente d'obtenir, avec l'accord de l'utilisateur, un jeton d'accès pour appeler une API en son nom. Ce jeton est destiné à l'API, qui le valide ; le client n'a pas à l'interpréter, et OAuth2 ne lui dit pas qui est l'utilisateur. OpenID Connect ajoute cette couche d'identité : la portée openid, un id_token — un JWT destiné au client, dont aud est le client_id —, un point userinfo, et le document de découverte /.well-known/openid-configuration, qui publie points de terminaison et clés. L'API valide des jetons d'accès ; l'application qui connecte l'utilisateur consomme un id_token.
Le flux recommandé est Authorization Code avec PKCE ; la RFC 9700 (janvier 2025) l'impose aux clients publics et le recommande aux autres :
- le client tire un
code_verifier, en calcule lecode_challenge, et redirige le navigateur vers/authorizeavec ce défi, unstateet, en OIDC, unnonce; - l'utilisateur s'authentifie chez le fournisseur, qui redirige vers le client avec un code à usage unique ;
- le client vérifie
state, puis échange le code à/token, en appel direct, en joignant lecode_verifier; - le fournisseur vérifie que l'empreinte du vérificateur est le défi reçu, et rend les jetons.
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
// Le client tire, pour chaque connexion, un secret a usage unique : le
// code_verifier. 32 octets aleatoires donnent 43 caracteres base64url, le
// minimum que la RFC 7636 autorise.
var verificateur = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
// Il n'envoie a /authorize que l'empreinte, le code_challenge (methode S256).
// Le code_verifier ne part qu'avec l'echange du code, a /token.
static string Defi(string verificateur) =>
Base64Url.EncodeToString(SHA256.HashData(Encoding.ASCII.GetBytes(verificateur)));
Console.WriteLine($"{verificateur.Length} caracteres, defi {Defi(verificateur)}");
// Le vecteur de l'annexe B de la RFC 7636, pour verifier le calcul :
Console.WriteLine(Defi("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"));
// 43 caracteres, defi ycTUBi3TUL_-qfmktmE_fO2Qote0Gceg0IHNkxjX2_A (aleatoire)
// E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMUn code intercepté pendant la redirection ne sert donc à rien sans le vérificateur, qui ne figure en clair dans aucune redirection. C'est ce qui protège le code d'un client public — une application Angular, qui ne peut garder aucun secret — et ce qui a fait abandonner le flux implicite, où les jetons voyageaient dans l'URL. PKCE ne protège pas pour autant les jetons que la SPA détient : un script injecté dans la page peut les lire, ou lancer lui-même un nouveau flux. La RFC 10017 (BCP 212, août 2026), consacrée aux applications dans le navigateur, recommande donc fortement le motif BFF pour les applications métier, sensibles ou qui traitent des données personnelles : un backend mène le flux, garde les jetons et ne donne au navigateur qu'un cookie. La configuration qui suit en est le socle ; un BFF complet y ajoute le relais des appels d'API et une protection anti-CSRF.
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
// Paquet : Microsoft.AspNetCore.Authentication.OpenIdConnect (10.0.12 pour la
// capture qui suit). Une application
// cote serveur qui connecte ses utilisateurs par OIDC. connexion.exemple.fr
// est un nom d'exemple : ce code compile, mais il faut un vrai fournisseur,
// et un client qui y est declare, pour aller plus loin que la redirection.
builder.Services
.AddAuthentication(options =>
{
// Le cookie porte la session une fois la connexion faite. OIDC ne sert
// qu'a l'etablir : c'est lui qui repond au challenge.
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
// Le document /.well-known/openid-configuration de l'autorite donne
// les points de terminaison et les cles publiques de signature.
options.Authority = "https://connexion.exemple.fr";
options.ClientId = "portail-commandes";
options.ClientSecret = builder.Configuration["Oidc:SecretClient"];
// Le defaut est "id_token" : pas de code, donc pas de PKCE.
options.ResponseType = "code";
options.Scope.Add("commandes.lire");
// Garde le jeton d'acces dans le cookie, pour appeler l'API ensuite.
options.SaveTokens = true;
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = "name";
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapGet("/", (ClaimsPrincipal utilisateur) => $"Bonjour {utilisateur.Identity!.Name}")
.RequireAuthorization();
app.Run(); Cet exemple ne joue le rôle d'aucun fournisseur et ne se connecte à rien. Lancé tel quel, il répond 500 : IDX20803: Unable to obtain configuration. Pour capturer la redirection qu'il émet, les métadonnées ont été fournies localement par options.Configuration au lieu d'être découvertes ; la redirection est bien celle d'ASP.NET Core 10. Elle montre un défaut qui surprend : sans ResponseType = "code", le gestionnaire demande un id_token et n'envoie aucun défi PKCE, bien que UsePkce vaille vrai. Et depuis .NET 9, PushedAuthorizationBehavior vaut UseIfAvailable : face à un fournisseur qui publie un point PAR (RFC 9126), le gestionnaire pousse d'abord ces paramètres par un appel direct, et la redirection ne porte plus que client_id et request_uri.
# Capture faite avec OpenIdConnect 10.0.12 sur http://localhost:5488 ; une
# ligne par parametre, valeurs longues tronquees, attribut expires des cookies
# omis.
curl -i http://localhost:5488/
# HTTP/1.1 302 Found
# Location: https://connexion.exemple.fr/authorize
# ?client_id=portail-commandes
# &redirect_uri=http%3A%2F%2Flocalhost%3A5488%2Fsignin-oidc
# &response_type=code
# &scope=openid%20profile%20commandes.lire
# &code_challenge=bawbd360t7-2Ox5ABvaQBzY1Zo4WFNFOqo5C7fKrsHE
# &code_challenge_method=S256
# &response_mode=form_post
# &nonce=639259581066720101.MjA5ZDllZjgtY2RmNy00...
# &state=CfDJ8IskmXkpM7ZEsVl0WmDKPjrs8TFouPCZ6qWp...
# &x-client-SKU=ID_NET10_0
# &x-client-ver=8.19.2.0
# Set-Cookie: .AspNetCore.OpenIdConnect.Nonce.CfDJ8...=N; path=/signin-oidc; secure; samesite=none; httponly
# Set-Cookie: .AspNetCore.Correlation.a62akOfD...=N; path=/signin-oidc; secure; samesite=none; httponly
#
# x-client-SKU et x-client-ver identifient la version d'IdentityModel installee :
# ils changent avec les paquets (ID_NET9_0 et 8.0.1.0 avec OpenIdConnect 10.0.8).
#
# state est chiffre par la protection des donnees d'ASP.NET Core : il transporte,
# illisible pour le navigateur, le code_verifier dont /token aura besoin.
# Sans ResponseType = "code" : response_type=id_token, ni code_challenge ni methode. Côté API, un fournisseur réel remplace la clé symétrique des sections précédentes : options.Authority désigne l'émetteur, options.Audience l'identifiant de l'API, et le gestionnaire télécharge les clés publiques depuis le document de découverte, en HTTPS obligatoire tant que RequireHttpsMetadata vaut vrai.
Claims : ce que contient l'identité
Un ClaimsPrincipal contient une ou plusieurs ClaimsIdentity, chacune une liste de revendications : un type, une valeur, un émetteur. Identity.Name et IsInRole lisent les types désignés par NameClaimType et RoleClaimType. Le gestionnaire JWT y ajoute une traduction héritée de WS-Federation, MapInboundClaims, vraie par défaut, qui renomme les types courts du jeton en URI longues avant de construire l'identité. Le code écrit d'après le contenu du jeton lit alors des revendications qui n'existent plus.
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://auth.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
});
builder.Services.AddAuthorization();
var app = builder.Build();
// Le jeton porte "sub": "alice", "name": "Alice Martin", "role": "gestionnaire".
app.MapGet("/moi", (ClaimsPrincipal utilisateur) => new
{
sub = utilisateur.FindFirstValue("sub"),
nom = utilisateur.Identity!.Name,
gestionnaire = utilisateur.IsInRole("gestionnaire"),
types = utilisateur.Claims.Select(c => c.Type),
}).RequireAuthorization();
app.Run();
// {"sub":null,"nom":null,"gestionnaire":true,
// "types":["aud","iss","exp",
// "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
// "name",
// "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
// "iat","nbf"]}using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Les revendications gardent le nom qu'elles ont dans le jeton...
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://auth.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
// ... et l'identite apprend lesquelles portent le nom et le role.
NameClaimType = "name",
RoleClaimType = "role",
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapGet("/moi", (ClaimsPrincipal utilisateur) => new
{
sub = utilisateur.FindFirstValue("sub"),
nom = utilisateur.Identity!.Name,
gestionnaire = utilisateur.IsInRole("gestionnaire"),
types = utilisateur.Claims.Select(c => c.Type),
}).RequireAuthorization();
app.Run();
// {"sub":"alice","nom":"Alice Martin","gestionnaire":true,
// "types":["aud","iss","exp","sub","name","role","iat","nbf"]} Dans la première version, sub est devenu nameidentifier ; role est devenu l'URI de ClaimTypes.Role, ce qui fait marcher IsInRole par accident ; name, absent de la table, est resté tel quel alors que NameClaimType attend l'URI longue, si bien que Identity.Name est nul. La seconde coupe la traduction et dit quels types portent le nom et le rôle. Couper la traduction sans renseigner RoleClaimType est le piège inverse : IsInRole cherche l'URI longue, ne la trouve plus, et toute politique RequireRole répond 403 à un gestionnaire légitime. Les deux réglages vont ensemble, et les exemples suivants partent de cette configuration.
Politiques (policies)
Une politique est une liste d'exigences, toutes nécessaires. RequireRole, RequireClaim, RequireAuthenticatedUser et RequireAssertion en sont des raccourcis ; AddAuthorizationBuilder les nomme au démarrage, RequireAuthorization("Nom") les pose sur un point de terminaison. RequireClaim accepte plusieurs valeurs, dont une seule suffit, et les compare entières : c'est là que le jeton OAuth2 le plus courant le prend en défaut. La RFC 8693 définit scope comme une chaîne de portées séparées par des espaces ; un fournisseur qui l'émet ainsi produit une seule revendication, que RequireClaim ne découpe pas.
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://auth.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
NameClaimType = "name",
RoleClaimType = "role",
};
});
// Le jeton porte "scope": "commandes.lire commandes.ecrire" : une seule
// revendication, dont la valeur est la chaine entiere. RequireClaim compare
// des valeurs entieres : "commandes.lire" n'y figure pas.
builder.Services.AddAuthorizationBuilder()
.AddPolicy("LireCommandes", politique => politique.RequireClaim("scope", "commandes.lire"));
var app = builder.Build();
app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireAuthorization("LireCommandes");
app.Run();
// GET /commandes, avec ce jeton qui a pourtant le droit :
// HTTP/1.1 403 Forbiddenusing System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://auth.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
NameClaimType = "name",
RoleClaimType = "role",
};
});
builder.Services.AddAuthorizationBuilder()
// Politique de repli : elle s'applique a tout ce qui n'en declare aucune.
// Un endpoint oublie est ferme au lieu d'etre ouvert.
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())
// La portee est une liste separee par des espaces : on la decoupe.
.AddPolicy("LireCommandes", politique => politique
.RequireAuthenticatedUser()
.RequireAssertion(contexte => contexte.User
.FindAll("scope")
.SelectMany(portee => portee.Value.Split(' '))
.Contains("commandes.lire")))
// Deux exigences, toutes deux necessaires ; entre les valeurs admises
// d'une meme revendication, une seule suffit.
.AddPolicy("GererCommandes", politique => politique
.RequireRole("gestionnaire")
.RequireClaim("service", "ventes", "logistique"));
var app = builder.Build();
app.MapGet("/sante", () => "ok").AllowAnonymous();
app.MapGet("/profil", (HttpContext http) => http.User.Identity!.Name);
app.MapGet("/commandes", () => new[] { "C-1", "C-2" }).RequireAuthorization("LireCommandes");
app.MapDelete("/commandes/{id:int}", (int id) => TypedResults.NoContent())
.RequireAuthorization("GererCommandes");
app.Run();
// GET /commandes, scope "commandes.lire commandes.ecrire" -> 200 ["C-1","C-2"]
// GET /commandes, scope "commandes.ecrire" -> 403
// GET /commandes, sans jeton -> 401
// GET /sante, sans jeton -> 200 ok
// GET /profil, sans jeton -> 401 (repli)
// GET /nimporte, sans jeton -> 401, pas 404
// DELETE /commandes/7, gestionnaire + service ventes -> 204
// DELETE /commandes/7, gestionnaire + service compta -> 403
// DELETE /commandes/7, service ventes sans le role -> 403 La politique de repli est le filet qu'on oublie de tendre. RequireAuthorization() sans nom applique la politique par défaut, qui exige un utilisateur authentifié ; un point de terminaison sans aucune métadonnée d'autorisation, lui, reste ouvert — sauf si FallbackPolicy est définie. Le banc d'essai montre sa portée réelle : /profil exige un jeton sans rien déclarer, /sante ne reste ouvert que par AllowAnonymous, et une route inexistante répond 401 à un anonyme au lieu de 404, car le repli s'applique aussi aux requêtes qu'aucun point de terminaison n'a prises.
Une règle qui dépend de la ressource
Certaines règles ne se jugent qu'avec la donnée en main : seul l'auteur d'un document, ou un gestionnaire, peut le modifier. Une politique posée sur le point de terminaison est évaluée par UseAuthorization, avant que le document ne soit chargé ; la règle s'évalue donc dans le code, par IAuthorizationService.AuthorizeAsync, qui reçoit la ressource. La logique vit dans un IAuthorizationHandler, ici dérivé d'AuthorizationHandler<TRequirement, TResource>, appelé seulement pour cette exigence et ce type de ressource.
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = "https://auth.exemple.fr",
ValidAudience = "api-commandes",
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Cle"]!)),
NameClaimType = "name",
RoleClaimType = "role",
};
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ModifierDocument", politique => politique.AddRequirements(new PeutModifier()));
// Deux gestionnaires pour la meme exigence. Ils sont sans etat, donc
// singletons ; un gestionnaire qui lit une base se declare scoped.
builder.Services.AddSingleton<IAuthorizationHandler, ProprietaireHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, GestionnaireHandler>();
builder.Services.AddSingleton<DepotDocuments>();
var app = builder.Build();
// UseAuthorization s'execute avant que le document soit charge : la politique
// ne peut pas etre posee sur l'endpoint. On l'evalue ici, ressource en main.
app.MapPut("/documents/{id:int}", async Task<Results<NoContent, NotFound, ForbidHttpResult>> (
int id,
ClaimsPrincipal utilisateur,
DepotDocuments depot,
IAuthorizationService autorisation) =>
{
if (depot.Lire(id) is not { } document) return TypedResults.NotFound();
var resultat = await autorisation.AuthorizeAsync(utilisateur, document, "ModifierDocument");
return resultat.Succeeded ? TypedResults.NoContent() : TypedResults.Forbid();
}).RequireAuthorization();
app.Run();
public sealed record Document(int Id, string Titre, string Proprietaire);
public sealed class DepotDocuments
{
private readonly Dictionary<int, Document> table = new()
{
[1] = new Document(1, "Devis Dupont", Proprietaire: "alice"),
};
public Document? Lire(int id) => table.GetValueOrDefault(id);
}
public sealed class PeutModifier : IAuthorizationRequirement;
// Le second parametre de type est la ressource : ce gestionnaire n'est
// appele que si AuthorizeAsync recoit un Document.
public sealed class ProprietaireHandler : AuthorizationHandler<PeutModifier, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext contexte, PeutModifier exigence, Document document)
{
if (contexte.User.FindFirstValue("sub") == document.Proprietaire)
contexte.Succeed(exigence);
// Ne rien faire n'est pas refuser : un autre gestionnaire peut encore
// reussir. contexte.Fail() refuserait, quoi que disent les autres.
return Task.CompletedTask;
}
}
public sealed class GestionnaireHandler : AuthorizationHandler<PeutModifier>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext contexte, PeutModifier exigence)
{
if (contexte.User.IsInRole("gestionnaire")) contexte.Succeed(exigence);
return Task.CompletedTask;
}
}
// PUT /documents/1 sub=alice -> 204
// PUT /documents/1 sub=bob -> 403
// PUT /documents/1 sub=carole, role=gestionnaire -> 204
// PUT /documents/99 sub=bob -> 404
// PUT /documents/1 sans jeton -> 401 La combinaison obéit à une règle simple : une exigence est satisfaite si au moins un de ses gestionnaires appelle Succeed et qu'aucun n'appelle Fail, et une politique l'est si toutes ses exigences le sont. Deux gestionnaires pour une même exigence expriment donc un OU, deux exigences un ET. Reste un choix de conception : répondre 403 à Bob lui confirme que le document 1 existe. Quand l'existence même est sensible, 404 ne révèle rien, et c'est au gestionnaire de requête d'en décider, pas au framework.