Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# SecurityService

![Alt](https://repobeats.axiom.co/api/embed/da37d4d35818e149f5221a018d8f10d89e8ae4a4.svg "Repobeats analytics image")

See the [access token claims contract](docs/token-claims.md) for documented claim names and values.
15 changes: 15 additions & 0 deletions SecurityService.BusinessLogic/Oidc/OidcHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ namespace SecurityService.BusinessLogic.Oidc;

public static class OidcHelpers
{
public static string ClientTypeClaim { get; } = "client_type";
public static string UserClientType { get; } = "user";
public static string ServiceClientType { get; } = "client";

public static string BuildCurrentRequestUrl(HttpRequest request)
{
var path = request.PathBase + request.Path;
Expand Down Expand Up @@ -49,6 +53,7 @@ public static async Task<ClaimsPrincipal> CreatePrincipal(
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);

identity.SetClaim(Claims.Subject, user.Id)
.SetClaim(ClientTypeClaim, UserClientType)
.SetClaim(Claims.Email, user.Email)
.SetClaim(Claims.Name, user.UserName)
.SetClaim(Claims.PreferredUsername, user.UserName)
Expand Down Expand Up @@ -76,6 +81,11 @@ public static async Task<ClaimsPrincipal> CreatePrincipal(

foreach (var claim in await userManager.GetClaimsAsync(user))
{
if (string.Equals(claim.Type, ClientTypeClaim, StringComparison.Ordinal))
{
continue;
}

AddClaimIfMissing(identity, claim.Type, claim.Value);
}

Expand Down Expand Up @@ -107,6 +117,11 @@ private static void AddClaimIfMissing(ClaimsIdentity identity, string type, stri

public static IEnumerable<string> GetDestinations(Claim claim)
{
if (string.Equals(claim.Type, ClientTypeClaim, StringComparison.Ordinal))
{
return [Destinations.AccessToken];
}

return claim.Type switch
{
Claims.Name or Claims.PreferredUsername or Claims.Email or Claims.GivenName or Claims.FamilyName or "middle_name" =>
Expand Down
3 changes: 2 additions & 1 deletion SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@ private async Task<TokenCommandResult> HandleClientCredentials(OpenIddictRequest
var grantedScopes = await OidcHelpers.ResolveClientCredentialsScopes(request, this._dbContext, cancellationToken);
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, request.ClientId!)
.SetClaim(Claims.Name, request.ClientId!);
.SetClaim(Claims.Name, request.ClientId!)
.SetClaim(OidcHelpers.ClientTypeClaim, OidcHelpers.ServiceClientType);
identity.SetScopes(grantedScopes);
identity.SetResources(await this._scopeManager.ListResourcesAsync(ImmutableArray.CreateRange(grantedScopes), cancellationToken).ToListAsync(cancellationToken));
identity.SetDestinations(OidcHelpers.GetDestinations);
Expand Down
2 changes: 2 additions & 0 deletions SecurityService.IntegrationTests/Token/Token.feature
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Scenario: Get Tokens
| ClientId | ClientSecret |
| serviceClient | Secret1 |
Then my token is returned
And the access token has client type 'client'

@mfa
Scenario: Password token is rejected when MFA is required
Expand All @@ -50,5 +51,6 @@ Scenario: Password token is rejected when MFA is required
| ClientId | ClientSecret | Username | Password |
| merchantClient | Secret1 | merchantuser | 123456 |
Then my token is returned
And the access token has client type 'user'


84 changes: 45 additions & 39 deletions SecurityService.IntegrationTests/Token/Token.feature.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions SecurityService.IntegrationTests/Token/TokenSteps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace SecurityService.IntegrationTests.Token
{
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -62,6 +63,14 @@ public void ThenMyTokenIsReturned()
this.TestingContext.AccessToken.ShouldNotBeNullOrEmpty();
}

[Then(@"the access token has client type '([^']*)'")]
public void ThenTheAccessTokenHasClientType(String expectedClientType)
{
this.TestingContext.AccessToken.ShouldNotBeNullOrEmpty();
JwtSecurityToken token = new JwtSecurityTokenHandler().ReadJwtToken(this.TestingContext.AccessToken);
token.Claims.ShouldContain(claim => claim.Type == "client_type" && claim.Value == expectedClientType);
}

[Then(@"the password token request fails")]
public void ThenThePasswordTokenRequestFails()
{
Expand Down
19 changes: 19 additions & 0 deletions SecurityService.UnitTests/Oidc/OidcEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,25 @@ public async Task CreatePrincipal_WhenUserClaimsDuplicateBuiltInClaims_DoesNotDu
principal.Claims.Count(claim => claim.Type == OpenIddictConstants.Claims.Role && claim.Value == "Merchant").ShouldBe(1);
}

[Fact]
public async Task CreatePrincipal_AddsUserClientTypeToAccessTokenOnly()
{
var userManager = IdentityMocks.CreateUserManager();
var user = new ApplicationUser { Id = "user-1", UserName = "alice" };
userManager.GetRolesAsync(user).ReturnsAsync([]);
userManager.GetClaimsAsync(user).ReturnsAsync([new Claim(OidcHelpers.ClientTypeClaim, "client")]);

var principal = await OidcHelpers.CreatePrincipal(
user,
userManager.Instance(),
[],
[],
authorizationId: null);

principal.FindAll(OidcHelpers.ClientTypeClaim).Select(claim => claim.Value).ShouldBe([OidcHelpers.UserClientType]);
OidcHelpers.GetDestinations(principal.FindFirst(OidcHelpers.ClientTypeClaim)!).ShouldBe([OpenIddictConstants.Destinations.AccessToken]);
}

private sealed class FakeAuthenticationService : IAuthenticationService
{
private readonly AuthenticateResult _authenticateResult;
Expand Down
2 changes: 1 addition & 1 deletion SecurityService/Endpoints/OidcEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static void MapOidcEndpoints(this IEndpointRouteBuilder endpoints)
.WithName("Token")
.WithTags(OpenIdConnectTag)
.WithSummary("Exchange an OAuth grant for tokens")
.WithDescription("OAuth 2.0 token endpoint. Submit one grant as `application/x-www-form-urlencoded`; authenticate the client as required by its registration. Successful responses follow OAuth token response conventions (`access_token`, `token_type`, `expires_in`, and, when issued, `refresh_token`). Invalid grants return an OAuth error response such as `invalid_grant`.")
.WithDescription("OAuth 2.0 token endpoint. Submit one grant as `application/x-www-form-urlencoded`; authenticate the client as required by its registration. Successful responses follow OAuth token response conventions (`access_token`, `token_type`, `expires_in`, and, when issued, `refresh_token`). Access tokens include a `client_type` claim with value `user` for user tokens or `client` for client-credentials tokens; see the token claims contract. Invalid grants return an OAuth error response such as `invalid_grant`.")
.Produces(200, contentType: "application/json").Produces(400, contentType: "application/json").Produces(401)
.WithResponseExample("""{"access_token":"eyJhbGciOi...","token_type":"Bearer","expires_in":3600,"refresh_token":"def50200..."}""");
endpoints.MapMethods("/connect/logout", [HttpMethods.Get, HttpMethods.Post], Handlers.LogoutHandler.LogoutAsync)
Expand Down
10 changes: 10 additions & 0 deletions docs/token-claims.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Access token claims

Access tokens issued by the SecurityService token endpoint include the `client_type` claim. Resource services can use it to distinguish a token representing a user from one representing a service client.

| Claim | Value | Meaning |
| --- | --- | --- |
| `client_type` | `user` | A token issued for a user, including authorization-code, refresh-token, and enabled password-grant flows. |
| `client_type` | `client` | A token issued through the client-credentials grant for a service client. |

The claim is included in access tokens. Its values are lowercase and form part of the token contract; consumers should handle unknown future values safely.
Loading