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
12 changes: 12 additions & 0 deletions .superpowers/sdd/2026-10-06-oauth-grant-restrictions/progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# SDD ledger — plan: docs/superpowers/plans/2026-10-06-oauth-grant-restrictions.md

Pre-flight: Task 1 produces `OAuthGrantPolicy` and `OAuthOptions`, consumed by Tasks 2–4. Task 2 produces client descriptor permissions consumed by Task 4 synchronization. Task 3 produces server capability configuration consumed by Tasks 2 and 4. No other shared-interface conflicts found.

Ruling: Work in the current checkout — the user selected native execution, the repository is already the requested project, and creating a separate worktree is unavailable in this managed workspace; cost if wrong: changes are made on the current branch and may need manual branch isolation later.

Task 1: complete. Added OAuth grant policy configuration/helper and four focused tests. Verification: `dotnet build SecurityService.UnitTests/SecurityService.UnitTests.csproj --no-restore` passed; the Microsoft Testing Platform executable ran the four focused tests with 4 passed, 0 failed. Ruling: `OAuthOptions` was introduced alongside the policy helper because the policy tests consume the configuration contract; cost if wrong: a small early configuration surface that can be adjusted in the follow-up wiring task.
Task 2: complete. Client registration now validates configured grants through `OAuthGrantPolicy` and persists the derived OpenIddict permissions/requirements, including PKCE for authorization-code clients. Verification: unit-test project build passed; `ClientRequestHandlerTests` passed (6/6). Commit attempt deferred because Git index locking is currently denied in this checkout.
Task 3: complete. Server defaults now enable only authorization code, client credentials, and refresh tokens; legacy capabilities are enabled only for non-empty configured client lists; device endpoints and grant-permission bypass were removed. Shipped configuration examples and deployment documentation now show the empty legacy map.
Task 4: complete. Startup reconciliation updates existing OpenIddict applications from persisted grant lists and fails on unconfigured legacy grants; management bootstrap remains client-credentials-only and is synchronized as well. Verification: management bootstrap tests passed (3/3), application builds passed.
Task 5/6: complete for this scope. Removed device-code handler routing, preserved explicit legacy integration fixtures via test-only environment configuration, and added OAuth rollout documentation. Focused policy tests passed (4/4); client registration tests passed (6/6); integration projects build successfully.
Final verification: full unit suite built and ran 120 tests; 118 passed and 2 certificate-loader tests failed because the test PFX files were unavailable in the environment. `git diff --check` passed. Git commit remains unavailable because this checkout denies `.git/index.lock` creation.
103 changes: 103 additions & 0 deletions SecurityService.BusinessLogic/Oidc/OAuthGrantPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Collections.Immutable;
using OpenIddict.Abstractions;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace SecurityService.BusinessLogic.Oidc;

public sealed record OAuthGrantPolicyResult(
ImmutableHashSet<string> Permissions,
ImmutableHashSet<string> Requirements);

