Skip to content

Commit

Permalink
Added the Client Credential Flow (#14)
Browse files Browse the repository at this point in the history
Added the Client Credentials Flow:
* Implementation in the server
* Support of the Client Credentials Flow in the sample client application
* Updated the READMEs
  • Loading branch information
AKlaus authored Oct 11, 2024
1 parent 308f94d commit e4cd18a
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 41 deletions.
4 changes: 2 additions & 2 deletions AzureADAuthClient/Configuration/AddAndConfigureSwagger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public static IServiceCollection AddAndConfigureSwagger(this IServiceCollection
AuthorizationUrl = GetAzureAdEndpoint(settings,"authorize")+"?nonce=SWAGGER", // pass 'nonce', as Swagger UI still doesn't fully support OIDC (see https://github.com/swagger-api/swagger-ui/issues/3517)
// It also doesn't support `response_mode=form_post` but it's not critical for local debugging
Type = OpenApiSecuritySchemeType.OAuth2,
Description = "Azure AD auth by `id_token` only",
Description = "Azure Entra ID auth by `id_token` only",
Flow = OpenApiOAuth2Flow.Implicit,
ExtensionData = new Dictionary<string, object?>
{
Expand All @@ -28,7 +28,7 @@ public static IServiceCollection AddAndConfigureSwagger(this IServiceCollection
},
Scopes = new Dictionary<string, string>
{
[settings.AzureAd.Scope] = "Access This API", // [Optional] To control access to the app by Azure AD users
[settings.AzureAd.Scope] = "Access This API", // [Optional] To control access to the app by Azure Entra ID users
["profile"] = "Profile", // [Optional] Returns claims that represent basic profile information, including name, family_name, given_name, etc.
["openid"] = "Mandatory 'OpenId'" // Required by OIDC
}
Expand Down
2 changes: 1 addition & 1 deletion AzureADAuthClient/Configuration/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public class AppSettings
public string AppName { get; private set; } = null!;

/// <summary>
/// Settings for the App Registration and Azure AD
/// Settings for the App Registration and Azure Entra ID
/// </summary>
public AzureCredentialsSettings AzureAd { get; private set; } = null!;

Expand Down
18 changes: 14 additions & 4 deletions OpenIdDict.Client.Api/Configuration/AddAndConfigureSwagger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,22 @@ public static IServiceCollection AddAndConfigureSwagger(this IServiceCollection
"Bearer",
new OpenApiSecurityScheme
{
AuthorizationUrl = GetAuthEndpoint(settings, "authorize"),
TokenUrl = GetAuthEndpoint(settings, "token"),
Type = OpenApiSecuritySchemeType.OAuth2,
Description = "Identity Server auth",
Flow = OpenApiOAuth2Flow.AccessCode,
Scopes = new Dictionary<string, string> { [settings.OAuth.Scope] = "Access This API" }
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = GetAuthEndpoint(settings, "token")
},
AuthorizationCode = new OpenApiOAuthFlow
{
TokenUrl = GetAuthEndpoint(settings, "token"),
AuthorizationUrl = GetAuthEndpoint(settings, "authorize"),
RefreshUrl = GetAuthEndpoint(settings, "token"),
Scopes = new Dictionary<string, string> { [settings.OAuth.Scope] = "Access This API" }
}
}
});
s.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor(/* 'Bearer' is the default scheme */));
});
Expand Down
103 changes: 87 additions & 16 deletions OpenIdDict.Server/Authorisation/OpenIdDictEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,19 @@ internal static class OpenIdDictEvents
};

