Implement the On Behalf Of flow between an Azure AD protected API and an API protected using OpenIddict

This article shows how to implement the On Behalf Of flow between two APIs, one using Azure AD to authorize the HTTP requests and a second API protected using OpenIddict. The Azure AD protected API uses the On Behalf Of flow (OBO) 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 Azure AD access token with an access_as_user scope. By using the OBO flow, delegated and application authorization mixing can be avoided and the trusted between systems can be reduced.

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

Architecture Setup

We have a solution setup using OpenIddict as the identity provider. This could be any OpenID Connect server. We have APIs implemented and secured using users from this system and protected with the OpenIddict identity provider. With some Azure AD system constraints and new collaboration requirements, we need to support users from Azure AD. Due to this, our users from Azure AD need to use the APIs protected with the OpenIddict identity provider. The On Behalf Of flow is used to support this. An new Azure AD protected API is used to accept the Azure AD access token used for accessing the “protected” APIs. This token is used to get a new OpenIddict delegated access token which can be used for the existing APIs. For this to work, the users must exist in both systems. We match the accounts using an email. You could also use the Azure OID and save this to the existing system. It is harder to add custom IDs to Azure AD from other identity providers. A second way of implementing this would be to use the OAuth token exchange RFC 8693. This supports much more than we require. I based the implementation on the Microsoft documentation. By using the OBO flow, delegated access tokens can be used everyway and the trust required can be reduced between the APIs. I try to use delegated user access tokens whenever possible. Application access tokens should be avoided for UIs or user delegated flows.

Implement the OBO client

The Azure AD protected API uses the On Behalf Of flow to get a new access token to access the downstream API protected with the OpenIddict identity provider. The Azure AD delegated user access token is used to acquire a new access token.

[HttpGet]
public async Task<IEnumerable<string>?> Get()
{
	var scopeRequiredByApi = new string[] { "access_as_user" };
	HttpContext.VerifyUserHasAnyAcceptedScope(scopeRequiredByApi);

	var aadBearerToken = Request.Headers[HeaderNames.Authorization]
		.ToString().Replace("Bearer ", "");

	var dataFromDownstreamApi = await _apiService
		.GetApiDataAsync(aadBearerToken);
		
	return dataFromDownstreamApi;
}

The GetApiTokenObo method is used to get the access token from a distributed cache or from the OpenIddict identity provider using the OBO flow. With the new user access token, the protected API can be used.

var client = _clientFactory.CreateClient();

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

var access_token = await _apiTokenClient.GetApiTokenObo(
	_downstreamApi.Value.ClientId,
	_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>();
}

The GetApiTokenOboAad method uses the OBO helper library to acquire the new access token. The GetDelegatedApiTokenOboModel parameter class is used to define the required values.

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

	var oboSuccessResponse = await RequestDelegatedAccessToken
		.GetDelegatedApiTokenObo(
		new GetDelegatedApiTokenOboModel
		{
			Scope = scope,
			AccessToken = aadAccessToken,
			ClientSecret = clientSecret,
			ClientId = clientId,
			EndpointUrl = "/connect/obotoken",
			OboHttpClient = oboHttpClient
		}, _logger);

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

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

The GetDelegatedApiTokenObo method implements the OBO client request. The parameters are passed in the body of the HTTP POST request as defined on the Microsoft documentation. The requests returns a success response or an error response.

