Implement the OAUTH 2.0 Token Exchange delegated flow between an Microsoft Entra ID API and an API protected using OpenIddict

This article shows how to implement the OAUTH 2.0 Token Exchange RFC 8693 delegated flow between two APIs, one using Microsoft Entra ID to authorize the HTTP requests and a second API protected using OpenIddict. The Microsoft Entra ID protected API uses the OAUTH 2.0 Token Exchange RFC 8693 delegated flow to get a new OpenIddict delegated access token using the AAD delegated access token. An ASP.NET Core Razor page application using a confidential client is used to get the Microsoft Entra ID access token with an access_as_user scope. By using the OAUTH 2.0 Token Exchange flow, delegated and application authorization mixing can be avoided and the trust between systems can be reduced.

Code: https://github.com/damienbod/OAuthGrantExchangeOidcDownstreamApi

History

2023-09-22 Update blog and packages

Setup OAUTH 2.0 Token Exchange RFC 8693 for delegated flows

A Razor page UI application is implemented using Microsoft Entra ID as the identity provider. This application authenticates using a confidential client against Microsoft Entra ID. The UI uses Microsoft.Identity.Web to implement the client authentication logic. The application requests an Microsoft Entra ID delegated access token to use the API which is also protected using Microsoft Entra ID. This API application needs to use a downstream API which is protected using a separate identity provider and is protected using OpenIddict. The API uses the Microsoft Entra ID access token to acquire another access token which the OpenIddict protected API accepts. The OAuth 2.0 token exchange RFC 8693 is used to implement this using the delegated flow. Only known Microsoft Entra ID delegated access tokens can be used. The identity provider which is used to host OpenIddict implements the server logic of token exchange flow. I have kept this separated but I assume this could be integrated into OpenIddict as well. It is important to validate the flow correctly and not just the flow but the mapping logic between the different identities used in the delegated access token. I did not implement the full spec in this demo, just the bits requires for the delegated flow. Impersonation and other such use cases for the RFC 8693 are not supported at present. Maybe I will implement this later.

Implement the OAUTH 2.0 Token Exchange client

The GetApiDataAsync method is used to get an access token for the OpenIddict downstream API and use it to get the data. It uses the GetApiTokenOauthGrantTokenExchange to the get the access token using the token exchange flow and then uses it to call the business API. The configuration values are used as well as the client secret to acquire the new token.