/// <summary>
/// Handling redirects to `/authorize` route
/// Handling redirects to `/authorize` route for the Authorization Code Flow
/// </summary>
internal static Func<OpenIddictServerEvents.HandleAuthorizationRequestContext, ValueTask> HandleAuthorizationRequest(AppSettings.AuthCredentialsSettings authSettings) =>
async context =>
{
var request = context.Transaction.GetHttpRequest() ??
throw new InvalidOperationException("The ASP.NET request cannot be retrieved.");
throw new InvalidOperationException("The HTTP request cannot be retrieved.");

// Confirm the Authorization Code Flow
var openIdDictRequest = request.HttpContext.GetOpenIddictServerRequest();
if (openIdDictRequest?.IsAuthorizationCodeFlow() != true)
return;

// Retrieve the user principal stored in the user profile cookie.
var authResult = await request.HttpContext.AuthenticateAsync(OpenIdConnectDefaults.AuthenticationScheme);
// If the principal cannot be retrieved, this indicates that the user is not logged in.
Expand All @@ -82,10 +87,9 @@ internal static class OpenIdDictEvents
return;
}

var email = ResolveAzureAdEmailClaim(authResult.Principal);
var name = ResolveAzureAdUserNameClaim(authResult.Principal);

// Now, identity of the user confirmed by the linked OIDC Provider.
string email = ResolveAzureAdEmailClaim(authResult.Principal);
string name = ResolveAzureAdUserNameClaim(authResult.Principal);

/*
* Here's a place for app-specific authorisation and resolving app-related user's claims.
Expand All @@ -97,22 +101,79 @@ internal static class OpenIdDictEvents
*/

// Form new claims
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType /* sets it to 'Federated Authentication' */);
identity.SetClaim(OpenIddictConstants.Claims.Subject /* unique identifier of the user */, email /* or any unique mandatory identifier, see RFC-7519, 4.1.2 */);
identity.SetClaim(OpenIddictConstants.Claims.Email, email);
identity.SetClaim(OpenIddictConstants.Claims.Name, name);
var claimsIdentity = CreateClaimsIdentity(email, name);

// Attach the principal to the authorization context, so that an OpenID Connect response
// with an authorization code can be generated by the OpenIddict server services.
context.Principal = new ClaimsPrincipal(identity)
// Re-attach supported scopes, so downstream handlers don't reject 'unsupported' scopes or not issue a 'refresh_token' on the grounds of absent 'offline_access' scope
.SetScopes(authSettings.ScopesFullSet.Keys);
context.Principal = new ClaimsPrincipal(claimsIdentity)
// Re-attach the requested scopes adding the mandatory ones, so downstream handlers don't reject 'unsupported' scopes or not issue a 'refresh_token' if 'offline_access' scope is absent
.SetScopes(openIdDictRequest.GetScopes().Union(authSettings.ScopesFullSet.Keys));
};

/// <summary>
/// Handling requests to `/token` route for the Client Credentials Flow only
/// </summary>
internal static Func<OpenIddictServerEvents.HandleTokenRequestContext, ValueTask> HandleClientCredentialsTokenRequest(AppSettings.AuthCredentialsSettings authSettings) =>
context =>
{
var httpRequest = context.Transaction.GetHttpRequest() ??
throw new InvalidOperationException("The HTTP request cannot be retrieved.");

// For non-Client Credentials flows we use the default processing (for Auth Code flow it lands in `HandleAuthorizationRequest`)
// Note, the `/token` point can be called for the the Authorization Code Flow too.
var openIdDictRequest = httpRequest.HttpContext.GetOpenIddictServerRequest();
if (openIdDictRequest?.IsClientCredentialsGrantType() != true)
return default;

// Resolve the user by the client_id and client_secret.
(string email, string name) = ResolveUserByClientCredentials(openIdDictRequest.ClientId, openIdDictRequest.ClientSecret, authSettings);
if ((email, name) == default)
{
/*
* If the app's authorisation fails then you can return a 403 page by
* request.HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
* await request.HttpContext.Response.WriteAsync('Not registered');
* context.HandleRequest();
* return default;
*/
}

// Set the destination for the added claims to 'Access Token', as they're authorisation-related attributes.
// Another plus, the API controllers can retrieve them from the ClaimsPrincipal instance.
identity.SetDestinations(_ => new[] { OpenIddictConstants.Destinations.AccessToken });
var claimsIdentity = CreateClaimsIdentity(email, name, TimeSpan.FromHours(6));

// Attach the principal to the authorization context, so that an OpenID Connect response
// with an authorization code can be generated by the OpenIddict server services.
context.Principal = new ClaimsPrincipal(claimsIdentity)
.SetScopes(openIdDictRequest.GetScopes());

return default;
};