public static async Task<OboSuccessResponse?> GetDelegatedApiTokenObo(
	GetDelegatedApiTokenOboModel reqData, ILogger logger)
{
	if (reqData.OboHttpClient == null)
		throw new ArgumentException("Httpclient missing, is null");

	// Content-Type: application/x-www-form-urlencoded
	var oboTokenExchangeBody = new[]
	{
		new KeyValuePair<string, string>("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
		new KeyValuePair<string, string>("client_id", reqData.ClientId),
		new KeyValuePair<string, string>("client_secret", OboExtentions.ToSha256(reqData.ClientSecret)),
		new KeyValuePair<string, string>("assertion", reqData.AccessToken),
		new KeyValuePair<string, string>("scope", reqData.Scope),
		new KeyValuePair<string, string>("requested_token_use", "on_behalf_of"),
	};

	var response = await reqData.OboHttpClient.PostAsync(reqData.EndpointUrl, 
		new FormUrlEncodedContent(oboTokenExchangeBody));

	if (response.IsSuccessStatusCode)
	{
		var tokenResponse = await JsonSerializer.DeserializeAsync<OboSuccessResponse>(
		await response.Content.ReadAsStreamAsync());
		return tokenResponse;
	}
	if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
	{
		// Unauthorized error
		var errorResult = await JsonSerializer.DeserializeAsync<OboErrorResponse>(
	   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 following HTTP POST request is sent to the identity provider supporting the OBO flow. As you can see, a secret (or certificate) is used so only a trusted application can implement an OBO client.

// Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&client_id={clientId}
&client_secret={clientSecret}
&assertion={accessToken}
&scope={scope}
&requested_token_use=on_behalf_of

Implement the OBO token exchange

The identity provider supporting the OBO flow needs to accept the POST request, validate the request and issue a new access token using the correct signature. It might be possible to integrate this flow better into the different identity providers, I kept this separate which would make it easier to re-use on different IDPs. All you need is the correct signature created from the certificate used by the identity provider. The Exchange method needs to validate the request correctly. The access token sent in the assertion parameter needs full validation included the signature, issuer and audience. You should not allow unspecific access tokens to be used. The method also validates the client ID and a client secret to validate the trusted application which sent the request. You could also use a certificate for this which can then use client assertions as well.

Logging is added and PII logs are also logged if this feature is activate. Once all the parameters are validated, the CreateDelegatedAccessTokenPayloadModel class is used to create the new user access token using the data from the payload and the original valid AAD access token.

[AllowAnonymous]
[HttpPost("~/connect/obotoken"), Produces("application/json")]
public async Task<IActionResult> Exchange([FromForm] OboPayload oboPayload)
{
	var (Valid, Reason) = ValidateOboRequestPayload.IsValid(oboPayload, _oboConfiguration);

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

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

	var wellKnownEndpoints =  await configurationManager.GetConfigurationAsync();

	var accessTokenValidationResult = ValidateOboRequestPayload.ValidateTokenAndSignature(
		oboPayload.assertion,
		_oboConfiguration,
		wellKnownEndpoints.SigningKeys);
	
	if(!accessTokenValidationResult.Valid)
	{
		return UnauthorizedValidationTokenAndSignatureFailed(
			oboPayload, accessTokenValidationResult);
	}

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

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

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

	// 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 = _oboConfiguration.ScopeForNewAccessToken,
		Audience = _oboConfiguration.AudienceForNewAccessToken,
		Issuer = _oboConfiguration.IssuerForNewAccessToken,
		OriginalClientId = _oboConfiguration.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,
			ValidateOboRequestPayload.GetPreferredUserName(claimsPrincipal));
	}

	return Ok(new OboSuccessResponse
	{
		ExpiresIn = 60 * 60,
		AccessToken = accessToken,
		Scope = oboPayload.scope
	});
}

The OboErrorResponse response can be created using the error result from the validation. The PII data is only logged if the application is allowed to log this. This is not set in productive deployments.

private IActionResult UnauthorizedValidationParametersFailed(
	OboPayload oboPayload, string Reason)
{
	var errorResult = new OboErrorResponse
	{
		error = "Validation request parameters failed",
		error_description = Reason,
		timestamp = DateTime.UtcNow,
		correlation_id = Guid.NewGuid().ToString(),
		trace_id = Guid.NewGuid().ToString(),
	};

	_logger.LogInformation("{error} {error_description} {correlation_id} {trace_id}",
		errorResult.error,
		errorResult.error_description,
		errorResult.correlation_id,
		errorResult.trace_id);

	if (IdentityModelEventSource.ShowPII)
	{
		_logger.LogDebug("OBO new access token returned for assertion {assertion}", oboPayload.assertion);
	}

	return Unauthorized(errorResult);
}

The GenerateJwtTokenAsync method creates a new OpenIddict user access token with the correct signature. We could also create a reference token or any other type of access token if required. The claims can be added as required and these claims are validated in the downstream API protected using OpenIddict.

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 = ValidateOboRequestPayload.GetPreferredUserName(payload.ClaimsPrincipal);
		var azp = ValidateOboRequestPayload.GetAzp(payload.ClaimsPrincipal);
		var azpacr = ValidateOboRequestPayload.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"
	};

	if (tokenDescriptor.AdditionalHeaderClaims == null)
	{
		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);
}

The following token will be created:

{
  "alg": "RS256",
  "kid": "5626CE6A8F4F5FCD79C6642345282CA76D337548",
  "x5t": "VibOao9PX815xmQjRSgsp20zdUg",
  "typ": "at+jwt"
}.{
  "sub": "8ec43e8d-1873-49ab-a4e2-744ed40586a2",
  "name": "-upn or email of user--",
  "scope": "--requested validated scope in OBO request--",
  "azp": "azp value from AAD token",
  "azpacr": "1", // AAD auth type,  1 == used client secret
  "act": {
    "sub": "--Guid from the AAD app registration client ID used for the API--"
  },
  "nbf": 1664533888,
  "exp": 1664537488,
  "iat": 1664533888,
  "iss": "https://localhost:44318/",
  "aud": "--aud from configuration--"
}.[Signature]

Token Exchange validation

The validate class checks both the POST request parameters as well as the signature and the claims of the Azure AD access token. It is important to validate this fully.

public static (bool Valid, string Reason) IsValid(OboPayload oboPayload, OboConfiguration oboConfiguration)
{
	if(!oboPayload.requested_token_use.ToLower().Equals("on_behalf_of"))
	{
		return (false, "obo requested_token_use parameter has an incorrect value, expected on_behalf_of");
	};

	if (!oboPayload.grant_type.ToLower().Equals("urn:ietf:params:oauth:grant-type:jwt-bearer"))
	{
		return (false, "obo grant_type parameter has an incorrect value, expected urn:ietf:params:oauth:grant-type:jwt-bearer");
	};

	if (!oboPayload.client_id.Equals(oboConfiguration.ClientId))
	{
		return (false, "obo client_id parameter has an incorrect value");
	};

	if (!oboPayload.client_secret.Equals(OboExtentions.ToSha256(oboConfiguration.ClientSecret)))
	{
		return (false, "obo client secret parameter has an incorrect value");
	};

	if (!oboPayload.scope.ToLower().Equals(oboConfiguration.ScopeForNewAccessToken.ToLower()))
	{
		return (false, "obo scope parameter has an incorrect value");
	};

	return (true, string.Empty);
}

public static (bool Valid, string Reason, ClaimsPrincipal? ClaimsPrincipal) 
	ValidateTokenAndSignature(
		string jwtToken, 
		OboConfiguration 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);
	}
}

public static string GetPreferredUserName(ClaimsPrincipal claimsPrincipal)
{
	string preferredUsername = string.Empty;
	var preferred_username = claimsPrincipal.Claims.FirstOrDefault(t => t.Type == "preferred_username");
	if (preferred_username != null)
	{
		preferredUsername = preferred_username.Value;
	}

	return preferredUsername;
}

public static string GetAzpacr(ClaimsPrincipal claimsPrincipal)
{
	string azpacr = string.Empty;
	var azpacrClaim = claimsPrincipal.Claims.FirstOrDefault(t => t.Type == "azpacr");
	if (azpacrClaim != null)
	{
		azpacr = azpacrClaim.Value;
	}

	return azpacr;
}

public static string GetAzp(ClaimsPrincipal claimsPrincipal)
{
	string azp = string.Empty;
	var azpClaim = claimsPrincipal.Claims.FirstOrDefault(t => t.Type == "azp");
	if (azpClaim != null)
	{
		azp = azpClaim.Value;
	}

	return azp;
}

What about OAuth token exchange (RFC 8693)

OAuth token exchange (RFC 8693) can also be used to implement this feature and it does not vary too much from this implementation. I will probably do a second demo using the OAuth specifications. The OBO flow is like a subset of the OAuth RFC with some features, claims implemented differently. Every identity provider seems to implement these things differently or with different levels of support, so IDPs which can be adapted are usually the best choice. Inter-opt between identity providers is not so easy and not well documented. The OBO flow is a Microsoft specific flow which is close to the OAuth token exchange RFC specification.

Improvements

I would love feedback on how to improve this or pull requests in the github repo.

Links

https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow

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

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

https://stackoverflow.com/questions/61536846/difference-between-jwt-bearer-and-token-exchange-grant-types

2 comments

  1. […] Implement the On Behalf Of flow between an Azure AD protected API and an API protected using OpenIdd… (Damien Bowden) […]

  2. […] Implement the On Behalf Of flow between an Azure AD protected API and an API protected using OpenIdd… – Damien Bowden […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

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

%d bloggers like this: