IdentityServer: Token Exchange

This custom Token Exchange Grant Flow allows IdentityServer to exchange a reference token through the creation of a new JWT token. There are many reasons why this may need to be done. The documentation on this process isn’t as clear and I thought it would help others if I shared what I learned and experienced.

We’ll also use POSTMAN to demonstrate these flows.

GitHub: identityserver-token-exchange

Reference Tokens

A reference token can be used for a higher level of security but this presents a challenge and can put a huge load on the IdentityServer because downstream microservices may use Token Introspection. Imagine, a user requests a Reference Token and that token has to hit the introspection endpoint for each microservice that it comes in contact with. This is where the problem exists… Another option is to use an aggregate microservice or incorporate this into an Azure APIM policy.

API: Token Introspection Code

A .NET API may use code that introspects the Reference Token. When the API receives the Reference Token it will post to the IdentityServer to get the claims associated with that token.

services.AddAuthentication("token")
    .AddOAuth2Introspection("token", options =>
    {
        options.Authority = Constants.Authority;

        // this maps to the API resource name and secret
        options.ClientId = "resource1";
        options.ClientSecret = "secret";
    });

Token Exchange Code

This code will allow the creation of a custom Grant Flow that allows a Reference Token to go in and a JWT Token to come out.

public class TokenExchangeFlow : IExtensionGrantValidator
{
    // logging
    private readonly ILogger<TokenExchangeFlow> _logger;

    private readonly IdentityServerTools _tools;
    private readonly ITokenValidator _validator;

    /// <summary>
    /// This grant type validates incoming reference tokens and returns a "JWT" token to the APIM.
    /// </summary>
    public string GrantType => OidcConstants.GrantTypes.TokenExchange;

    public TokenExchangeFlow(ILogger<TokenExchangeFlow> logger, IdentityServerTools tools, ITokenValidator validator)
    {
        _logger = logger;
        _tools = tools;
        _validator = validator;
    }

    public async Task ValidateAsync(ExtensionGrantValidationContext context)
    {
        _logger.LogInformation($"Client ID: {context.Request.ClientId} Using Token Exchange Grant Flow.");

        try
        {
            // default response is error
            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);

            // incoming token
            var subjectToken = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectToken);

            // token type
            var subjectTokenType = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectTokenType);

            // mandatory parameters
            if (String.IsNullOrWhiteSpace(subjectToken)) {
                return;
            }

            // for our impersonation/delegation scenario we require an access token
            if (!String.IsNullOrWhiteSpace(subjectToken)) {
                return;
            }

            // validate the incoming access token with the built-in token validator
            var validationResult = await _validator.ValidateAccessTokenAsync(subjectToken);

            if (validationResult == null || validationResult.IsError) {
                return;
            }

            // variables
            var clientId = validationResult.Claims?.First(x => x.Type == JwtClaimTypes.ClientId).Value;

            // scopes
            var scopes = validationResult.Claims?.Where(x => x.Type == JwtClaimTypes.Scope)
                                                            .Select(x => x.Value)
                                                            .ToList();

            // override scopes
            context.Request.RequestedScopes = scopes;

            // parsed scopes
            var parsedScopes = new HashSet<ParsedScopeValue>();

            foreach (var scope in scopes)
            {
                parsedScopes.Add(new ParsedScopeValue(scope));
            }
            
            context.Request.ValidatedResources.ParsedScopes = parsedScopes;

            // reference token expiration
            var refTokenExpiration = validationResult.Claims?.SingleOrDefault(x => x.Type == JwtClaimTypes.Expiration)!.Value;

            // the spec allows for various token types, most commonly your return an access token
            var customResponse = new Dictionary<string, object>
                {
                    { OidcConstants.TokenResponse.IssuedTokenType, OidcConstants.TokenTypeIdentifiers.Jwt },
                    { "reference_token_expires_in", refTokenExpiration }
                };

            // request context
            context.Request.ClientId = clientId;

            // set validation result
            context.Result = new GrantValidationResult(customResponse: customResponse);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, ex.Message, null);
            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, errorDescription: ex.Message);
        }
    }
}

Client Registration for Token Exchange Grant Flow

    public static IEnumerable<Client> Clients =>
        new Client[]
        {
         ...
            /* token exchange */
            new Client {
                ClientId = "client-token-exchange",
                ClientSecrets = { new Secret("secret".Sha256()) },
                ClientName = "Token Exchange (Client)",
                AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
                RedirectUris = {
                    "http://localhost:5473/signin-oidc"
                },
                PostLogoutRedirectUris = {
                    "http://localhost:5473/signin-oidc"
                },

                AllowedScopes = {
                    "api1",
                    "api2",
                    "openid",
                    "profile",
                    "email",
                    "verification"
                },
                AllowOfflineAccess = true,
                EnableLocalLogin = false,
                AccessTokenType = AccessTokenType.Reference,
                RequirePkce = true
            },

POSTMAN Token Exchange

Get Reference Token

Exchange for JWT Token

Another way to exchange this token would be to make a more direct post using raw format and include this as below.

Further Reading

https://docs.duendesoftware.com/identityserver/v5/tokens/extension_grants/token_exchange/
https://docs.duendesoftware.com/identityserver/v6/apis/aspnetcore/reference/