/// <summary>
/// Form the claims, the identity type and the expiration period for the security principal
/// </summary>
private static ClaimsIdentity CreateClaimsIdentity(string email, string name, TimeSpan? accessTokenLifetime = null)
{
// Form new claims
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType /* sets it to 'Federated Authentication' */);
identity.SetClaim(OpenIddictConstants.Claims.Subject /* unique identifier of the user */, email /* or any unique mandatory identifier, see RFC-7519, 4.1.2 */);
identity.SetClaim(OpenIddictConstants.Claims.Email, email);
identity.SetClaim(OpenIddictConstants.Claims.Name, name);

/*
* Here's a place for app-specific authorisation and resolving app-related user's claims.
*/

// Set the destination for the added claims to 'Access Token', as they're authorisation-related attributes.
// Another plus, the API controllers can retrieve them from the ClaimsPrincipal instance.
identity.SetDestinations(_ => new[] { OpenIddictConstants.Destinations.AccessToken });

// The default token's lifetime is 1 hour, but you can override here
if (accessTokenLifetime.HasValue)
identity.SetAccessTokenLifetime(accessTokenLifetime);

return identity;
}

/// <summary>
/// Resolving mandatory email from a relevant mapped claim
/// </summary>
Expand All @@ -121,5 +182,15 @@ internal static class OpenIdDictEvents
/// <summary>
/// Resolve an optional name from the claims
/// </summary>
private static string ResolveAzureAdUserNameClaim(ClaimsPrincipal claims) => claims.GetClaim(OpenIddictConstants.Claims.Name) ?? string.Empty;
private static string ResolveAzureAdUserNameClaim(ClaimsPrincipal claims) => claims.GetClaim(OpenIddictConstants.Claims.Name) ?? string.Empty;

/// <summary>
/// A dummy check for correct credentials
/// </summary>
private static (string email, string name) ResolveUserByClientCredentials(string? clientId, string? clientSecret, AppSettings.AuthCredentialsSettings authSettings)
{
if (clientId == authSettings.ClientId)
return ("test@email", "Test Name");
return default;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ internal static partial class ServiceCollectionExtensions
/// <remarks>
/// OAuth 2.0 uses two endpoints: `/authorize` and `/oauth/token` (https://auth0.com/docs/authenticate/protocols/oauth).
/// Their meaning for the Authorization Code Flow:
/// `/authorize` – issues an authorization code (redirects to Azure AD)
/// `/authorize` – issues an authorization code (redirects to Azure Entra ID)
/// `/token` – exchanges the authorization code for an access token (how does it get used for refreshing the token?)
/// </remarks>
internal static IServiceCollection AddAndConfigureAuthorisation(this IServiceCollection services, AppSettings settings)
Expand All @@ -45,10 +45,13 @@ internal static IServiceCollection AddAndConfigureAuthorisation(this IServiceCol
.AddEventHandler<OpenIddictServerEvents.ValidateAuthorizationRequestContext>(builder => builder.UseInlineHandler(OpenIdDictEvents.ValidateAuthorizationRequestFunc(settings.Auth)))
.AddEventHandler<OpenIddictServerEvents.ValidateTokenRequestContext>(builder => builder.UseInlineHandler(OpenIdDictEvents.ValidateTokenRequestFunc(settings.Auth)))
.AddEventHandler<OpenIddictServerEvents.HandleAuthorizationRequestContext>(builder => builder.UseInlineHandler(OpenIdDictEvents.HandleAuthorizationRequest(settings.Auth)))
.AddEventHandler<OpenIddictServerEvents.HandleTokenRequestContext>(builder => builder.UseInlineHandler(OpenIdDictEvents.HandleClientCredentialsTokenRequest(settings.Auth)))
// Enable the Authorization Code Flow with PKCE and Refresh Token Flow
.AllowAuthorizationCodeFlow()
.RequireProofKeyForCodeExchange()
.AllowRefreshTokenFlow()
// Enable the Client Credential Flow
.AllowClientCredentialsFlow()
// Enable caching/resolving the auth code in/from memory cache
.AddEventHandler(CodeReferenceTokenStorageHandler.Descriptor)
.AddEventHandler(ValidateCodeReferenceTokenHandler.Descriptor)
Expand Down Expand Up @@ -85,7 +88,7 @@ internal static IServiceCollection AddAndConfigureAuthorisation(this IServiceCol

services.AddAuthorization()
.AddAuthentication(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)
// Configuration for the linked Azure AD tenant
// Configuration for the linked Azure Entra ID tenant
.AddMicrosoftIdentityWebApp(options =>
{
options.Instance = settings.AzureAd.Instance;
Expand All @@ -100,7 +103,7 @@ internal static IServiceCollection AddAndConfigureAuthorisation(this IServiceCol
// Without this handler an exception will be thrown on sending a simple `curl --request POST 'https://LOCALHOST/signin-oidc'`
// NOTE: Add logging of the exception to the log sink
await context.Request.HttpContext.ForbidAsync();
await context.Response.WriteAsync("Incorrect response from Azure AD");
await context.Response.WriteAsync("Incorrect response from Azure Entra ID");
context.HandleResponse();
}
};
Expand Down
18 changes: 14 additions & 4 deletions OpenIdDict.Server/Configuration/AddAndConfigureSwagger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,22 @@ public static IServiceCollection AddAndConfigureSwagger(this IServiceCollection
Microsoft.Identity.Web.Constants.Bearer,
new OpenApiSecurityScheme
{
AuthorizationUrl = GetAuthEndpoint("authorize"),
TokenUrl = GetAuthEndpoint("token"),
Type = OpenApiSecuritySchemeType.OAuth2,
Description = "Identity Server auth",
Flow = OpenApiOAuth2Flow.AccessCode,
Scopes = settings.Auth.ScopesFullSet
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = GetAuthEndpoint("token")
},
AuthorizationCode = new OpenApiOAuthFlow
{
TokenUrl = GetAuthEndpoint("token"),
AuthorizationUrl = GetAuthEndpoint("authorize"),
RefreshUrl = GetAuthEndpoint("token"),
Scopes = settings.Auth.ScopesFullSet
}
}
});
s.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor(Microsoft.Identity.Web.Constants.Bearer));
});
Expand Down
2 changes: 1 addition & 1 deletion OpenIdDict.Server/Configuration/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class AppSettings
public string AppName { get; private set; } = null!;

/// <summary>
/// Settings for the App Registration and Azure AD
/// Settings for the App Registration and Azure Entra ID
/// </summary>
public AzureCredentialsSettings AzureAd { get; private set; } = null!;

Expand Down
28 changes: 22 additions & 6 deletions OpenIdDict.Server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@