public async Task<List<string>> GetApiDataAsync(string aadAccessToken)
{
	try
	{
		var client = _clientFactory.CreateClient();

		client.BaseAddress = new Uri(
			_downstreamApi.Value.ApiBaseAddress);

		var access_token = await _apiTokenClient
			.GetApiTokenOauthGrantTokenExchange
		(
			_downstreamApi.Value.ClientId,
			_downstreamApi.Value.Audience,
			_downstreamApi.Value.ScopeForAccessToken,
			_downstreamApi.Value.ClientSecret,
			aadAccessToken
		);

		client.SetBearerToken(access_token);

		var response = await client.GetAsync("api/values");
		if (response.IsSuccessStatusCode)
		{
			var data = await JsonSerializer
				.DeserializeAsync<List<string>>(
			await response.Content.ReadAsStreamAsync());

			if(data != null)
				return data;

			return new List<string>();
		}

		throw new ApplicationException($"Status code: {response.StatusCode}, 
			Error: {response.ReasonPhrase}");
	}
	catch (Exception e)
	{
		throw new ApplicationException($"Exception {e}");
	}
}

The GetApiTokenOauthGrantTokenExchangeAad is an internal method used to call the OpenIddict identity provider to get the correct access token. This method is only called once per session or as long as the token is valid. This is normally cached once acquired. The method passes the required parameters which match the server settings.

private async Task<AccessTokenItem> GetApiTokenOauthGrantTokenExchangeAad(
	string clientId, 
	string audience,
	string scope, 
	string clientSecret, 
	string aadAccessToken)
{
	var tokenExchangeHttpClient = _httpClientFactory.CreateClient();
	tokenExchangeHttpClient.BaseAddress = new Uri(
		_downstreamApiConfigurations.Value.IdentityProviderUrl);

	var tokenExchangeSuccessResponse = await RequestDelegatedAccessToken
		.GetDelegatedApiTokenTokenExchange(
			new GetDelegatedApiTokenOAuthTokenExchangeModel
			{
				Scope = scope,
				AccessToken = aadAccessToken,
				ClientSecret = clientSecret,
				Audience = audience,
				ClientId = clientId,
				EndpointUrl = "/connect/oauthTokenExchangetoken",
				GrantExchangeHttpClient = tokenExchangeHttpClient
			}, _logger);

	if (tokenExchangeSuccessResponse != null)
	{
		return new AccessTokenItem
		{
			ExpiresIn = DateTime.UtcNow
				.AddSeconds(tokenExchangeSuccessResponse.expires_in),
			AccessToken = tokenExchangeSuccessResponse.access_token
		};
	}

	_logger.LogError(
		"no success response from oauth token exchange access token request");
	throw new ApplicationException(
		"no success response from oauth token exchange access token request");
}

The GetDelegatedApiTokenTokenExchange method implements the client business of the OAuth flow. This creates an authentication header using basic authentication as we only want to use a confidential client for this. The parameters are passed as a KeyValuePair and match the defined specifications in the RFC 8693 for the POST body. If the data is returned correctly a success response is returned, otherwise the error response like in the RFC definition with a few extra parameters. The OauthTokenExchangeSuccessResponse is used to get the successful HTTP response from the POST request.

public static async Task<OauthTokenExchangeSuccessResponse?> GetDelegatedApiTokenTokenExchange(
	GetDelegatedApiTokenOAuthTokenExchangeModel reqData, ILogger logger)
{
	if (reqData.GrantExchangeHttpClient == null)
		throw new ArgumentException("Httpclient missing, is null");

	string credentials = CreateBasicAuthenticationHeader(reqData);

	reqData.GrantExchangeHttpClient.DefaultRequestHeaders.Authorization =
		new AuthenticationHeaderValue("Basic", credentials);

	KeyValuePair<string, string>[] oauthTokenExchangeBody = CreateTokenExchangeBody(reqData);

	var response = await reqData.GrantExchangeHttpClient.PostAsync(reqData.EndpointUrl,
		new FormUrlEncodedContent(oauthTokenExchangeBody));

	if (response.IsSuccessStatusCode)
	{
		var tokenResponse = await JsonSerializer.DeserializeAsync<OauthTokenExchangeSuccessResponse>(
		await response.Content.ReadAsStreamAsync());
		return tokenResponse;
	}

	if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
	{
		// Unauthorized error
		var errorResult = await JsonSerializer.DeserializeAsync<OauthTokenExchangeErrorResponse>(
	   await response.Content.ReadAsStreamAsync());

		if (errorResult != null)
		{
			logger.LogInformation("{error} {error_description} {correlation_id} {trace_id}",
				errorResult.error,
				errorResult.error_description,
				errorResult.correlation_id,
				errorResult.trace_id);
		}
		else
		{
			logger.LogInformation("RequestDelegatedAccessToken Error, Unauthorized unknown reason");
		}
	}
	else
	{
		// unknown error, log
		logger.LogInformation("RequestDelegatedAccessToken Error unknown reason");
	}

	return null;
}

The CreateTokenExchangeBody creates the body. This is implemented for the delegated flow which requests an access token. The subject_token parameter is used to pass the Microsoft Entra ID access token.

private static KeyValuePair<string, string>[] CreateTokenExchangeBody(
	GetDelegatedApiTokenOAuthTokenExchangeModel reqData)
{
	// Content-Type: application/x-www-form-urlencoded
	var oauthTokenExchangeBody = new[]
	{
		new KeyValuePair<string, string>("grant_type", 
			OAuthGrantExchangeConsts.GRANT_TYPE),
		new KeyValuePair<string, string>("audience", reqData.Audience),
		new KeyValuePair<string, string>("subject_token_type", 
			OAuthGrantExchangeConsts.TOKEN_TYPE_ACCESS_TOKEN),
		new KeyValuePair<string, string>("subject_token", reqData.AccessToken),
		new KeyValuePair<string, string>("scope", reqData.Scope)

		// new KeyValuePair<string, string>("resource", "--optional--")
		// new KeyValuePair<string, string>("requested_token_type", "--optional--")
		// new KeyValuePair<string, string>("actor_token", "--optional--")
		// new KeyValuePair<string, string>("actor_token_type", "--optional--")
	};

	return oauthTokenExchangeBody;
}

I created a consts class to implement the specification per defined string types.

public class OAuthGrantExchangeConsts
{
    public const string TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
    public const string TOKEN_TYPE_REFRESH_TOKEN = "urn:ietf:params:oauth:token-type:refresh_token";
    public const string TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token";
    public const string TOKEN_TYPE_SAML1 = "urn:ietf:params:oauth:token-type:saml1";
    public const string TOKEN_TYPE_SAML2 = "urn:ietf:params:oauth:token-type:saml2";

    public const string GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";

    public const string ERROR_INVALID_REQUEST = "invalid_request";
    public const string ERROR_INVALID_CLIENT = "invalid_client";
    public const string ERROR_INVALID_GRANT = "invalid_grant";
    public const string ERROR_UNAUTHORIZED_CLIENT = "unauthorized_client";
    public const string ERROR_UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type";
    public const string ERROR_INVALID_SCOPE = "invalid_scope";

    // ... more consts, see the code for the full definitions
}

That’s all that is required to implement the client side of the OAuth Token exchange delegated flow. If you require other flow types from this specification, then this needs to be implemented. See the RFC docs for details (In the links below)

Implement the OAUTH 2.0 Token Exchange server

The server part of the flow needs to validate a few different things. The identity provider validates the POST request using BASIC authentication, then it validates the body of the HTTP POST request. The server needs to fully validate the Microsoft Entra ID access token including the signature, aud and iss as per standard. Once the Microsoft Entra ID token is validated, the claims can be used to authorize the identity delegated in the access token. Only delegated access tokens should be accepted and so in an Microsoft Entra ID token V2, you can do this be checking for an oid claim and a scp claim. These claims might be renamed if using the default Microsoft namespaces. The server must match its users to the Microsoft Entra ID users. You need to be careful when using emails for this. The Azure OID a good claim to use for this.

The server must do the following:

  • Validate the Basic authentication
  • Validate the body of the POST request as per standard
  • Validate the access token fully
  • Validate the claims, do the authorization
  • Generate the new access token as per standard

Validate Basic authentication

Basic authentication is used so that only confidential clients can use the API. This is not the strongest of authentication methods but it is how the specification recommends sending the clientId and clientSecret. The used authentication is validated using an Authorize attribute and the correct scheme.

[Authorize(AuthenticationSchemes = BasicAuthenticationDefaults.AuthenticationScheme)]
[HttpPost("~/connect/oauthTokenExchangetoken"), Produces("application/json")]
public async Task<IActionResult> Exchange([FromForm] OauthTokenExchangePayload oauthTokenExchangePayload)
{
	// Implement validate and create AT logic
}

Once authenticated, the validation can begin.

Validate payload of POST request

The payload of the HTTP POST request is validated. This checks that the body has the expected values and the ones which are allowed. If any are incorrect, the error parameter of the unauthorized request is returned as defined in the specification.

var (Valid, Reason, Error) = ValidateOauthTokenExchangeRequestPayload
	.IsValid(oauthTokenExchangePayload, 
		_oauthTokenExchangeConfigurationConfiguration);

if(!Valid)
{
	return UnauthorizedValidationParametersFailed(
		oauthTokenExchangePayload, Reason, Error);
}

Validate access token and signature

If the payload is validated, then the access token sent using the subject_token parameter is validated. This must be fully validated including the signature. The well known endpoints of the Microsoft Entra ID identity provider is used to get the public keys of the certificate used to create the JWT token. This is used to validate the token signature. The iss and the aud are validated and checked against the expected values.

// get well known endpoints and validate access token sent in the assertion
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
	_oauthTokenExchangeConfigurationConfiguration.AccessTokenMetadataAddress, 
	new OpenIdConnectConfigurationRetriever());

var wellKnownEndpoints =  await configurationManager
	.GetConfigurationAsync();

var accessTokenValidationResult = 
	ValidateOauthTokenExchangeRequestPayload.ValidateTokenAndSignature(
		oauthTokenExchangePayload.subject_token,
		_oauthTokenExchangeConfigurationConfiguration,
		wellKnownEndpoints.SigningKeys);

if(!accessTokenValidationResult.Valid)
{
	return UnauthorizedValidationTokenAndSignatureFailed(
		oauthTokenExchangePayload, accessTokenValidationResult);
}

The ValidateTokenAndSignature method checks and validates the token.

public static (bool Valid, string Reason, ClaimsPrincipal? 
	ClaimsPrincipal) ValidateTokenAndSignature(
	string jwtToken, 
	OauthTokenExchangeConfiguration oboConfiguration, 
	ICollection<SecurityKey> signingKeys)
{
	try
	{
		var validationParameters = new TokenValidationParameters
		{
			RequireExpirationTime = true,
			ValidateLifetime = true,
			ClockSkew = TimeSpan.FromMinutes(1),
			RequireSignedTokens = true,
			ValidateIssuerSigningKey = true,
			IssuerSigningKeys = signingKeys,
			ValidateIssuer = true,
			ValidIssuer = oboConfiguration.AccessTokenAuthority,
			ValidateAudience = true, 
			ValidAudience = oboConfiguration.AccessTokenAudience
		};

		ISecurityTokenValidator tokenValidator = new JwtSecurityTokenHandler();

		var claimsPrincipal = tokenValidator
			.ValidateToken(jwtToken, validationParameters, out var _);

		return (true, string.Empty, claimsPrincipal);
	}
	catch (Exception ex)
	{
		return (false, $"Access Token Authorization failed {ex.Message}", null);
	}
}

