The profile service
The IProfileService decides what claims a token carries about its user, and
whether the user may still have one. Customization shows a profile service
registered; this page is what the server tells it, what it asks it for, and where it asks. The code is included from
samples/Server.Tokens.
/// <summary>A profile service that knows why it is being asked.</summary>
public sealed partial class DirectoryProfileService(ILogger<DirectoryProfileService> logger) : IProfileService
{
private static readonly HashSet<string> Departed = new(StringComparer.Ordinal) { "e-0042" };
/// <inheritdoc />
public Task GetProfileDataAsync(ProfileDataRequestContext context, CancellationToken cancellationToken = default)
{
// The validated request the token is cut for: pattern-match it for the grant and the raw parameters.
if (context.ProtocolRequest is ValidatedTokenRequest { GrantType: OidcConstants.GrantTypes.RefreshToken })
{
Refreshing(logger, context.Application.Identifier);
}
// Only the claim types the request asked for: an API's UserClaims, or an identity resource's.
context.AddRequestedClaims(
[
new Claim(JwtClaimTypes.Name, "Alice Smith"),
new Claim(JwtClaimTypes.Email, "alice@example.com"),
]);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task IsActiveAsync(IsActiveContext context, CancellationToken cancellationToken = default)
{
// Asked at the token endpoint, the authorize endpoint for a signed-in user, and wherever an access token
// with a sub is validated (context.Caller says which). Inactive ends the user's tokens and session there.
var subject = context.Subject.FindFirst(JwtClaimTypes.Subject)?.Value;
context.IsActive = subject is null || !Departed.Contains(subject);
return Task.CompletedTask;
}
[LoggerMessage(Level = LogLevel.Information, Message = "Re-reading claims on a refresh by {ClientId}")]
private static partial void Refreshing(ILogger logger, string clientId);
}
What the profile service is told
New in 0.126.0. Besides the subject, the application, the caller and the claim types, a profile service is handed what Duende 8.0.1 hands it, measured:
- ProtocolRequest, an
IValidatedRequest —
Application,Subject,SessionId,IssuerNameand theOptionsthe request was validated under — for each token a grant issues. It is the validated token request: pattern-match it for the grant, the raw parameters and the rest, as the sample does to tell a refresh. - RequestedResources, the
ResourceValidationResultthe token is cut for: the access token's narrowed to the resource indicator the request named, the identity token's the whole grant. At userinfo, the enabled identity resources the token reaches, with no protocol request, as in Duende.
ProfileDataRequestContext also has Duende's empty constructor and settable properties, for a host building one to test
its own profile service; its other constructors refuse a null argument by name, as Duende's do.
Every validated request is one
New in 0.155.0, Duende 8.0.1's, measured from its validators' IL. The authorize, pushed authorization, end-session,
device authorization and backchannel requests are IValidatedRequests too, as the token request and SAML's
ValidatedAuthnRequest already were, and carry what the interface says:
| Request | IssuerName |
Options |
Subject, SessionId |
|---|---|---|---|
ValidatedAuthorizeRequest |
the authorize endpoint | the validator | the user; their session, read by the endpoint for a signed-in user, and the code issued carries it |
ValidatedPushedAuthorizationRequest |
the validator | the validator | none: a push is a client's |
ValidatedEndSessionRequest |
the validator | the validator | the user and their session, as before |
ValidatedDeviceAuthorizationRequest |
the endpoint, which reads it back for the verification URI | the validator | none: a device asks before any user is involved |
ValidatedBackchannelAuthenticationRequest |
the validator | the validator | a principal holding SubjectId as its sub; no session |
The request object's claims reach the redirect URI validator and the validated request, as Duende's
RedirectUriValidationContext.RequestObjectValues and ValidatedAuthorizeRequest.RequestObjectValues: the signed
payload less the JWT's own claims (iss, aud, exp and the rest), one claim per value — so a host can tell a signed
value from one in the query string, which Raw holds merged. They travel by a new
IAuthorizeRequestValidator.ValidateAsync overload taking them, which the authorize endpoint and the pushed
authorization validator call; a validator that does not implement it is asked without them.
Resource indicators are judged by the resource validator, through Duende's
IResourceValidator.ValidateRequestedResourcesAsync and ResourceValidationRequest (a default interface method over
ValidateAsync, so a host's validator judges them the same way), into ResourceValidationResult.InvalidResourceIndicators,
which Succeeded counts. Duende's rules: with indicators, the APIs the scopes reach are kept when they do not require
one or are named, and an indicator naming none of them is invalid; without, an API with RequireResourceIndicator is
left out of the request's resources; a result with anything invalid grants nothing. The authorize and backchannel
validators ask it, and answer as before: an invalid scope is refused first (invalid_scope), then an indicator too long,
malformed, or naming no API the scopes reach (invalid_target). PushedAuthorizationRequestValidator takes the issuer
through a new constructor overload with an IIssuerNameService, which the container chooses; the five-parameter
constructor stays, and a push built through it carries no issuer.
Ours, recorded: a request wrong in both ways is refused for its scope first, as before; Duende answers
invalid_target. Duende writes the issuer on the authorize request in its validator, so a custom authorize request
validator sees it; here the validator cannot read the HTTP request, and the endpoint writes the issuer and the session
once it has answered. Duende leaves the issuer unset on the push, end-session, device and backchannel requests, and the
options on the push and end-session ones; here every one carries both. Duende's anonymous authorize request has the
session "", this one null. An indicator naming an API reached only by a scope that is itself refused is listed in
InvalidResourceIndicators here; Duende looks the API up by every requested scope — either way the request is refused
for its scope. A request object fetched by request_uri is stored as parameters once fetched, so its claims are not
carried past the first pass. Not taken, and given release 33: CIBA completion's auth_time and idp (Duende adds both to
the stored subject; the completion here is told a subject id, not the approving user, and the request stores no
principal), and Duende's scopes for a token request that names none — which would change what existing clients receive.
What it is asked for, and what it may give
Changed in 0.127.0, and one part of it is breaking. Duende 8.0.1's rules, measured from its DefaultClaimsService:
An access token asks only for what its APIs name. The claim types come from the API resources' and API scopes'
UserClaims, not the identity resources'. A user's profile belongs in the identity token and at userinfo; until 0.127.0 it went into every API's access token too. A host whose API readnameoremailfrom the access token now names them where the API is configured:// An access token asks the profile service only for the claims its APIs name. An API that reads the user's name // and email from the token says so here. .AddInMemoryApiScopes([new ApiScope("orders", userClaims: [JwtClaimTypes.Name, JwtClaimTypes.Email])])Protocol claims are not the profile service's to give. It is never asked for
at_hash,aud,amr,auth_time,azp,c_hash,client_id,exp,idp,iat,iss,jti,nonce,nbf,reference_token_id,sid,sub,scopeorcnf, and any of them it issues is dropped from both tokens. Until 0.127.0 a profile service'sscopeclaim widened the token's scopes, and itscnforsidrebound it. The token's ownsub,auth_time,idp,amr,acrandactstill come from the signed-in user.It is asked even when nothing is left to request, so it may add what it always adds; and the built-in profile services —
DefaultProfileService, the test-user one — issue nothing for an empty request, as Duende's do. Until 0.127.0 they issued every claim, which never reached a token only because nothing asked with an empty list.At userinfo without a session, the user handed to the profile service is the token's claims less those about the token itself (
aud,client_id,scope,sid,jtiand the like);substays.
Changed in 0.173.0, and breaking for a consumer that parsed the old shapes. The token's auth_time is a JSON number and
its amr a JSON array of strings, with one method as with several, as OpenID Connect Core 1.0 §2 defines them and Duende
8.0.1 writes them. Until 0.173.0 a user signed in through IdentityServerUser reached every token as
"auth_time": "1793660261", and a single method as "amr": "pwd". The same holds at userinfo, when the profile service
releases either, and at introspection; a grant stored before the upgrade issues the new shapes too. Every other claim,
the profile service's own among them, is still written as its value type says. Who is affected and what to do:
replacing Duende.
Users it no longer calls active
New in 0.128.0, Duende 8.0.1's, measured. A session cookie and an access token both outlive the account they were
issued for. The profile service's IsActiveAsync is asked wherever either is about to answer for a user, with Duende's
caller name in context.Caller (IdentityServerConstants.ProfileIsActiveCallers):
| Where | ProfileIsActiveCallers. |
An inactive user |
|---|---|---|
| The authorize endpoint, for a signed-in user | AuthorizeEndpoint |
is treated as signed out: the sign-in page, or login_required under prompt=none |
Any validation of an access token with a sub — introspection, userinfo, the local API, a token-exchange subject token |
AccessTokenValidation |
makes the token invalid_token: inactive at introspection, 401 at userinfo and the local API |
| SAML single sign-on, for a signed-in user | SamlSsoEndpoint |
is treated as signed out: the sign-in page, or NoPassive for a passive request |
| IdP-initiated SAML sign-on | SamlSsoEndpoint |
is sent to sign in |
Already asked before 0.128.0, and unchanged: the token endpoint, for every grant that names a user (authorization code,
refresh, device code, CIBA, an extension grant), and userinfo (UserInfoRequestValidation, after
AccessTokenValidation, as Duende asks both).
- A machine's token is not asked about. An access token with no
sub— client credentials — has no user to be inactive. Nor is a token whose client cannot be found: that is not this check's to refuse. - For an access token, the user is the token's claims, with the client it was issued to as
context.Application. A profile service that looks the user up bysubworks unchanged; one that reads a claim only the session cookie has will not find it here. - IdP-initiated SAML is stricter than Duende, whose IdP-initiated service checks only that the user is signed in. An assertion is an assertion whichever door asked for it.
ProfileIsActiveCallers.IdentityTokenValidationis not added: Duende declares it and never asks with it.
DefaultProfileService answers active for everyone, so a host that has not written IsActiveAsync sees no change. The
test-user and ASP.NET Identity profile services answer from the user they find, and inactive for one they do not: a token
whose user has been deleted stops at introspection and the local API, not only at refresh. A host with a deactivated flag
or a lockout has it honoured beyond the token endpoint — ASP.NET Core Identity shows a lockout.