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/

