Validate claims and authorize the access token

Now that the token is validated, the returned claimsPrincipal can be used to check and authorize the identity from the access token. The token must be validated that it is a delegated token and must contain a scp claim and an oid claim. The scp is what we added to use the service. We added an access_as_user claim. I would avoid roles as roles can be used for application tokens as well. I matched the name claim with the email from to the identity in the second IAM system. Using the OID claim would be a more trusted way of doing this.

// get claims from aad token and re use in OpenIddict token
var claimsPrincipal = accessTokenValidationResult.ClaimsPrincipal;

var isDelegatedToken = ValidateOauthTokenExchangeRequestPayload
	.IsDelegatedAadAccessToken(claimsPrincipal);

if (!isDelegatedToken)
{
	return UnauthorizedValidationRequireDelegatedTokenFailed();
}

var name = ValidateOauthTokenExchangeRequestPayload
	.GetPreferredUserName(claimsPrincipal);
var isNameAndEmail = ValidateOauthTokenExchangeRequestPayload
	.IsEmailValid(name);
if(!isNameAndEmail)
{
	return UnauthorizedValidationPrefferedUserNameFailed();
}

// validate user exists
var user = await _userManager.FindByNameAsync(name);
if (user == null)
{
	return UnauthorizedValidationNoUserExistsFailed();
}

The delegated access token check is validated using the oid and the scp claims. Sometimes the claims get changed using the namespaces from Microsoft. I added a fallback check to validate both.

public static bool IsDelegatedAadAccessToken(ClaimsPrincipal claimsPrincipal)
{
	// oid if magic MS namespaces not user
	var oid = claimsPrincipal.Claims.FirstOrDefault(t => t.Type == 
		"http://schemas.microsoft.com/identity/claims/objectidentifier");
	// scp if magic MS namespaces not added
	var scp = claimsPrincipal.Claims.FirstOrDefault(t => t.Type 
		== "http://schemas.microsoft.com/identity/claims/scope");

	if (oid != null && scp != null)
	{
		return true;
	}

	oid = claimsPrincipal.Claims.FirstOrDefault(t => t.Type == "oid");
	scp = claimsPrincipal.Claims.FirstOrDefault(t => t.Type == "scp");
	if (oid != null && scp != null)
	{
		return true;
	}

	return false;
}

Generate new access token

A new access token is created using the same certificate as the defualt one used by OpenIddict. This makes it possible to validate the token using the well known endpoints.

// use data and return new access token
var (ActiveCertificate, _) = await Startup.GetCertificates(_environment, _configuration);