A custom build Identity Server that implements OAuth 2 [Authorization Code Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow) with [PKCE](https://oauth.net/2/pkce/) to serve other client apps as a trusted authority and perform authentication from a linked _Identity Provider_ (a specified tenant of Azure AD).

## 2. Run it
As a bonus for the API integration with third-parties, it also implements the [Client Credentials Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow).

## 2. Run the sample app

**Prerequisite**: an _Azure AD_ tenant supporting authentication.

1. Configure `appsettings.json` (by setting parameters directly in the file or view [user secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets)):
1. To support _Azure Entra ID_ authentication, configure the `appsettings.json` (by setting parameters directly in the file or via the [user secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets)):
- `AzureAd:Tenant` – the name of your tenant (e.g. _contoso.onmicrosoft.com_) or its tenant ID (a GUID). Sometime it's referred as the _issuer_<br>The parameter is used in forming a set of HTTP endpoints for the _Identity Provider_ (Azure AD in our case). E.g. `https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/authorize`.
- `AzureAd:ClientId` – the _Application (client) ID_ (a GUID) of the _App Registration_.
- `AzureAd:Scope` – The requested scope (also called [delegated permission](https://learn.microsoft.com/en-au/azure/active-directory/develop/permissions-consent-overview)), for the client apps to obtain an access token. Note that all APIs must publish a minimum of one scope and this app is using just one for simplicity.
2. Launch it.
2. Launch the Server.
3. Try the two available end-points. `/anonymous` must return a HTTP 200 code, while `/protected` gives a 401.
4. Pass the Swagger authentication.<br> It will pop up a new tab with a bunch of redirects that brings the user to the Azure AD login page and will close it on successful authentication.
4. Pass the Swagger authentication. Either of the 2 provided options:
1. The _Authorization Code Flow_ (for users) to authenticate via the linked _Azure Entra ID_.<br>It will pop up a new tab with a bunch of redirects that brings the user to the _Azure Entra ID_ login page and will close it on successful authentication.
2. The _Client Credentials Flow_ (for API integration) to authenticate via the `client_id` and `client_secret` only.
5. Try `/protected` end-point and it must return a HTTP 200 code.

## 3. Implementation
Expand All @@ -23,13 +27,25 @@ The API has just two end-points:
- `/anonymous` that always returns HTTP code 200 on any request.
- `/protected` that requires user to authenticate and provide a self-issued Bearer token. Otherwise, it returns HTTP code 401 Unauthorized.

Handling OAuth 2 [Authorization Code Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow) with [PKCE](https://oauth.net/2/pkce/) is implemented with using [OpenIdDict](https://github.com/openiddict/openiddict-core) NuGet package with the key implementation is in `HandleAuthorizationRequestContext` handler of its 'degraded mode' (see [this article](https://kevinchalet.com/2020/02/18/creating-an-openid-connect-server-proxy-with-openiddict-3-0-s-degraded-mode/) from the author of the library on detailed implementation).
Handling the _OAuth 2_ is implemented with using [OpenIdDict](https://github.com/openiddict/openiddict-core) NuGet package with the key implementation is in `HandleAuthorizationRequestContext` handler of its 'degraded mode' (see [this article](https://kevinchalet.com/2020/02/18/creating-an-openid-connect-server-proxy-with-openiddict-3-0-s-degraded-mode/) from the author of the library on detailed implementation).

The authentication client is implemented by [NSwag](https://github.com/RicoSuter/NSwag).

The authentication client is implemented by [NSwag](https://github.com/RicoSuter/NSwag):
From the consumer's perspective, the _Authorization Code Flow_ looks like:
1. The user gets redirected to `/authorize` route.<br>
E.g. `/connect/authorize?response_type=code&client_id=TestApp&redirect_uri=https%3A%2F%2Flocalhost%3A5003%2Fswagger%2Foauth2-redirect.html&scope=openid&state={STATE}&realm=realm&code_challenge={CODE_CHALLENGE}&code_challenge_method=S256`
2. If a relevant user identity cookie not found,
1. the user gets redirected further to the login page of the linked _Identity Provider_ (for Azure AD it's `https://login.microsoftonline.com/{TENANT}/oauth2/v2.0/authorize`).
2. on successful authentication withing the tenant, the user gets redirected back to the Auth Gateway to continue the authentication/authorisation process.
3. On successful authentication/authorisation, the user gets redirected back to Swagger<br> `/swagger/oauth2-redirect.html?code={CODE}&session_state={RANDOM_STATE}`,<br>where `CODE` is a reference token to the auth code stored in memory cache on the server.
4. Then _NSwag_'s JavaScript exchanges the received _code_ to an _access token_ by running a `POST` request to `/token`.

The _Client Credentials Flow_ is simpler and involves only a single call to the `/token` route. E.g.:
```bash
curl --location '/connect/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'grant_type=client_credentials' \
--data 'scope=profile' \
--data 'client_id=TestApp' \
--data 'client_secret=test'
```
Loading

0 comments on commit e4cd18a

Please sign in to comment.