From 17edccab638972c31f5c48e2e1e1e5beabf51ec0 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Tue, 27 Oct 2026 16:58:21 +0000 Subject: [PATCH 1/2] feat: include client type in access tokens --- README.md | 2 + .../Oidc/OidcHelpers.cs | 11 +++ .../Oidc/OidcRequestHandler.cs | 3 +- .../Token/Token.feature | 2 + .../Token/Token.feature.cs | 84 ++++++++++--------- .../Token/TokenSteps.cs | 9 ++ .../Oidc/OidcEndpointTests.cs | 19 +++++ SecurityService/Endpoints/OidcEndpoints.cs | 2 +- docs/token-claims.md | 10 +++ 9 files changed, 101 insertions(+), 41 deletions(-) create mode 100644 docs/token-claims.md diff --git a/README.md b/README.md index 40d56644..4b36ce92 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs b/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs index 93f8a8df..6ab04efa 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs @@ -14,6 +14,10 @@ namespace SecurityService.BusinessLogic.Oidc; public static class OidcHelpers { + public const string ClientTypeClaim = "client_type"; + public const string UserClientType = "user"; + public const string ServiceClientType = "client"; + public static string BuildCurrentRequestUrl(HttpRequest request) { var path = request.PathBase + request.Path; @@ -49,6 +53,7 @@ public static async Task 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) @@ -76,6 +81,11 @@ public static async Task CreatePrincipal( foreach (var claim in await userManager.GetClaimsAsync(user)) { + if (string.Equals(claim.Type, ClientTypeClaim, StringComparison.Ordinal)) + { + continue; + } + AddClaimIfMissing(identity, claim.Type, claim.Value); } @@ -113,6 +123,7 @@ public static IEnumerable GetDestinations(Claim claim) [Destinations.AccessToken, Destinations.IdentityToken], Claims.Role => [Destinations.AccessToken, Destinations.IdentityToken], Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], + ClientTypeClaim => [Destinations.AccessToken], _ => [Destinations.AccessToken] }; } diff --git a/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs index 7d16c185..e7bf98d5 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs @@ -259,7 +259,8 @@ private async Task 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); diff --git a/SecurityService.IntegrationTests/Token/Token.feature b/SecurityService.IntegrationTests/Token/Token.feature index 7f4172a5..e623ed31 100644 --- a/SecurityService.IntegrationTests/Token/Token.feature +++ b/SecurityService.IntegrationTests/Token/Token.feature @@ -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 @@ -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' diff --git a/SecurityService.IntegrationTests/Token/Token.feature.cs b/SecurityService.IntegrationTests/Token/Token.feature.cs index 91b4271f..03a49284 100644 --- a/SecurityService.IntegrationTests/Token/Token.feature.cs +++ b/SecurityService.IntegrationTests/Token/Token.feature.cs @@ -121,83 +121,83 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa { #line 4 #line hidden - global::Reqnroll.Table table23 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table1 = new global::Reqnroll.Table(new string[] { "Role Name"}); - table23.AddRow(new string[] { + table1.AddRow(new string[] { "Estate"}); - table23.AddRow(new string[] { + table1.AddRow(new string[] { "Merchant"}); #line 6 - await testRunner.GivenAsync("I create the following roles", ((string)(null)), table23, "Given "); + await testRunner.GivenAsync("I create the following roles", ((string)(null)), table1, "Given "); #line hidden - global::Reqnroll.Table table24 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table2 = new global::Reqnroll.Table(new string[] { "Name", "DisplayName", "Description"}); - table24.AddRow(new string[] { + table2.AddRow(new string[] { "estateManagement", "estateManagement Scope", "A scope for estateManagement"}); - table24.AddRow(new string[] { + table2.AddRow(new string[] { "transactionProcessor", "transactionProcessor Scope", "A scope for transactionProcessor"}); - table24.AddRow(new string[] { + table2.AddRow(new string[] { "transactionProcessorAcl", "transactionProcessorAcl Scope", "A scope for transactionProcessorAcl"}); #line 11 - await testRunner.GivenAsync("I create the following api scopes", ((string)(null)), table24, "Given "); + await testRunner.GivenAsync("I create the following api scopes", ((string)(null)), table2, "Given "); #line hidden - global::Reqnroll.Table table25 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table3 = new global::Reqnroll.Table(new string[] { "Name", "DisplayName", "Secret", "Scopes", "UserClaims"}); - table25.AddRow(new string[] { + table3.AddRow(new string[] { "estateManagement", "Estate Managememt REST", "Secret1", "estateManagement", "MerchantId, EstateId, role"}); - table25.AddRow(new string[] { + table3.AddRow(new string[] { "transactionProcessor", "Transaction Processor REST", "Secret1", "transactionProcessor", ""}); - table25.AddRow(new string[] { + table3.AddRow(new string[] { "transactionProcessorAcl", "Transaction Processor ACL REST", "Secret1", "transactionProcessorAcl", "MerchantId, EstateId, role"}); #line 17 - await testRunner.GivenAsync("I create the following api resources", ((string)(null)), table25, "Given "); + await testRunner.GivenAsync("I create the following api resources", ((string)(null)), table3, "Given "); #line hidden - global::Reqnroll.Table table26 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table4 = new global::Reqnroll.Table(new string[] { "ClientId", "Name", "Secret", "Scopes", "GrantTypes"}); - table26.AddRow(new string[] { + table4.AddRow(new string[] { "serviceClient", "Service Client", "Secret1", "estateManagement,transactionProcessor,transactionProcessorAcl", "client_credentials"}); - table26.AddRow(new string[] { + table4.AddRow(new string[] { "merchantClient", "Merchant Client", "Secret1", "transactionProcessorAcl", "password"}); #line 23 - await testRunner.GivenAsync("I create the following clients", ((string)(null)), table26, "Given "); + await testRunner.GivenAsync("I create the following clients", ((string)(null)), table4, "Given "); #line hidden - global::Reqnroll.Table table27 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table5 = new global::Reqnroll.Table(new string[] { "User Name", "Email Address", "Password", @@ -207,7 +207,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "Family Name", "Claims", "Roles"}); - table27.AddRow(new string[] { + table5.AddRow(new string[] { "merchantuser", "", "123456", @@ -218,7 +218,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa "EstateId:1,MerchantId:2", "Merchant"}); #line 28 - await testRunner.GivenAsync("I create the following users", ((string)(null)), table27, "Given "); + await testRunner.GivenAsync("I create the following users", ((string)(null)), table5, "Given "); #line hidden } @@ -252,17 +252,20 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa #line 4 await this.FeatureBackgroundAsync(); #line hidden - global::Reqnroll.Table table28 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table6 = new global::Reqnroll.Table(new string[] { "ClientId", "ClientSecret"}); - table28.AddRow(new string[] { + table6.AddRow(new string[] { "serviceClient", "Secret1"}); #line 34 - await testRunner.WhenAsync("I request a client token with the following values", ((string)(null)), table28, "When "); + await testRunner.WhenAsync("I request a client token with the following values", ((string)(null)), table6, "When "); #line hidden #line 37 await testRunner.ThenAsync("my token is returned", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 38 + await testRunner.AndAsync("the access token has client type \'client\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); @@ -280,7 +283,7 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa global::Reqnroll.ScenarioInfo scenarioInfo = new global::Reqnroll.ScenarioInfo("Password token is rejected when MFA is required", null, tagsOfScenario, argumentsOfScenario, featureTags, pickleIndex); string[] tagsOfRule = ((string[])(null)); global::Reqnroll.RuleInfo ruleInfo = null; -#line 40 +#line 41 this.ScenarioInitialize(scenarioInfo, ruleInfo); #line hidden if ((global::Reqnroll.TagHelper.ContainsIgnoreTag(scenarioInfo.CombinedTags) || global::Reqnroll.TagHelper.ContainsIgnoreTag(featureTags))) @@ -293,49 +296,52 @@ public void ScenarioInitialize(global::Reqnroll.ScenarioInfo scenarioInfo, globa #line 4 await this.FeatureBackgroundAsync(); #line hidden -#line 41 +#line 42 await testRunner.WhenAsync("I require MFA for user \'merchantuser\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); #line hidden -#line 42 +#line 43 await testRunner.ThenAsync("the user MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden - global::Reqnroll.Table table29 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table7 = new global::Reqnroll.Table(new string[] { "ClientId", "ClientSecret", "Username", "Password"}); - table29.AddRow(new string[] { + table7.AddRow(new string[] { "merchantClient", "Secret1", "merchantuser", "123456"}); -#line 43 - await testRunner.WhenAsync("I request a password token with the following values", ((string)(null)), table29, "When "); +#line 44 + await testRunner.WhenAsync("I request a password token with the following values", ((string)(null)), table7, "When "); #line hidden -#line 46 +#line 47 await testRunner.ThenAsync("the password token request fails", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden -#line 47 +#line 48 await testRunner.WhenAsync("I remove MFA for user \'merchantuser\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "When "); #line hidden -#line 48 +#line 49 await testRunner.ThenAsync("the user MFA policy operation succeeds", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); #line hidden - global::Reqnroll.Table table30 = new global::Reqnroll.Table(new string[] { + global::Reqnroll.Table table8 = new global::Reqnroll.Table(new string[] { "ClientId", "ClientSecret", "Username", "Password"}); - table30.AddRow(new string[] { + table8.AddRow(new string[] { "merchantClient", "Secret1", "merchantuser", "123456"}); -#line 49 - await testRunner.WhenAsync("I request a password token with the following values", ((string)(null)), table30, "When "); +#line 50 + await testRunner.WhenAsync("I request a password token with the following values", ((string)(null)), table8, "When "); #line hidden -#line 52 +#line 53 await testRunner.ThenAsync("my token is returned", ((string)(null)), ((global::Reqnroll.Table)(null)), "Then "); +#line hidden +#line 54 + await testRunner.AndAsync("the access token has client type \'user\'", ((string)(null)), ((global::Reqnroll.Table)(null)), "And "); #line hidden } await this.ScenarioCleanupAsync(); diff --git a/SecurityService.IntegrationTests/Token/TokenSteps.cs b/SecurityService.IntegrationTests/Token/TokenSteps.cs index ccd3d162..75b744c2 100644 --- a/SecurityService.IntegrationTests/Token/TokenSteps.cs +++ b/SecurityService.IntegrationTests/Token/TokenSteps.cs @@ -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; @@ -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() { diff --git a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs index 974a8b77..a879bcb8 100644 --- a/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs +++ b/SecurityService.UnitTests/Oidc/OidcEndpointTests.cs @@ -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; diff --git a/SecurityService/Endpoints/OidcEndpoints.cs b/SecurityService/Endpoints/OidcEndpoints.cs index 8daa2c68..e7fa9ab4 100644 --- a/SecurityService/Endpoints/OidcEndpoints.cs +++ b/SecurityService/Endpoints/OidcEndpoints.cs @@ -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) diff --git a/docs/token-claims.md b/docs/token-claims.md new file mode 100644 index 00000000..ece322ce --- /dev/null +++ b/docs/token-claims.md @@ -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. From 1f13c16578a3f6f0efb031774e5e2a2fc28451bc Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Tue, 27 Oct 2026 17:08:11 +0000 Subject: [PATCH 2/2] style: use properties for token claim values --- SecurityService.BusinessLogic/Oidc/OidcHelpers.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs b/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs index 6ab04efa..c589f6c5 100644 --- a/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs +++ b/SecurityService.BusinessLogic/Oidc/OidcHelpers.cs @@ -14,9 +14,9 @@ namespace SecurityService.BusinessLogic.Oidc; public static class OidcHelpers { - public const string ClientTypeClaim = "client_type"; - public const string UserClientType = "user"; - public const string ServiceClientType = "client"; + 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) { @@ -117,13 +117,17 @@ private static void AddClaimIfMissing(ClaimsIdentity identity, string type, stri public static IEnumerable 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" => [Destinations.AccessToken, Destinations.IdentityToken], Claims.Role => [Destinations.AccessToken, Destinations.IdentityToken], Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], - ClientTypeClaim => [Destinations.AccessToken], _ => [Destinations.AccessToken] }; }