public static class OAuthGrantPolicy
{
private static readonly HashSet<string> ApprovedGrantTypes = new(StringComparer.OrdinalIgnoreCase)
{
GrantTypes.AuthorizationCode,
GrantTypes.ClientCredentials,
GrantTypes.RefreshToken
};

private static readonly HashSet<string> LegacyGrantTypes = new(StringComparer.OrdinalIgnoreCase)
{
GrantTypes.Password,
GrantTypes.Implicit,
"hybrid"
};

public static bool IsGrantAllowed(string grantType, string clientId, OAuthOptions options)
{
if (ApprovedGrantTypes.Contains(grantType))
{
return true;
}

if (LegacyGrantTypes.Contains(grantType) == false || options.LegacyGrantTypeClients.TryGetValue(grantType, out List<string>? clients) == false)
{
return false;
}

return clients.Any(client => string.Equals(client, clientId, StringComparison.OrdinalIgnoreCase));
}

public static OAuthGrantPolicyResult CreatePermissions(
IReadOnlyCollection<string> allowedGrantTypes,
string clientId,
OAuthOptions options)
{
ImmutableHashSet<string>.Builder permissions = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);
ImmutableHashSet<string>.Builder requirements = ImmutableHashSet.CreateBuilder<string>(StringComparer.Ordinal);

foreach (string grantType in allowedGrantTypes.Distinct(StringComparer.OrdinalIgnoreCase))
Comment thread
StuartFerguson marked this conversation as resolved.
Resolved
{
if (IsGrantAllowed(grantType, clientId, options) == false)
{
throw new InvalidOperationException($"Grant type '{grantType}' is not allowed for client '{clientId}'.");
}

switch (grantType.ToLowerInvariant())
{
case GrantTypes.AuthorizationCode:
permissions.Add(Permissions.Endpoints.Authorization);
permissions.Add(Permissions.Endpoints.Token);
permissions.Add(Permissions.GrantTypes.AuthorizationCode);
permissions.Add(Permissions.ResponseTypes.Code);
requirements.Add(Requirements.Features.ProofKeyForCodeExchange);
break;

case GrantTypes.ClientCredentials:
permissions.Add(Permissions.Endpoints.Token);
permissions.Add(Permissions.GrantTypes.ClientCredentials);
break;

case GrantTypes.RefreshToken:
permissions.Add(Permissions.Endpoints.Token);
permissions.Add(Permissions.GrantTypes.RefreshToken);
break;

case GrantTypes.Password:
permissions.Add(Permissions.Endpoints.Token);
permissions.Add(Permissions.GrantTypes.Password);
break;

case GrantTypes.Implicit:
permissions.Add(Permissions.Endpoints.Authorization);
permissions.Add(Permissions.GrantTypes.Implicit);
permissions.Add(Permissions.ResponseTypes.Token);
permissions.Add(Permissions.ResponseTypes.IdToken);
break;

case "hybrid":
permissions.Add(Permissions.Endpoints.Authorization);
permissions.Add(Permissions.Endpoints.Token);
permissions.Add(Permissions.GrantTypes.AuthorizationCode);
permissions.Add(Permissions.ResponseTypes.CodeIdToken);
break;

default:
throw new InvalidOperationException($"Unsupported grant type '{grantType}'.");
}
}

return new OAuthGrantPolicyResult(permissions.ToImmutable(), requirements.ToImmutable());
}
}
2 changes: 1 addition & 1 deletion SecurityService.BusinessLogic/Oidc/OidcRequestHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ public async Task<Result<TokenCommandResult>> Handle(OidcCommands.TokenCommand c
var context = command.HttpContext;
var request = context.GetOpenIddictServerRequest() ?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType() || request.IsDeviceCodeGrantType())
if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
{
return Result.Success<TokenCommandResult>(await this.HandleCodeOrRefreshToken(context, cancellationToken));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
using System.Text;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using SecurityService.BusinessLogic.Oidc;
using SecurityService.BusinessLogic.Requests;
using SecurityService.Database;
using SecurityService.Database.DbContexts;
Expand All @@ -18,24 +20,18 @@ public sealed class ClientRequestHandler :
IRequestHandler<SecurityServiceQueries.GetClientQuery, Result<ClientDetails>>,
IRequestHandler<SecurityServiceQueries.GetClientsQuery, Result<List<ClientDetails>>>
{
private static readonly HashSet<string> SupportedGrantTypes = new(StringComparer.OrdinalIgnoreCase)
{
GrantTypes.AuthorizationCode,
GrantTypes.ClientCredentials,
GrantTypes.DeviceCode,
"hybrid",
GrantTypes.Implicit,
GrantTypes.Password,
GrantTypes.RefreshToken
};

private readonly SecurityServiceDbContext DbContext;
private readonly IOpenIddictApplicationManager ApplicationManager;
private readonly OAuthOptions OAuthOptions;

public ClientRequestHandler(SecurityServiceDbContext dbContext, IOpenIddictApplicationManager applicationManager)
public ClientRequestHandler(
SecurityServiceDbContext dbContext,
IOpenIddictApplicationManager applicationManager,
IOptions<ServiceOptions> options)
{
this.DbContext = dbContext;
this.ApplicationManager = applicationManager;
this.OAuthOptions = options.Value.OAuth;
}

public async Task<Result> Handle(SecurityServiceCommands.CreateClientCommand command, CancellationToken cancellationToken)
Expand All @@ -50,7 +46,7 @@ public async Task<Result> Handle(SecurityServiceCommands.CreateClientCommand com
return Result.Invalid("At least one grant type is required.");
}

String[] invalidGrantTypes = command.AllowedGrantTypes.Where(grantType => SupportedGrantTypes.Contains(grantType) == false).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
String[] invalidGrantTypes = command.AllowedGrantTypes.Where(grantType => OAuthGrantPolicy.IsGrantAllowed(grantType, command.ClientId, this.OAuthOptions) == false).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (invalidGrantTypes.Length > 0)
{
return Result.Invalid($"Unsupported grant types: {string.Join(", ", invalidGrantTypes)}.");
Expand All @@ -61,6 +57,8 @@ public async Task<Result> Handle(SecurityServiceCommands.CreateClientCommand com
return Result.Conflict($"A client with id '{command.ClientId}' already exists.");
}

OAuthGrantPolicyResult permissions = OAuthGrantPolicy.CreatePermissions(command.AllowedGrantTypes, command.ClientId, this.OAuthOptions);

OpenIddictApplicationDescriptor descriptor = new OpenIddictApplicationDescriptor
{
ClientId = command.ClientId,
Expand All @@ -70,6 +68,14 @@ public async Task<Result> Handle(SecurityServiceCommands.CreateClientCommand com
ClientSecret = string.IsNullOrWhiteSpace(command.Secret) ? null : command.Secret
};

descriptor.Permissions.UnionWith(permissions.Permissions);
descriptor.Requirements.UnionWith(permissions.Requirements);

if (command.AllowOfflineAccess)
{
descriptor.Permissions.Add(Permissions.GrantTypes.RefreshToken);
}

foreach (String redirectUri in command.ClientRedirectUris.Where(uri => string.IsNullOrWhiteSpace(uri) == false).Distinct(StringComparer.OrdinalIgnoreCase))
{
descriptor.RedirectUris.Add(new Uri(redirectUri, UriKind.Absolute));
Expand Down
9 changes: 9 additions & 0 deletions SecurityService.BusinessLogic/SecurityServiceOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using SimpleResults;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Collections.Generic;

namespace SecurityService.BusinessLogic;

Expand All @@ -18,6 +19,7 @@ public ServiceOptions()
this.SignInOptions = new SignInOptions();
this.UserOptions = new UserOptions();
this.ManagementBootstrap = new ManagementBootstrapOptions();
this.OAuth = new OAuthOptions();
}

#region Properties
Expand Down Expand Up @@ -48,9 +50,16 @@ public ServiceOptions()

public ManagementBootstrapOptions ManagementBootstrap { get; set; }

public OAuthOptions OAuth { get; set; }

#endregion
}

public sealed class OAuthOptions
{
public Dictionary<string, List<string>> LegacyGrantTypeClients { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

public sealed class ManagementBootstrapOptions
{
public bool Enabled { get; set; }
Expand Down
2 changes: 2 additions & 0 deletions SecurityService.IntegrationTests/Common/DockerHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ public override ContainerBuilder SetupSecurityServiceContainer()
environmentVariables.Add("ServiceOptions:ManagementBootstrap:Enabled", "true");
environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientId", "management-bootstrap");
environmentVariables.Add("ServiceOptions:ManagementBootstrap:ClientSecret", "management-bootstrap-secret");
environmentVariables.Add("ServiceOptions:OAuth:LegacyGrantTypeClients:password:0", "merchantClient");
environmentVariables.Add("ServiceOptions:OAuth:LegacyGrantTypeClients:hybrid:0", "testclient2");
environmentVariables.Add("ConnectionStrings:PersistedGrantDbContext", this.SetConnectionString($"PersistedGrantStore-{this.TestId}", this.UseSecureSqlServerDatabase));
environmentVariables.Add("ConnectionStrings:ConfigurationDbContext", this.SetConnectionString($"Configuration-{this.TestId}", this.UseSecureSqlServerDatabase));
environmentVariables.Add("ConnectionStrings:AuthenticationDbContext", this.SetConnectionString($"Authentication-{this.TestId}", this.UseSecureSqlServerDatabase));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Background:

Given I create the following clients
| ClientId | Name | Secret | Scopes | GrantTypes | RedirectUris | PostLogoutRedirectUris | RequireConsent | AllowOfflineAccess | ClientUri |
| estateUIClient | Merchant Client | Secret1 | transactionProcessor,openid,email,profile | hybrid | https://[url]:[port]/signin-oidc | https://[url]:[port]/signout-oidc | false | true | https://[url]:[port] |
| estateUIClient | Merchant Client | Secret1 | transactionProcessor,openid,email,profile,offline_access | authorization_code | https://[url]:[port]/signin-oidc | https://[url]:[port]/signout-oidc | false | true | https://[url]:[port] |


@PRTest
Expand All @@ -42,4 +42,4 @@ Scenario: Change Passwword
When I enter my new password 'Pa55word!'
And I confirm my new password 'Pa55word!'
And I click the change password button
Then I am returned to the application home page
Then I am returned to the application home page

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

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Background:

Given I create the following clients
| ClientId | Name | Secret | Scopes | GrantTypes | RedirectUris | PostLogoutRedirectUris | RequireConsent | AllowOfflineAccess | ClientUri |
| estateUIClient | Merchant Client | Secret1 | transactionProcessor,openid,email,profile | hybrid | https://[url]:[port]/signin-oidc | https://[url]:[port]/signout-oidc | false | true | https://[url]:[port] |
| estateUIClient | Merchant Client | Secret1 | transactionProcessor,openid,email,profile,offline_access | authorization_code | https://[url]:[port]/signin-oidc | https://[url]:[port]/signout-oidc | false | true | https://[url]:[port] |

Given I create the following users
| Email Address | Password | Phone Number | Given Name | Middle Name | Family Name | Claims | Roles |
Expand All @@ -46,4 +46,4 @@ Scenario: Forgot Password
When I enter my new password 'Pa55word!'
And I confirm my new password 'Pa55word!'
And I click the reset password button
Then my password is reset successfully
Then my password is reset successfully

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

Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Background:

Given I create the following clients
| ClientId | Name | Secret | Scopes | GrantTypes | RedirectUris | PostLogoutRedirectUris | RequireConsent | AllowOfflineAccess | ClientUri |
| estateUIClient | Merchant Client | Secret1 | transactionProcessor,openid,email,profile | hybrid | https://[url]:[port]/signin-oidc | https://[url]:[port]/signout-oidc | false | true | https://[url]:[port] |
| estateUIClient | Merchant Client | Secret1 | transactionProcessor,openid,email,profile,offline_access | authorization_code | https://[url]:[port]/signin-oidc | https://[url]:[port]/signout-oidc | false | true | https://[url]:[port] |

@PRTest
Scenario: Create User and Login
Expand Down

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

Loading
Loading