Table of Contents

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, IssuerName and the Options the 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 ResourceValidationResult the 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 read name or email from 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, scope or cnf, and any of them it issues is dropped from both tokens. Until 0.127.0 a profile service's scope claim widened the token's scopes, and its cnf or sid rebound it. The token's own sub, auth_time, idp, amr, acr and act still 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, jti and the like); sub stays.

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 by sub works 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.IdentityTokenValidation is 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.