var tokenData = new CreateDelegatedAccessTokenPayloadModel
{
	Sub = Guid.NewGuid().ToString(),
	ClaimsPrincipal = claimsPrincipal,
	SigningCredentials = ActiveCertificate,
	Scope = _oauthTokenExchangeConfigurationConfiguration.ScopeForNewAccessToken,
	Audience = _oauthTokenExchangeConfigurationConfiguration.AudienceForNewAccessToken,
	Issuer = _oauthTokenExchangeConfigurationConfiguration.IssuerForNewAccessToken,
	OriginalClientId = _oauthTokenExchangeConfigurationConfiguration.AccessTokenAudience
};

var accessToken = CreateDelegatedAccessTokenPayload.GenerateJwtTokenAsync(tokenData);

_logger.LogInformation("OBO new access token returned sub {sub}", tokenData.Sub);

if(IdentityModelEventSource.ShowPII)
{
	_logger.LogDebug("OBO new access token returned for sub {sub} for user {Username}", tokenData.Sub,
		ValidateOauthTokenExchangeRequestPayload.GetPreferredUserName(claimsPrincipal));
}

return Ok(new OauthTokenExchangeSuccessResponse
{
	expires_in = 60 * 60,
	access_token = accessToken,
	scope = oauthTokenExchangePayload.scope
});

The claims are added like in the RFC specification.

public static string GenerateJwtTokenAsync(CreateDelegatedAccessTokenPayloadModel payload)
{
	SigningCredentials signingCredentials = new X509SigningCredentials(payload.SigningCredentials);

	var alg = signingCredentials.Algorithm;

	//{
	//  "alg": "RS256",
	//  "kid": "....",
	//  "typ": "at+jwt",
	//}

	var subject = new ClaimsIdentity(new[] {
			new Claim("sub", payload.Sub),              
			new Claim("scope", payload.Scope),
			new Claim("act", $"{{ \"sub\": \"{payload.OriginalClientId}\" }}", JsonClaimValueTypes.Json )
		});

	if(payload.ClaimsPrincipal != null)
	{
		var name = ValidateOauthTokenExchangeRequestPayload.GetPreferredUserName(payload.ClaimsPrincipal);
		var azp = ValidateOauthTokenExchangeRequestPayload.GetAzp(payload.ClaimsPrincipal);
		var azpacr = ValidateOauthTokenExchangeRequestPayload.GetAzpacr(payload.ClaimsPrincipal);

		if(!string.IsNullOrEmpty(name))
			subject.AddClaim(new Claim("name", name));

		if (!string.IsNullOrEmpty(name))
			subject.AddClaim(new Claim("azp", azp));

		if (!string.IsNullOrEmpty(name))
			subject.AddClaim(new Claim("azpacr", azpacr));
	}

	var tokenHandler = new JwtSecurityTokenHandler();
	var tokenDescriptor = new SecurityTokenDescriptor
	{       
		Subject = subject,
		Expires = DateTime.UtcNow.AddHours(1),
		IssuedAt = DateTime.UtcNow,
		Issuer = "https://localhost:44318/",
		Audience = payload.Audience,
		SigningCredentials = signingCredentials,
		TokenType = "at+jwt"
	};

	tokenDescriptor.AdditionalHeaderClaims ??= new Dictionary<string, object>();

	if (!tokenDescriptor.AdditionalHeaderClaims.ContainsKey("alg"))
	{
		tokenDescriptor.AdditionalHeaderClaims.Add("alg", alg);
	}

	var token = tokenHandler.CreateToken(tokenDescriptor);

	return tokenHandler.WriteToken(token);
}

Start all the applications and if everything is configured correctly with your Microsoft Entra ID tenant, the data from the OpenIddict protected API can be used and displayed in the Microsoft Entra ID UI.

Links

https://documentation.openiddict.com/configuration/application-permissions.html

https://datatracker.ietf.org/doc/html/rfc8693

https://www.youtube.com/watch?v=Ue8HKBGkIJY&t=

https://github.com/damienbod/OnBehalfFlowOidcDownstreamApi

https://www.rfc-editor.org/rfc/rfc6749#section-5.2

https://github.com/blowdart/idunno.Authentication/tree/dev/src/idunno.Authentication.Basic

3 comments

  1. […] Implement the OAUTH 2.0 Token Exchange delegated flow between an Azure AD API and an API protected u… (Damien Bowden) […]

  2. Martin Rublik · · Reply

    Hi any idea if this could be possibly implemented in opposite way? Does Azure AD support rfc8693 ?

    Thank you.

    Martin

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.