From ed168ef30b06a96c2f97c94cfb09414ce275623b Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 22 Oct 2026 15:33:49 +0100 Subject: [PATCH 1/4] added in misssing scalar --- Directory.Packages.props | 1 - .../Requests.cs | 49 +++++ .../Responses.cs | 59 ++++- ...SecurityService.DataTransferObjects.csproj | 2 + .../Common/GenericSteps.cs | 2 +- .../Endpoints/DeveloperEndpoints.cs | 10 +- .../Endpoints/ManagementEndpoints.cs | 134 +++++++++--- SecurityService/Endpoints/OidcEndpoints.cs | 32 ++- .../Handlers/ApiResourceHandler.cs | 2 +- SecurityService/Handlers/ApiScopeHandler.cs | 2 +- SecurityService/Handlers/ClientHandler.cs | 2 +- .../Handlers/IdentityResourceHandler.cs | 4 +- SecurityService/Handlers/RoleHandler.cs | 6 +- SecurityService/Handlers/UserHandler.cs | 8 +- .../OpenApi/DtoXmlSchemaTransformer.cs | 69 ++++++ .../OpenApi/EndpointExampleMetadata.cs | 80 +++++++ .../OidcRequestDocumentationTransformer.cs | 206 ++++++++++++++++++ SecurityService/Program.cs | 24 +- SecurityService/SecurityService.csproj | 3 +- 19 files changed, 641 insertions(+), 54 deletions(-) create mode 100644 SecurityService/OpenApi/DtoXmlSchemaTransformer.cs create mode 100644 SecurityService/OpenApi/EndpointExampleMetadata.cs create mode 100644 SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index e91a4550..fdb0a742 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -53,7 +53,6 @@ - diff --git a/SecurityService.DataTransferObjects/Requests.cs b/SecurityService.DataTransferObjects/Requests.cs index ad176790..c63f4094 100644 --- a/SecurityService.DataTransferObjects/Requests.cs +++ b/SecurityService.DataTransferObjects/Requests.cs @@ -1,93 +1,142 @@ namespace SecurityService.DataTransferObjects; +/// Configuration for registering an OAuth/OIDC client. +/// {"clientId":"orders-web","secret":"replace-with-a-secret","clientName":"Orders web app","allowedScopes":["openid","profile","orders.read"],"allowedGrantTypes":["authorization_code","refresh_token"],"clientUri":"https://orders.example.com","clientRedirectUris":["https://orders.example.com/signin-oidc"],"clientPostLogoutRedirectUris":["https://orders.example.com/signed-out"],"requireConsent":true,"allowOfflineAccess":true} public sealed class CreateClientRequest { + /// Unique identifier sent as the OAuth client_id. public string ClientId { get; set; } = string.Empty; + /// Shared secret for a confidential client. Omit for a public client; never returned by read endpoints. public string? Secret { get; set; } + /// Human-readable client name shown on consent and management screens. public string ClientName { get; set; } = string.Empty; + /// Optional administrative description of this client. public string? ClientDescription { get; set; } + /// OAuth scopes this client is permitted to request, such as openid and orders.read. public List AllowedScopes { get; set; } = new(); + /// OAuth grant types this client may use, such as authorization_code, refresh_token, or client_credentials. public List AllowedGrantTypes { get; set; } = new(); + /// Optional homepage URI for the client application. public string? ClientUri { get; set; } + /// Exact OAuth callback URIs allowed for authorization responses. public List ClientRedirectUris { get; set; } = new(); + /// Exact URIs allowed after an OIDC logout completes. public List ClientPostLogoutRedirectUris { get; set; } = new(); + /// Whether the user must approve requested scopes through a consent screen. public bool RequireConsent { get; set; } + /// Whether the client may request offline access and receive refresh tokens when supported by its grant. public bool AllowOfflineAccess { get; set; } } +/// Definition of a permission scope exposed by a protected API. +/// {"name":"orders.read","displayName":"Read orders","description":"View orders belonging to the signed-in user."} public sealed class CreateApiScopeRequest { + /// Unique scope name requested by OAuth clients, for example orders.read. public string Name { get; set; } = string.Empty; + /// Optional human-readable name shown in consent and documentation. public string? DisplayName { get; set; } + /// Optional explanation of the access granted by this scope. public string? Description { get; set; } } +/// Definition of a protected API resource, its scopes, and the user claims it can receive. +/// {"name":"orders-api","displayName":"Orders API","description":"Order management service","secret":"replace-with-a-secret","scopes":["orders.read"],"userClaims":["sub","email"]} public sealed class CreateApiResourceRequest { + /// Unique audience/resource name used to identify this API. public string Name { get; set; } = string.Empty; + /// Optional human-readable API name. public string? DisplayName { get; set; } + /// Optional description of the API resource. public string? Description { get; set; } + /// Optional shared secret used by the resource server for applicable token validation flows. public string? Secret { get; set; } + /// Scope names that grant access to this resource. public List Scopes { get; set; } = new(); + /// User claim names that may be included in tokens issued for this resource. public List UserClaims { get; set; } = new(); } +/// Definition of an OIDC identity resource and the claims it exposes. +/// {"name":"profile","displayName":"Profile","description":"Basic profile claims","required":false,"emphasize":false,"showInDiscoveryDocument":true,"claims":["name","given_name","family_name"]} public sealed class CreateIdentityResourceRequest { + /// Unique identity scope/resource name, such as profile or email. public string Name { get; set; } = string.Empty; + /// Optional human-readable name shown to the user. public string? DisplayName { get; set; } + /// Optional explanation of the identity data represented by this resource. public string? Description { get; set; } + /// Whether requesting clients must request this resource to complete authentication. public bool Required { get; set; } + /// Whether this resource should be highlighted on the consent screen. public bool Emphasize { get; set; } + /// Whether this resource is listed in the OIDC discovery document. Defaults to . public bool ShowInDiscoveryDocument { get; set; } = true; + /// Claim names provided when this identity resource is granted, such as name or email. public List Claims { get; set; } = new(); } +/// Role name to create. +/// {"roleName":"OrderManager"} public sealed class CreateRoleRequest { + /// Unique role name to create. public string RoleName { get; set; } = string.Empty; } +/// Account details, credentials, and optional authorization data for a new user. +/// {"givenName":"Avery","familyName":"Nguyen","userName":"avery.nguyen","password":"replace-with-a-policy-compliant-password","emailAddress":"avery@example.com","phoneNumber":"+15550102020","claims":{"department":"sales"},"roles":["OrderManager"]} public sealed class CreateUserRequest { + /// Optional given/first name. public string? GivenName { get; set; } + /// Optional middle name. public string? MiddleName { get; set; } + /// Optional family/last name. public string? FamilyName { get; set; } + /// Unique sign-in name for the account. public string UserName { get; set; } = string.Empty; + /// Initial password; must satisfy the service's configured identity password policy. public string Password { get; set; } = string.Empty; + /// Optional email address. Confirmation requirements depend on service configuration. public string? EmailAddress { get; set; } + /// Optional phone number, preferably in international format. public string? PhoneNumber { get; set; } + /// Additional string claims to assign, represented as claim-name/value pairs. public Dictionary Claims { get; set; } = new(); + /// Names of existing roles to assign to this user. public List Roles { get; set; } = new(); } diff --git a/SecurityService.DataTransferObjects/Responses.cs b/SecurityService.DataTransferObjects/Responses.cs index 4c94900d..4bec328e 100644 --- a/SecurityService.DataTransferObjects/Responses.cs +++ b/SecurityService.DataTransferObjects/Responses.cs @@ -3,102 +3,159 @@ namespace SecurityService.DataTransferObjects; +/// Public description of a protected API resource. Secrets are intentionally excluded. +/// {"name":"orders-api","displayName":"Orders API","description":"Order management service","scopes":["orders.read"],"userClaims":["sub","email"]} public sealed class ApiResourceResponse { + /// Unique API resource/audience name. public string Name { get; set; } + /// Optional human-readable API name. public string? DisplayName { get; set; } + /// Optional description of the API. public string? Description { get; set; } + /// Scopes associated with this API. public IReadOnlyCollection Scopes { get; set; } + /// User claim names that may be included in tokens for this API. public IReadOnlyCollection UserClaims { get; set; } } +/// Public description of an API scope. +/// {"name":"orders.read","displayName":"Read orders","description":"View orders belonging to the signed-in user."} public sealed record ApiScopeResponse { + /// Unique scope name used in OAuth requests. public string Name { get; set; } + /// Optional human-readable scope name. public string? DisplayName { get; set; } + /// Optional explanation of the permission. public string? Description { get; set; } } +/// Public client registration details. The client secret is never returned. +/// {"clientId":"orders-web","clientName":"Orders web app","description":"Customer order portal","clientUri":"https://orders.example.com","allowedScopes":["openid","profile","orders.read"],"allowedGrantTypes":["authorization_code","refresh_token"],"clientRedirectUris":["https://orders.example.com/signin-oidc"],"clientPostLogoutRedirectUris":["https://orders.example.com/signed-out"],"requireConsent":true,"allowOfflineAccess":true,"clientType":"confidential"} public sealed record ClientResponse { + /// OAuth client identifier. public string ClientId { get; set; } + /// Human-readable client name. public string ClientName { get; set; } + /// Optional administrative description. public string? Description { get; set; } + /// Optional homepage URI. public string? ClientUri { get; set; } + /// Scopes the client is permitted to request. public IReadOnlyCollection AllowedScopes { get; set; } + /// OAuth grant types permitted for the client. public IReadOnlyCollection AllowedGrantTypes { get; set; } + /// Registered authorization callback URIs. public IReadOnlyCollection ClientRedirectUris { get; set; } + /// Registered post-logout callback URIs. public IReadOnlyCollection ClientPostLogoutRedirectUris { get; set; } + /// Whether user consent is required for requested scopes. public bool RequireConsent { get; set; } + /// Whether offline access/refresh tokens are permitted. public bool AllowOfflineAccess { get; set; } + /// Configured client classification, such as public or confidential. public string ClientType { get; set; } } +/// Public description of an OIDC identity resource. +/// {"name":"profile","displayName":"Profile","description":"Basic profile claims","required":false,"emphasize":false,"showInDiscoveryDocument":true,"claims":["name","given_name","family_name"]} public sealed record IdentityResourceResponse { + /// Unique identity resource name. public string Name { get; set; } + /// Optional human-readable name. public string? DisplayName { get; set; } + /// Optional explanation of the identity data. public string? Description { get; set; } + /// Whether clients must request this resource. public bool Required { get; set; } + /// Whether this resource is highlighted on consent screens. public bool Emphasize { get; set; } + /// Whether this resource is listed in OIDC discovery. public bool ShowInDiscoveryDocument { get; set; } + /// Claims provided by this resource. public IReadOnlyCollection Claims { get; set; } } +/// Role identifier and name. +/// {"roleId":"2f0c1d2e-3456-4789-a123-123456789abc","roleName":"OrderManager"} public sealed record RoleResponse { + /// Identity system's stable role ID. public String RoleId { get; set; } + /// Unique role name. public string RoleName { get; set; } } +/// User profile and authorization data returned by management endpoints. +/// {"userId":"7a0c1d2e-3456-4789-a123-123456789abc","userName":"avery.nguyen","emailAddress":"avery@example.com","phoneNumber":"+15550102020","givenName":"Avery","middleName":null,"familyName":"Nguyen","claims":{"department":"sales"},"roles":["OrderManager"],"registrationDateTime":"2026-10-22T10:30:00Z"} public sealed record UserResponse { + /// Identity system's stable user ID. public String UserId { get; set; } + /// Sign-in name. public string UserName { get; set; } + /// Optional email address. public string? EmailAddress { get; set; } + /// Optional phone number. public string? PhoneNumber { get; set; } + /// Optional given/first name. public string? GivenName { get; set; } + /// Optional middle name. public string? MiddleName { get; set; } + /// Optional family/last name. public string? FamilyName { get; set; } + /// Additional string claims assigned to the user. public IReadOnlyDictionary Claims{ get; set; } + /// Role names assigned to the user. public IReadOnlyCollection Roles { get; set; } + /// UTC date and time when the account was registered. public DateTime RegistrationDateTime { get; set; } } +/// OAuth token response details with expiry timestamps. +/// {"accessToken":"eyJ...","expires":"2026-10-22T11:30:00Z","expiresIn":3600,"issued":"2026-10-22T10:30:00Z","refreshToken":"def..."} public class TokenResponse { + /// Bearer access token issued by the authorization server. public String AccessToken { get; private set; } + /// Absolute expiration timestamp for the access token. public DateTimeOffset Expires { get; private set; } + /// Access token lifetime in seconds. public Int64 ExpiresIn { get; private set; } + /// Timestamp when this response was received/issued. public DateTimeOffset Issued { get; private set; } + /// Refresh token when the grant and client are permitted to receive one; otherwise null. public String RefreshToken { get; private set; } public static TokenResponse Create(String token) { using var doc = JsonDocument.Parse(token); @@ -140,4 +197,4 @@ private TokenResponse(String accessToken, this.Issued = issued; this.Expires = expires; } -} \ No newline at end of file +} diff --git a/SecurityService.DataTransferObjects/SecurityService.DataTransferObjects.csproj b/SecurityService.DataTransferObjects/SecurityService.DataTransferObjects.csproj index 759e34c5..df4e5f9c 100644 --- a/SecurityService.DataTransferObjects/SecurityService.DataTransferObjects.csproj +++ b/SecurityService.DataTransferObjects/SecurityService.DataTransferObjects.csproj @@ -3,6 +3,8 @@ net10.0 enable enable + true + $(NoWarn);1591 diff --git a/SecurityService.OpenIdConnect.IntegrationTests/Common/GenericSteps.cs b/SecurityService.OpenIdConnect.IntegrationTests/Common/GenericSteps.cs index b2839903..f432a022 100644 --- a/SecurityService.OpenIdConnect.IntegrationTests/Common/GenericSteps.cs +++ b/SecurityService.OpenIdConnect.IntegrationTests/Common/GenericSteps.cs @@ -30,7 +30,7 @@ public GenericSteps(ScenarioContext scenarioContext, public async Task StartSystem() { // Initialise a logger - String scenarioName = this.ScenarioContext.ScenarioInfo.Title.Replace(" ", ""); + String scenarioName = this.ScenarioContext.ScenarioInfo.Title.Replace(" ", String.Empty); NlogLogger logger = new NlogLogger(); logger.Initialise(LogManager.GetLogger(scenarioName), scenarioName); LogManager.AddHiddenAssembly(typeof(NlogLogger).Assembly); diff --git a/SecurityService/Endpoints/DeveloperEndpoints.cs b/SecurityService/Endpoints/DeveloperEndpoints.cs index 1fa98db6..29d95d70 100644 --- a/SecurityService/Endpoints/DeveloperEndpoints.cs +++ b/SecurityService/Endpoints/DeveloperEndpoints.cs @@ -4,7 +4,13 @@ public static class DeveloperEndpoints { public static void MapDeveloperEndpoints(this IEndpointRouteBuilder endpoints) { - endpoints.MapGet("/api/developer/lastemail", Handlers.DeveloperHandler.GetLastEmail).WithName("GetLastEmail"); - endpoints.MapGet("/api/developer/lastsms", Handlers.DeveloperHandler.GetLastSms).WithName("GetLastSms"); + endpoints.MapGet("/api/developer/lastemail", Handlers.DeveloperHandler.GetLastEmail).WithName("GetLastEmail").WithTags("Developer testing") + .WithSummary("Get the most recent test email") + .WithDescription("Integration-test helper that returns the last email captured by the test messaging client, including recipient, subject, and body. Example: `GET /api/developer/lastemail` returns the captured email request JSON. Only mapped in the IntegrationTest environment. Returns 404 when no test email has been captured or the test client is unavailable.") + .Produces().Produces(404); + endpoints.MapGet("/api/developer/lastsms", Handlers.DeveloperHandler.GetLastSms).WithName("GetLastSms").WithTags("Developer testing") + .WithSummary("Get the most recent test SMS") + .WithDescription("Integration-test helper that returns the last SMS captured by the test messaging client, including recipient and message body. Example: `GET /api/developer/lastsms` returns the captured SMS request JSON. Only mapped in the IntegrationTest environment. Returns 404 when no test SMS has been captured or the test client is unavailable.") + .Produces().Produces(404); } } diff --git a/SecurityService/Endpoints/ManagementEndpoints.cs b/SecurityService/Endpoints/ManagementEndpoints.cs index 81f995fd..15b9f9d3 100644 --- a/SecurityService/Endpoints/ManagementEndpoints.cs +++ b/SecurityService/Endpoints/ManagementEndpoints.cs @@ -1,40 +1,120 @@ namespace SecurityService.Endpoints; using SecurityService.Authorization; +using SecurityService.OpenApi; public static class ManagementEndpoints { public static void MapManagementEndpoints(this IEndpointRouteBuilder endpoints) { RouteGroupBuilder management = endpoints.MapGroup("/api") + .WithTags("Management") .RequireAuthorization(ManagementAuthorizationPolicies.ManagementApi); - management.MapPost("/clients", Handlers.ClientHandler.CreateClient).WithName("CreateClient"); - management.MapGet("/clients/{clientId}", Handlers.ClientHandler.GetClient).WithName("GetClient"); - management.MapGet("/clients", Handlers.ClientHandler.GetClients).WithName("GetClients"); - - management.MapPost("/apiscopes", Handlers.ApiScopeHandler.CreateApiScope).WithName("CreateApiScope"); - management.MapGet("/apiscopes/{name}", Handlers.ApiScopeHandler.GetApiScope).WithName("GetApiScope"); - management.MapGet("/apiscopes", Handlers.ApiScopeHandler.GetApiScopes).WithName("GetApiScopes"); - - management.MapPost("/apiresources", Handlers.ApiResourceHandler.CreateApiResource).WithName("CreateApiResource"); - management.MapGet("/apiresources/{name}", Handlers.ApiResourceHandler.GetApiResource).WithName("GetApiResource"); - management.MapGet("/apiresources", Handlers.ApiResourceHandler.GetApiResources).WithName("GetApiResources"); - - management.MapPost("/identityresources", Handlers.IdentityResourceHandler.CreateIdentityResource).WithName("CreateIdentityResource"); - management.MapGet("/identityresources/{name}", Handlers.IdentityResourceHandler.GetIdentityResource).WithName("GetIdentityResource"); - management.MapGet("/identityresources", Handlers.IdentityResourceHandler.GetIdentityResources).WithName("GetIdentityResources"); - - management.MapPost("/roles", Handlers.RoleHandler.CreateRole).WithName("CreateRole"); - management.MapGet("/roles/{roleId}", Handlers.RoleHandler.GetRole).WithName("GetRole"); - management.MapGet("/roles", Handlers.RoleHandler.GetRoles).WithName("GetRoles"); - - management.MapPost("/users", Handlers.UserHandler.CreateUser).WithName("CreateUser"); - management.MapGet("/users/{userId}", Handlers.UserHandler.GetUser).WithName("GetUser"); - management.MapGet("/users", Handlers.UserHandler.GetUsers).WithName("GetUsers"); - management.MapPut("/users/{userId}/mfa-policy", Handlers.UserHandler.RequireMfa).WithName("RequireUserMfa"); - management.MapDelete("/users/{userId}/mfa-policy", Handlers.UserHandler.RemoveMfa).WithName("RemoveUserMfa"); - management.MapPut("/roles/{roleId}/mfa-policy", Handlers.RoleHandler.RequireMfa).WithName("RequireRoleMfa"); - management.MapDelete("/roles/{roleId}/mfa-policy", Handlers.RoleHandler.RemoveMfa).WithName("RemoveRoleMfa"); + management.MapPost("/clients", Handlers.ClientHandler.CreateClient).WithName("CreateClient") + .WithSummary("Register an OAuth client") + .WithDescription("Creates a client registration used by OAuth/OIDC flows. Supply allowed grant types, scopes, and exact callback URLs. A secret is used for confidential clients and is never returned by the read endpoints. Requires management API authorization. Invalid input returns 400; duplicate IDs may return 409.") + .WithRequestExample("""{"clientId":"orders-web","secret":"replace-with-a-secret","clientName":"Orders web app","allowedScopes":["openid","profile","orders.read"],"allowedGrantTypes":["authorization_code","refresh_token"],"clientUri":"https://orders.example.com","clientRedirectUris":["https://orders.example.com/signin-oidc"],"clientPostLogoutRedirectUris":["https://orders.example.com/signed-out"],"requireConsent":true,"allowOfflineAccess":true}""") + .Produces(200).Produces(400).Produces(401).Produces(403).Produces(404).Produces(409).Produces(500); + management.MapGet("/clients/{clientId}", Handlers.ClientHandler.GetClient).WithName("GetClient") + .WithSummary("Get a client registration") + .WithDescription("Returns the public configuration for one registered OAuth client. The client secret is never included. The caller must have management API authorization. Returns 404 when the ID is unknown.") + .Produces() + .Produces(404).Produces(401).Produces(403) + .WithResponseExample("""{"clientId":"orders-web","clientName":"Orders web app","allowedScopes":["openid","orders.read"],"allowedGrantTypes":["authorization_code"],"clientRedirectUris":["https://orders.example.com/signin-oidc"],"clientPostLogoutRedirectUris":[],"requireConsent":true,"allowOfflineAccess":false,"clientType":"confidential"}"""); + management.MapGet("/clients", Handlers.ClientHandler.GetClients).WithName("GetClients") + .WithSummary("List client registrations") + .WithDescription("Returns all registered OAuth clients as an array. No pagination or query filters are currently supported. Client secrets are never included.") + .Produces>() + .Produces(401).Produces(403) + .WithResponseExample("""[{"clientId":"orders-web","clientName":"Orders web app","allowedScopes":["openid","orders.read"],"allowedGrantTypes":["authorization_code"],"clientRedirectUris":["https://orders.example.com/signin-oidc"],"clientPostLogoutRedirectUris":[],"requireConsent":true,"allowOfflineAccess":false,"clientType":"confidential"}]"""); + + management.MapPost("/apiscopes", Handlers.ApiScopeHandler.CreateApiScope).WithName("CreateApiScope") + .WithSummary("Create an API scope") + .WithDescription("Registers a named permission that clients can request in access tokens. Requires management API authorization. Invalid input returns 400; a duplicate name may return 409.") + .WithRequestExample("""{"name":"orders.read","displayName":"Read orders","description":"View orders belonging to the signed-in user."}""") + .Produces(200).Produces(400).Produces(401).Produces(403).Produces(404).Produces(409).Produces(500); + management.MapGet("/apiscopes/{name}", Handlers.ApiScopeHandler.GetApiScope).WithName("GetApiScope") + .WithSummary("Get an API scope") + .WithDescription("Returns one API scope by its exact, case-sensitive configured name. Returns 404 when no scope has that name. Requires management API authorization.") + .Produces().Produces(404).Produces(401).Produces(403) + .WithResponseExample("""{"name":"orders.read","displayName":"Read orders","description":"View orders belonging to the signed-in user."}"""); + management.MapGet("/apiscopes", Handlers.ApiScopeHandler.GetApiScopes).WithName("GetApiScopes") + .WithSummary("List API scopes") + .WithDescription("Returns every configured API scope as an array. No pagination or query filters are currently supported. Requires management API authorization.") + .Produces>().Produces(401).Produces(403) + .WithResponseExample("""[{"name":"orders.read","displayName":"Read orders","description":"View orders belonging to the signed-in user."}]"""); + + management.MapPost("/apiresources", Handlers.ApiResourceHandler.CreateApiResource).WithName("CreateApiResource") + .WithSummary("Create an API resource") + .WithDescription("Registers a protected API and associates it with scopes and user claims. Requires management API authorization. Invalid input returns 400; a duplicate name may return 409.") + .WithRequestExample("""{"name":"orders-api","displayName":"Orders API","description":"Order management service","secret":"replace-with-a-secret","scopes":["orders.read"],"userClaims":["sub","email"]}""") + .Produces(200).Produces(400).Produces(401).Produces(403).Produces(404).Produces(409).Produces(500); + management.MapGet("/apiresources/{name}", Handlers.ApiResourceHandler.GetApiResource).WithName("GetApiResource") + .WithSummary("Get an API resource") + .WithDescription("Returns one API resource by its exact configured name. The resource secret is not returned. Returns 404 when unknown. Requires management API authorization.") + .Produces().Produces(404).Produces(401).Produces(403) + .WithResponseExample("""{"name":"orders-api","displayName":"Orders API","description":"Order management service","scopes":["orders.read"],"userClaims":["sub","email"]}"""); + management.MapGet("/apiresources", Handlers.ApiResourceHandler.GetApiResources).WithName("GetApiResources") + .WithSummary("List API resources") + .WithDescription("Returns all configured API resources as an array. Resource secrets are not returned. No pagination or query filters are currently supported.") + .Produces>().Produces(401).Produces(403) + .WithResponseExample("""[{"name":"orders-api","displayName":"Orders API","description":"Order management service","scopes":["orders.read"],"userClaims":["sub","email"]}]"""); + + management.MapPost("/identityresources", Handlers.IdentityResourceHandler.CreateIdentityResource).WithName("CreateIdentityResource") + .WithSummary("Create an identity resource") + .WithDescription("Registers an OIDC identity resource and the claims it exposes. Requires management API authorization.") + .WithRequestExample("""{"name":"profile","displayName":"Profile","description":"Basic profile claims","required":false,"emphasize":false,"showInDiscoveryDocument":true,"claims":["name","given_name","family_name"]}""") + .Produces(200).Produces(400).Produces(401).Produces(403).Produces(404).Produces(409).Produces(500); + management.MapGet("/identityresources/{name}", Handlers.IdentityResourceHandler.GetIdentityResource).WithName("GetIdentityResource") + .WithSummary("Get an identity resource") + .WithDescription("Returns one OIDC identity resource by its exact configured name. Returns 404 when unknown. Requires management API authorization.") + .Produces().Produces(404).Produces(401).Produces(403) + .WithResponseExample("""{"name":"profile","displayName":"Profile","description":"Basic profile claims","required":false,"emphasize":false,"showInDiscoveryDocument":true,"claims":["name","given_name","family_name"]}"""); + management.MapGet("/identityresources", Handlers.IdentityResourceHandler.GetIdentityResources).WithName("GetIdentityResources") + .WithSummary("List identity resources") + .WithDescription("Returns all configured OIDC identity resources as an array. No pagination or query filters are currently supported.") + .Produces>().Produces(401).Produces(403) + .WithResponseExample("""[{"name":"profile","displayName":"Profile","description":"Basic profile claims","required":false,"emphasize":false,"showInDiscoveryDocument":true,"claims":["name","given_name","family_name"]}]"""); + + management.MapPost("/roles", Handlers.RoleHandler.CreateRole).WithName("CreateRole") + .WithSummary("Create a role") + .WithDescription("Creates a role that can be assigned to users and used in authorization decisions. Requires management API authorization.") + .WithRequestExample("""{"roleName":"OrderManager"}""") + .Produces(200).Produces(400).Produces(401).Produces(403).Produces(404).Produces(409).Produces(500); + management.MapGet("/roles/{roleId}", Handlers.RoleHandler.GetRole).WithName("GetRole") + .WithSummary("Get a role") + .WithDescription("Returns a role by its identity role ID (not its display name). Returns 404 when unknown. Requires management API authorization.") + .Produces().Produces(404).Produces(401).Produces(403) + .WithResponseExample("""{"roleId":"2f0c1d2e-3456-4789-a123-123456789abc","roleName":"OrderManager"}"""); + management.MapGet("/roles", Handlers.RoleHandler.GetRoles).WithName("GetRoles") + .WithSummary("List roles") + .WithDescription("Returns every role as an array. No pagination or query filters are currently supported. Requires management API authorization.") + .Produces>().Produces(401).Produces(403) + .WithResponseExample("""[{"roleId":"2f0c1d2e-3456-4789-a123-123456789abc","roleName":"OrderManager"}]"""); + + management.MapPost("/users", Handlers.UserHandler.CreateUser).WithName("CreateUser") + .WithSummary("Create a user") + .WithDescription("Creates a local account and assigns the supplied claims and roles. Password must satisfy the configured identity password policy. Requires management API authorization.") + .WithRequestExample("""{"givenName":"Avery","familyName":"Nguyen","userName":"avery.nguyen","password":"replace-with-a-policy-compliant-password","emailAddress":"avery@example.com","phoneNumber":"+15550102020","claims":{"department":"sales"},"roles":["OrderManager"]}""") + .Produces(200).Produces(400).Produces(401).Produces(403).Produces(404).Produces(409).Produces(500); + management.MapGet("/users/{userId}", Handlers.UserHandler.GetUser).WithName("GetUser") + .WithSummary("Get a user") + .WithDescription("Returns the account, claims, roles, and registration time for the specified identity user ID. Returns 404 when unknown. Requires management API authorization.") + .Produces().Produces(404).Produces(401).Produces(403) + .WithResponseExample("""{"userId":"7a0c1d2e-3456-4789-a123-123456789abc","userName":"avery.nguyen","emailAddress":"avery@example.com","phoneNumber":"+15550102020","givenName":"Avery","middleName":null,"familyName":"Nguyen","claims":{"department":"sales"},"roles":["OrderManager"],"registrationDateTime":"2026-10-22T10:30:00Z"}"""); + management.MapGet("/users", Handlers.UserHandler.GetUsers).WithName("GetUsers") + .WithSummary("List users") + .WithDescription("Returns users as an array. Optionally filter by exact user name using `?userName=avery.nguyen`; omit it to return all users. No pagination is currently supported. Requires management API authorization.") + .Produces>().Produces(401).Produces(403) + .WithResponseExample("""[{"userId":"7a0c1d2e-3456-4789-a123-123456789abc","userName":"avery.nguyen","emailAddress":"avery@example.com","phoneNumber":"+15550102020","givenName":"Avery","middleName":null,"familyName":"Nguyen","claims":{"department":"sales"},"roles":["OrderManager"],"registrationDateTime":"2026-10-22T10:30:00Z"}]"""); + management.MapPut("/users/{userId}/mfa-policy", Handlers.UserHandler.RequireMfa).WithName("RequireUserMfa") + .WithSummary("Require MFA for a user").WithDescription("Enables the MFA-required policy for the specified user. Example: `PUT /api/users/7a0c1d2e-3456-4789-a123-123456789abc/mfa-policy`. Requires management API authorization.").Produces(200).Produces(404).Produces(401).Produces(403); + management.MapDelete("/users/{userId}/mfa-policy", Handlers.UserHandler.RemoveMfa).WithName("RemoveUserMfa") + .WithSummary("Remove the user MFA requirement").WithDescription("Removes the MFA-required policy from the specified user. Example: `DELETE /api/users/7a0c1d2e-3456-4789-a123-123456789abc/mfa-policy`. Requires management API authorization.").Produces(200).Produces(404).Produces(401).Produces(403); + management.MapPut("/roles/{roleId}/mfa-policy", Handlers.RoleHandler.RequireMfa).WithName("RequireRoleMfa") + .WithSummary("Require MFA for a role").WithDescription("Enables the MFA-required policy for the specified role; users in that role must satisfy MFA according to the service policy. Example: `PUT /api/roles/role-id/mfa-policy`. Requires management API authorization.").Produces(200).Produces(404).Produces(401).Produces(403); + management.MapDelete("/roles/{roleId}/mfa-policy", Handlers.RoleHandler.RemoveMfa).WithName("RemoveRoleMfa") + .WithSummary("Remove the role MFA requirement").WithDescription("Removes the MFA-required policy from the specified role. Example: `DELETE /api/roles/role-id/mfa-policy`. Requires management API authorization.").Produces(200).Produces(404).Produces(401).Produces(403); } } diff --git a/SecurityService/Endpoints/OidcEndpoints.cs b/SecurityService/Endpoints/OidcEndpoints.cs index 5c8f780d..d84c3500 100644 --- a/SecurityService/Endpoints/OidcEndpoints.cs +++ b/SecurityService/Endpoints/OidcEndpoints.cs @@ -1,12 +1,36 @@ namespace SecurityService.Endpoints; +using SecurityService.OpenApi; + public static class OidcEndpoints { public static void MapOidcEndpoints(this IEndpointRouteBuilder endpoints) { - endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], Handlers.AuthorizeHandler.AuthorizeAsync); - endpoints.MapPost("/connect/token", Handlers.TokenHandler.TokenAsync); - endpoints.MapMethods("/connect/logout", [HttpMethods.Get, HttpMethods.Post], Handlers.LogoutHandler.LogoutAsync); - endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], Handlers.UserInfoHandler.UserInfoAsync); + endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], Handlers.AuthorizeHandler.AuthorizeAsync) + .WithName("Authorize") + .WithTags("OpenID Connect") + .WithSummary("Start an OpenID Connect authorization request") + .WithDescription("Interactive OIDC authorization endpoint. Send the standard authorization parameters as a query string for GET or as form fields for POST. Use `response_type=code` and PKCE with `code_challenge_method=S256` for the authorization-code flow. The user may be redirected to sign in, consent, or the registered client callback. The client and redirect URI must be registered.") + .Produces(302).Produces(400).Produces(401); + endpoints.MapPost("/connect/token", Handlers.TokenHandler.TokenAsync) + .WithName("Token") + .WithTags("OpenID Connect") + .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`.") + .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) + .WithName("Logout") + .WithTags("OpenID Connect") + .WithSummary("End an OpenID Connect session") + .WithDescription("OIDC end-session endpoint. Accepts the standard `id_token_hint`, `post_logout_redirect_uri`, and `state` parameters as a query string for GET or as form fields for POST. The post-logout redirect URI must be registered for the client.") + .Produces(302).Produces(400); + endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], Handlers.UserInfoHandler.UserInfoAsync) + .WithName("UserInfo") + .WithTags("OpenID Connect") + .WithSummary("Get claims for the authenticated user") + .WithDescription("OIDC UserInfo endpoint. Send a valid access token in `Authorization: Bearer ` (GET or POST). Returns the claims authorized by the token's scopes; missing or invalid tokens are challenged.") + .Produces(200, contentType: "application/json").Produces(401) + .WithResponseExample("""{"sub":"7a0c1d2e-3456-4789-a123-123456789abc","name":"Avery Nguyen","given_name":"Avery","family_name":"Nguyen","email":"avery@example.com","email_verified":true}"""); } } diff --git a/SecurityService/Handlers/ApiResourceHandler.cs b/SecurityService/Handlers/ApiResourceHandler.cs index a1461946..2d81f2ba 100644 --- a/SecurityService/Handlers/ApiResourceHandler.cs +++ b/SecurityService/Handlers/ApiResourceHandler.cs @@ -19,7 +19,7 @@ public static async Task CreateApiResource(IMediator mediator, CreateAp } public static async Task GetApiResource(IMediator mediator, - string name, + [System.ComponentModel.Description("Exact configured API resource name, for example orders-api.")] string name, CancellationToken cancellationToken) { SecurityServiceQueries.GetApiResourceQuery query = new(name); diff --git a/SecurityService/Handlers/ApiScopeHandler.cs b/SecurityService/Handlers/ApiScopeHandler.cs index 2c15355e..dfa3c5d3 100644 --- a/SecurityService/Handlers/ApiScopeHandler.cs +++ b/SecurityService/Handlers/ApiScopeHandler.cs @@ -19,7 +19,7 @@ public static async Task CreateApiScope(IMediator mediator, CreateApiSc } public static async Task GetApiScope(IMediator mediator, - string name, + [System.ComponentModel.Description("Exact configured API scope name, for example orders.read.")] string name, CancellationToken cancellationToken) { SecurityServiceQueries.GetApiScopeQuery query = new(name); diff --git a/SecurityService/Handlers/ClientHandler.cs b/SecurityService/Handlers/ClientHandler.cs index 721a946e..4b064d23 100644 --- a/SecurityService/Handlers/ClientHandler.cs +++ b/SecurityService/Handlers/ClientHandler.cs @@ -30,7 +30,7 @@ public static async Task CreateClient(IMediator mediator, CreateClientR } public static async Task GetClient(IMediator mediator, - string clientId, + [System.ComponentModel.Description("OAuth client identifier assigned when the client registration was created.")] string clientId, CancellationToken cancellationToken) { SecurityServiceQueries.GetClientQuery query = new(clientId); diff --git a/SecurityService/Handlers/IdentityResourceHandler.cs b/SecurityService/Handlers/IdentityResourceHandler.cs index a476f64f..d2f8e62c 100644 --- a/SecurityService/Handlers/IdentityResourceHandler.cs +++ b/SecurityService/Handlers/IdentityResourceHandler.cs @@ -18,7 +18,7 @@ public static async Task CreateIdentityResource(IMediator mediator, Cre return ResponseFactory.FromResult(result); } - public static async Task GetIdentityResource(IMediator mediator, string name, CancellationToken cancellationToken) { + public static async Task GetIdentityResource(IMediator mediator, [System.ComponentModel.Description("Exact configured identity resource name, for example profile or email.")] string name, CancellationToken cancellationToken) { SecurityServiceQueries.GetIdentityResourceQuery query = new(name); Result result = await mediator.Send(query, cancellationToken); @@ -33,4 +33,4 @@ public static async Task GetIdentityResources(IMediator mediator, Cance return ResponseFactory.FromResult(result, ModelFactory.ConvertFrom); } -} \ No newline at end of file +} diff --git a/SecurityService/Handlers/RoleHandler.cs b/SecurityService/Handlers/RoleHandler.cs index 5b15f1ab..947a0f56 100644 --- a/SecurityService/Handlers/RoleHandler.cs +++ b/SecurityService/Handlers/RoleHandler.cs @@ -11,7 +11,7 @@ namespace SecurityService.Handlers; public static class RoleHandler { public static async Task RequireMfa(IMediator mediator, - string roleId, + [System.ComponentModel.Description("Stable identity ID of the role to update.")] string roleId, CancellationToken cancellationToken) { var result = await mediator.Send(new SecurityServiceCommands.RequireRoleMfaCommand(roleId), cancellationToken); @@ -19,7 +19,7 @@ public static async Task RequireMfa(IMediator mediator, } public static async Task RemoveMfa(IMediator mediator, - string roleId, + [System.ComponentModel.Description("Stable identity ID of the role to update.")] string roleId, CancellationToken cancellationToken) { var result = await mediator.Send(new SecurityServiceCommands.RemoveRoleMfaCommand(roleId), cancellationToken); @@ -35,7 +35,7 @@ public static async Task CreateRole(IMediator mediator, CreateRoleReque } public static async Task GetRole(IMediator mediator, - string roleId, + [System.ComponentModel.Description("Stable identity ID of the role to retrieve.")] string roleId, CancellationToken cancellationToken) { SecurityServiceQueries.GetRoleQuery query = new(roleId); diff --git a/SecurityService/Handlers/UserHandler.cs b/SecurityService/Handlers/UserHandler.cs index 9d032735..3332cf83 100644 --- a/SecurityService/Handlers/UserHandler.cs +++ b/SecurityService/Handlers/UserHandler.cs @@ -11,7 +11,7 @@ namespace SecurityService.Handlers; public static class UserHandler { public static async Task RequireMfa(IMediator mediator, - string userId, + [System.ComponentModel.Description("Stable identity ID of the user whose MFA requirement is changed.")] string userId, CancellationToken cancellationToken) { var result = await mediator.Send(new SecurityServiceCommands.RequireUserMfaCommand(userId), cancellationToken); @@ -19,7 +19,7 @@ public static async Task RequireMfa(IMediator mediator, } public static async Task RemoveMfa(IMediator mediator, - string userId, + [System.ComponentModel.Description("Stable identity ID of the user whose MFA requirement is removed.")] string userId, CancellationToken cancellationToken) { var result = await mediator.Send(new SecurityServiceCommands.RemoveUserMfaCommand(userId), cancellationToken); @@ -35,7 +35,7 @@ public static async Task CreateUser(IMediator mediator, CreateUserReque } public static async Task GetUser(IMediator mediator, - string userId, + [System.ComponentModel.Description("Stable identity ID of the user to retrieve.")] string userId, CancellationToken cancellationToken) { SecurityServiceQueries.GetUserQuery query = new(userId); @@ -48,7 +48,7 @@ public static async Task GetUser(IMediator mediator, return Results.Ok(ModelFactory.ConvertFrom(result.Data)); } - public static async Task GetUsers(IMediator mediator, string? userName, CancellationToken cancellationToken) { + public static async Task GetUsers(IMediator mediator, [System.ComponentModel.Description("Optional exact sign-in name to filter by; omit or leave empty to list all users.")] string? userName, CancellationToken cancellationToken) { SecurityServiceQueries.GetUsersQuery query = new(userName); Result> result = await mediator.Send(query, cancellationToken); diff --git a/SecurityService/OpenApi/DtoXmlSchemaTransformer.cs b/SecurityService/OpenApi/DtoXmlSchemaTransformer.cs new file mode 100644 index 00000000..ef9aaae5 --- /dev/null +++ b/SecurityService/OpenApi/DtoXmlSchemaTransformer.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using System.Xml; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace SecurityService.OpenApi; + +/// Applies DTO summaries, property descriptions, and examples to ASP.NET Core's OpenAPI schemas. +public sealed class DtoXmlSchemaTransformer : IOpenApiSchemaTransformer +{ + private static readonly Lazy XmlDocumentation = new(LoadXmlDocumentation); + + public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) + { + Type? dtoType = context.JsonTypeInfo?.Type; + MemberInfo? property = context.JsonPropertyInfo?.AttributeProvider as MemberInfo; + string? memberName = property is not null + ? $"P:{property.DeclaringType?.FullName}.{property.Name}" + : dtoType is not null ? $"T:{dtoType.FullName}" : null; + + if (memberName is null) + { + return Task.CompletedTask; + } + + XmlNode? member = XmlDocumentation.Value?.SelectSingleNode($"/doc/members/member[@name='{memberName}']"); + if (member is null) + { + return Task.CompletedTask; + } + + string? description = Normalize(member.SelectSingleNode("summary")?.InnerText); + if (!string.IsNullOrWhiteSpace(description)) + { + schema.Description = description; + } + + string? example = Normalize(member.SelectSingleNode("example")?.InnerText); + if (!string.IsNullOrWhiteSpace(example)) + { + try + { + schema.Example = System.Text.Json.Nodes.JsonNode.Parse(example); + } + catch (System.Text.Json.JsonException) + { + // Ignore malformed XML example markup rather than failing OpenAPI generation. + } + } + + return Task.CompletedTask; + } + + private static XmlDocument? LoadXmlDocumentation() + { + string path = Path.Combine(AppContext.BaseDirectory, "SecurityService.DataTransferObjects.xml"); + if (!File.Exists(path)) + { + return null; + } + + XmlDocument document = new(); + document.Load(path); + return document; + } + + private static string? Normalize(string? value) => + string.IsNullOrWhiteSpace(value) ? null : string.Join(" ", value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); +} diff --git a/SecurityService/OpenApi/EndpointExampleMetadata.cs b/SecurityService/OpenApi/EndpointExampleMetadata.cs new file mode 100644 index 00000000..f826f530 --- /dev/null +++ b/SecurityService/OpenApi/EndpointExampleMetadata.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace SecurityService.OpenApi; + +/// Describes an OpenAPI media type example attached to an endpoint. +public sealed record EndpointExampleMetadata( + EndpointExampleLocation Location, + string Json, + string ContentType = "application/json", + string StatusCode = "200", + string Summary = "Example"); + +public enum EndpointExampleLocation +{ + Request, + Response +} + +public static class EndpointExampleExtensions +{ + public static RouteHandlerBuilder WithRequestExample(this RouteHandlerBuilder builder, string json, string summary = "Example request", string contentType = "application/json") + { + return builder.WithMetadata(new EndpointExampleMetadata(EndpointExampleLocation.Request, json, contentType, Summary: summary)); + } + + public static RouteHandlerBuilder WithResponseExample(this RouteHandlerBuilder builder, string json, string summary = "Example response", string statusCode = "200", string contentType = "application/json") + { + return builder.WithMetadata(new EndpointExampleMetadata(EndpointExampleLocation.Response, json, contentType, statusCode, summary)); + } +} + +/// Adds the same request and response examples to ASP.NET Core's OpenAPI document used by Scalar. +public sealed class EndpointExampleOpenApiTransformer : IOpenApiOperationTransformer +{ + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + IEnumerable examples = context.Description.ActionDescriptor.EndpointMetadata + .OfType(); + + foreach (EndpointExampleMetadata example in examples) + { + OpenApiMediaType? mediaType = example.Location switch + { + EndpointExampleLocation.Request when operation.RequestBody?.Content.TryGetValue(example.ContentType, out OpenApiMediaType? requestContent) == true => requestContent, + EndpointExampleLocation.Response when operation.Responses.TryGetValue(example.StatusCode, out IOpenApiResponse? response) => GetOrAddResponseContent(response, example.ContentType), + _ => null + }; + + if (mediaType is null) + { + continue; + } + + JsonNode? json = JsonNode.Parse(example.Json); + mediaType.Example = json?.DeepClone(); + mediaType.Examples ??= new Dictionary(); + mediaType.Examples["Example"] = new OpenApiExample + { + Summary = example.Summary, + Value = json?.DeepClone() + }; + } + + return Task.CompletedTask; + } + + private static OpenApiMediaType GetOrAddResponseContent(IOpenApiResponse response, string contentType) + { + if (response.Content.TryGetValue(contentType, out OpenApiMediaType? content)) + { + return content; + } + + content = new OpenApiMediaType(); + response.Content[contentType] = content; + return content; + } +} diff --git a/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs new file mode 100644 index 00000000..8308b240 --- /dev/null +++ b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs @@ -0,0 +1,206 @@ +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; + +namespace SecurityService.OpenApi; + +/// Adds discoverable query, form, and bearer-auth examples for OIDC operations in Scalar. +public sealed class OidcRequestDocumentationTransformer : IOpenApiOperationTransformer +{ + private const string RedirectUri = "https://orders.example.com/signin-oidc"; + private const string CodeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + private const string CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; + + public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) + { + string path = context.Description.RelativePath?.TrimStart('/') ?? string.Empty; + string method = context.Description.HttpMethod ?? string.Empty; + + if (path.Equals("connect/authorize", StringComparison.OrdinalIgnoreCase)) + { + AddParameter(operation, "client_id", "Registered OAuth client identifier.", "orders-web", required: true); + AddParameter(operation, "redirect_uri", "Must exactly match a callback URI registered for the client.", RedirectUri, required: true); + AddParameter(operation, "response_type", "Use `code` for the authorization-code flow.", "code", required: true); + AddParameter(operation, "scope", "Space-separated OIDC scopes and API permissions.", "openid profile orders.read", required: true); + AddParameter(operation, "state", "Opaque value returned unchanged to the client callback.", "order-login-7f3a", required: false); + AddParameter(operation, "code_challenge", "Base64url-encoded SHA-256 challenge derived from the PKCE verifier.", CodeChallenge, required: false); + AddParameter(operation, "code_challenge_method", "Use `S256` for PKCE.", "S256", required: false); + + if (HttpMethods.IsPost(method)) + { + SetFormRequest(operation, "Authorization request (form POST)", new Dictionary + { + ["client_id"] = "orders-web", + ["redirect_uri"] = RedirectUri, + ["response_type"] = "code", + ["scope"] = "openid profile orders.read", + ["state"] = "order-login-7f3a", + ["code_challenge"] = CodeChallenge, + ["code_challenge_method"] = "S256" + }); + } + } + else if (path.Equals("connect/token", StringComparison.OrdinalIgnoreCase)) + { + AddTokenForm(operation); + } + else if (path.Equals("connect/logout", StringComparison.OrdinalIgnoreCase)) + { + AddParameter(operation, "id_token_hint", "Previously issued ID token for the session being ended.", "eyJhbGciOi...", required: false); + AddParameter(operation, "post_logout_redirect_uri", "Must exactly match a post-logout URI registered for the client.", "https://orders.example.com/signed-out", required: false); + AddParameter(operation, "state", "Opaque value returned unchanged to the post-logout redirect URI.", "logout-4c82", required: false); + + if (HttpMethods.IsPost(method)) + { + SetFormRequest(operation, "End-session request (form POST)", new Dictionary + { + ["id_token_hint"] = "eyJhbGciOi...", + ["post_logout_redirect_uri"] = "https://orders.example.com/signed-out", + ["state"] = "logout-4c82" + }); + } + } + else if (path.Equals("connect/userinfo", StringComparison.OrdinalIgnoreCase)) + { + AddBearerAuthentication(operation, context.Document); + } + + return Task.CompletedTask; + } + + private static void AddParameter(OpenApiOperation operation, string name, string description, string value, bool required) + { + operation.Parameters ??= new List(); + if (operation.Parameters.Any(parameter => parameter.Name == name && parameter.In == ParameterLocation.Query)) + { + return; + } + + operation.Parameters.Add(new OpenApiParameter + { + Name = name, + In = ParameterLocation.Query, + Description = description, + Required = required, + Schema = new OpenApiSchema { Type = JsonSchemaType.String, Example = JsonValue.Create(value) }, + Example = JsonValue.Create(value) + }); + } + + private static void AddTokenForm(OpenApiOperation operation) + { + OpenApiSchema schema = new() + { + Type = JsonSchemaType.Object, + Properties = new Dictionary + { + ["grant_type"] = StringField("OAuth grant to exchange, such as authorization_code, client_credentials, or refresh_token."), + ["client_id"] = StringField("Registered OAuth client identifier."), + ["client_secret"] = StringField("Required for confidential clients using client authentication."), + ["code"] = StringField("Authorization code returned to the registered callback."), + ["redirect_uri"] = StringField("Callback URI used in the authorization request; must match exactly."), + ["code_verifier"] = StringField("Original PKCE verifier corresponding to code_challenge."), + ["scope"] = StringField("Space-separated permissions requested for the token."), + ["refresh_token"] = StringField("Refresh token previously issued to this client.") + } + }; + + OpenApiMediaType content = new() + { + Schema = schema, + Examples = new Dictionary + { + ["authorization_code"] = FormExample("Authorization code with PKCE", new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = "orders-web", + ["code"] = "authorization-code-from-callback", + ["redirect_uri"] = RedirectUri, + ["code_verifier"] = CodeVerifier + }), + ["client_credentials"] = FormExample("Client credentials", new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = "orders-worker", + ["client_secret"] = "replace-with-client-secret", + ["scope"] = "orders.read" + }), + ["refresh_token"] = FormExample("Refresh an access token", new Dictionary + { + ["grant_type"] = "refresh_token", + ["client_id"] = "orders-web", + ["refresh_token"] = "replace-with-refresh-token" + }) + } + }; + + operation.RequestBody = new OpenApiRequestBody + { + Description = "Submit one of the grant-specific form examples. Client authentication requirements depend on the client registration.", + Required = true, + Content = new Dictionary { ["application/x-www-form-urlencoded"] = content } + }; + } + + private static void SetFormRequest(OpenApiOperation operation, string description, Dictionary values) + { + OpenApiSchema schema = new() + { + Type = JsonSchemaType.Object, + Properties = values.Keys.ToDictionary(key => key, _ => (IOpenApiSchema)StringField("OIDC form parameter.")) + }; + OpenApiExample example = FormExample(description, values); + operation.RequestBody = new OpenApiRequestBody + { + Description = description, + Required = true, + Content = new Dictionary + { + ["application/x-www-form-urlencoded"] = new OpenApiMediaType + { + Schema = schema, + Example = example.Value is JsonNode value ? value.DeepClone() : null, + Examples = new Dictionary { ["Example"] = example } + } + } + }; + } + + private static OpenApiSchema StringField(string description) => new() + { + Type = JsonSchemaType.String, + Description = description + }; + + private static OpenApiExample FormExample(string summary, Dictionary values) + { + JsonObject value = new(); + foreach ((string key, string fieldValue) in values) + { + value[key] = fieldValue; + } + + return new OpenApiExample { Summary = summary, Value = value }; + } + + private static void AddBearerAuthentication(OpenApiOperation operation, OpenApiDocument document) + { + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + if (!document.Components.SecuritySchemes.TryGetValue("Bearer", out IOpenApiSecurityScheme? bearer)) + { + bearer = new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Use an access token issued with the `openid` scope. Example header: `Authorization: Bearer eyJhbGciOi...`." + }; + document.Components.SecuritySchemes["Bearer"] = bearer; + } + + OpenApiSecuritySchemeReference bearerReference = new("Bearer", document); + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement { [bearerReference] = new List() }); + } +} diff --git a/SecurityService/Program.cs b/SecurityService/Program.cs index b275a2a4..c50806a5 100644 --- a/SecurityService/Program.cs +++ b/SecurityService/Program.cs @@ -23,6 +23,7 @@ using SecurityService.HealthChecks; using SecurityService.HostedServices; using SecurityService.Authorization; +using SecurityService.OpenApi; using Sentry.Extensibility; using Shared.EntityFramework; using Shared.Extensions; @@ -252,7 +253,7 @@ builder.Services.ConfigureHttpJsonOptions(jsonOptions => SecurityService.Configuration.JsonSerializerConfiguration.ConfigureMinimalApi(jsonOptions.SerializerOptions)); builder.Services.AddRazorPages(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); @@ -279,6 +280,13 @@ options.Title = "Security Service"; options.Description = "API to manage all aspects of application identity and security"; }); +// Scalar reads the ASP.NET Core OpenAPI document at /openapi/v1.json. +builder.Services.AddOpenApi("v1", options => +{ + options.AddSchemaTransformer(); + options.AddOperationTransformer(); + options.AddOperationTransformer(); +}); var rsa = RSA.Create(2048); var key = new RsaSecurityKey(rsa); @@ -386,8 +394,6 @@ app.UseStatusCodePagesWithReExecute("/Home/Error"); app.UseHttpLogging(); -app.UseSwagger(); -app.UseSwaggerUI(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); @@ -402,7 +408,15 @@ } app.MapManagementEndpoints(); app.MapOidcEndpoints(); -app.MapHealthChecks("health", new HealthCheckOptions { Predicate = _ => true, ResponseWriter = Shared.HealthChecks.HealthCheckMiddleware.WriteResponse }); -app.MapHealthChecks("healthui", new HealthCheckOptions { Predicate = _ => true, ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse }); +app.MapHealthChecks("health", new HealthCheckOptions { Predicate = _ => true, ResponseWriter = Shared.HealthChecks.HealthCheckMiddleware.WriteResponse }) + .WithMetadata(new Microsoft.AspNetCore.Http.EndpointSummaryAttribute("Check service health"), + new Microsoft.AspNetCore.Http.EndpointDescriptionAttribute("Runs the configured database, issuer, and messaging health checks and returns the service health result. Example: `GET /health` with `Accept: application/json`. A failing dependency is reflected in the returned health status."), + new Microsoft.AspNetCore.Http.TagsAttribute(["Health"])); +app.MapHealthChecks("healthui", new HealthCheckOptions { Predicate = _ => true, ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse }) + .WithMetadata(new Microsoft.AspNetCore.Http.EndpointSummaryAttribute("Check service health for the health UI"), + new Microsoft.AspNetCore.Http.EndpointDescriptionAttribute("Runs the same configured health checks and returns the JSON format consumed by the Health Checks UI. Example: `GET /healthui` with `Accept: application/json`."), + new Microsoft.AspNetCore.Http.TagsAttribute(["Health"])); + +app.UseApiDefaults(); app.Run(); diff --git a/SecurityService/SecurityService.csproj b/SecurityService/SecurityService.csproj index c79201af..fd9dc504 100644 --- a/SecurityService/SecurityService.csproj +++ b/SecurityService/SecurityService.csproj @@ -5,6 +5,8 @@ enable Linux 66711dd1-58c4-40cb-9a4c-2119de1f1d56 + true + $(NoWarn);1591 @@ -33,7 +35,6 @@ - From 8d20fc67db30e19447365fb63fe79d6116b0c37f Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 22 Oct 2026 15:41:45 +0100 Subject: [PATCH 2/4] Fix OpenAPI operation IDs and examples --- SecurityService/OpenApi/EndpointExampleMetadata.cs | 1 - .../OpenApi/OidcRequestDocumentationTransformer.cs | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/SecurityService/OpenApi/EndpointExampleMetadata.cs b/SecurityService/OpenApi/EndpointExampleMetadata.cs index f826f530..079467b6 100644 --- a/SecurityService/OpenApi/EndpointExampleMetadata.cs +++ b/SecurityService/OpenApi/EndpointExampleMetadata.cs @@ -54,7 +54,6 @@ EndpointExampleLocation.Response when operation.Responses.TryGetValue(example.St } JsonNode? json = JsonNode.Parse(example.Json); - mediaType.Example = json?.DeepClone(); mediaType.Examples ??= new Dictionary(); mediaType.Examples["Example"] = new OpenApiExample { diff --git a/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs index 8308b240..610dce0f 100644 --- a/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs +++ b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs @@ -18,6 +18,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform if (path.Equals("connect/authorize", StringComparison.OrdinalIgnoreCase)) { + operation.OperationId = HttpMethods.IsPost(method) ? "AuthorizePost" : "AuthorizeGet"; AddParameter(operation, "client_id", "Registered OAuth client identifier.", "orders-web", required: true); AddParameter(operation, "redirect_uri", "Must exactly match a callback URI registered for the client.", RedirectUri, required: true); AddParameter(operation, "response_type", "Use `code` for the authorization-code flow.", "code", required: true); @@ -46,6 +47,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } else if (path.Equals("connect/logout", StringComparison.OrdinalIgnoreCase)) { + operation.OperationId = HttpMethods.IsPost(method) ? "LogoutPost" : "LogoutGet"; AddParameter(operation, "id_token_hint", "Previously issued ID token for the session being ended.", "eyJhbGciOi...", required: false); AddParameter(operation, "post_logout_redirect_uri", "Must exactly match a post-logout URI registered for the client.", "https://orders.example.com/signed-out", required: false); AddParameter(operation, "state", "Opaque value returned unchanged to the post-logout redirect URI.", "logout-4c82", required: false); @@ -62,6 +64,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform } else if (path.Equals("connect/userinfo", StringComparison.OrdinalIgnoreCase)) { + operation.OperationId = HttpMethods.IsPost(method) ? "UserInfoPost" : "UserInfoGet"; AddBearerAuthentication(operation, context.Document); } @@ -159,7 +162,6 @@ private static void SetFormRequest(OpenApiOperation operation, string descriptio ["application/x-www-form-urlencoded"] = new OpenApiMediaType { Schema = schema, - Example = example.Value is JsonNode value ? value.DeepClone() : null, Examples = new Dictionary { ["Example"] = example } } } From 6f6c55241a62bc6906cea1b1304fd7a549e77e78 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 22 Oct 2026 15:51:03 +0100 Subject: [PATCH 3/4] Address Codacy findings in API docs --- SecurityService/Endpoints/OidcEndpoints.cs | 10 +- .../OpenApi/DtoXmlSchemaTransformer.cs | 36 +++-- .../OidcRequestDocumentationTransformer.cs | 141 ++++++++++-------- 3 files changed, 112 insertions(+), 75 deletions(-) diff --git a/SecurityService/Endpoints/OidcEndpoints.cs b/SecurityService/Endpoints/OidcEndpoints.cs index d84c3500..8daa2c68 100644 --- a/SecurityService/Endpoints/OidcEndpoints.cs +++ b/SecurityService/Endpoints/OidcEndpoints.cs @@ -4,30 +4,32 @@ namespace SecurityService.Endpoints; public static class OidcEndpoints { + private const string OpenIdConnectTag = "OpenID Connect"; + public static void MapOidcEndpoints(this IEndpointRouteBuilder endpoints) { endpoints.MapMethods("/connect/authorize", [HttpMethods.Get, HttpMethods.Post], Handlers.AuthorizeHandler.AuthorizeAsync) .WithName("Authorize") - .WithTags("OpenID Connect") + .WithTags(OpenIdConnectTag) .WithSummary("Start an OpenID Connect authorization request") .WithDescription("Interactive OIDC authorization endpoint. Send the standard authorization parameters as a query string for GET or as form fields for POST. Use `response_type=code` and PKCE with `code_challenge_method=S256` for the authorization-code flow. The user may be redirected to sign in, consent, or the registered client callback. The client and redirect URI must be registered.") .Produces(302).Produces(400).Produces(401); endpoints.MapPost("/connect/token", Handlers.TokenHandler.TokenAsync) .WithName("Token") - .WithTags("OpenID Connect") + .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`.") .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) .WithName("Logout") - .WithTags("OpenID Connect") + .WithTags(OpenIdConnectTag) .WithSummary("End an OpenID Connect session") .WithDescription("OIDC end-session endpoint. Accepts the standard `id_token_hint`, `post_logout_redirect_uri`, and `state` parameters as a query string for GET or as form fields for POST. The post-logout redirect URI must be registered for the client.") .Produces(302).Produces(400); endpoints.MapMethods("/connect/userinfo", [HttpMethods.Get, HttpMethods.Post], Handlers.UserInfoHandler.UserInfoAsync) .WithName("UserInfo") - .WithTags("OpenID Connect") + .WithTags(OpenIdConnectTag) .WithSummary("Get claims for the authenticated user") .WithDescription("OIDC UserInfo endpoint. Send a valid access token in `Authorization: Bearer ` (GET or POST). Returns the claims authorized by the token's scopes; missing or invalid tokens are challenged.") .Produces(200, contentType: "application/json").Produces(401) diff --git a/SecurityService/OpenApi/DtoXmlSchemaTransformer.cs b/SecurityService/OpenApi/DtoXmlSchemaTransformer.cs index ef9aaae5..1ec3968e 100644 --- a/SecurityService/OpenApi/DtoXmlSchemaTransformer.cs +++ b/SecurityService/OpenApi/DtoXmlSchemaTransformer.cs @@ -12,29 +12,47 @@ public sealed class DtoXmlSchemaTransformer : IOpenApiSchemaTransformer public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken) { - Type? dtoType = context.JsonTypeInfo?.Type; - MemberInfo? property = context.JsonPropertyInfo?.AttributeProvider as MemberInfo; - string? memberName = property is not null - ? $"P:{property.DeclaringType?.FullName}.{property.Name}" - : dtoType is not null ? $"T:{dtoType.FullName}" : null; - - if (memberName is null) + XmlDocument? documentation = XmlDocumentation.Value; + string? memberName = GetMemberName(context); + if (documentation is null || memberName is null) { return Task.CompletedTask; } - XmlNode? member = XmlDocumentation.Value?.SelectSingleNode($"/doc/members/member[@name='{memberName}']"); + XmlNode? member = documentation.SelectSingleNode($"/doc/members/member[@name='{memberName}']"); if (member is null) { return Task.CompletedTask; } + ApplyDescription(schema, member); + ApplyExample(schema, member); + return Task.CompletedTask; + } + + private static string? GetMemberName(OpenApiSchemaTransformerContext context) + { + MemberInfo? property = context.JsonPropertyInfo?.AttributeProvider as MemberInfo; + if (property is not null) + { + return $"P:{property.DeclaringType?.FullName}.{property.Name}"; + } + + Type? dtoType = context.JsonTypeInfo?.Type; + return dtoType is null ? null : $"T:{dtoType.FullName}"; + } + + private static void ApplyDescription(OpenApiSchema schema, XmlNode member) + { string? description = Normalize(member.SelectSingleNode("summary")?.InnerText); if (!string.IsNullOrWhiteSpace(description)) { schema.Description = description; } + } + private static void ApplyExample(OpenApiSchema schema, XmlNode member) + { string? example = Normalize(member.SelectSingleNode("example")?.InnerText); if (!string.IsNullOrWhiteSpace(example)) { @@ -47,8 +65,6 @@ public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext // Ignore malformed XML example markup rather than failing OpenAPI generation. } } - - return Task.CompletedTask; } private static XmlDocument? LoadXmlDocumentation() diff --git a/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs index 610dce0f..69cb9af9 100644 --- a/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs +++ b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs @@ -7,6 +7,16 @@ namespace SecurityService.OpenApi; /// Adds discoverable query, form, and bearer-auth examples for OIDC operations in Scalar. public sealed class OidcRequestDocumentationTransformer : IOpenApiOperationTransformer { + private const string AuthorizePath = "connect/authorize"; + private const string TokenPath = "connect/token"; + private const string LogoutPath = "connect/logout"; + private const string UserInfoPath = "connect/userinfo"; + private const string ClientIdParameter = "client_id"; + private const string RedirectUriParameter = "redirect_uri"; + private const string ScopeParameter = "scope"; + private const string StateParameter = "state"; + private const string GrantTypeParameter = "grant_type"; + private const string RefreshTokenParameter = "refresh_token"; private const string RedirectUri = "https://orders.example.com/signin-oidc"; private const string CodeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; private const string CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; @@ -16,59 +26,68 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform string path = context.Description.RelativePath?.TrimStart('/') ?? string.Empty; string method = context.Description.HttpMethod ?? string.Empty; - if (path.Equals("connect/authorize", StringComparison.OrdinalIgnoreCase)) + switch (path.ToLowerInvariant()) { - operation.OperationId = HttpMethods.IsPost(method) ? "AuthorizePost" : "AuthorizeGet"; - AddParameter(operation, "client_id", "Registered OAuth client identifier.", "orders-web", required: true); - AddParameter(operation, "redirect_uri", "Must exactly match a callback URI registered for the client.", RedirectUri, required: true); - AddParameter(operation, "response_type", "Use `code` for the authorization-code flow.", "code", required: true); - AddParameter(operation, "scope", "Space-separated OIDC scopes and API permissions.", "openid profile orders.read", required: true); - AddParameter(operation, "state", "Opaque value returned unchanged to the client callback.", "order-login-7f3a", required: false); - AddParameter(operation, "code_challenge", "Base64url-encoded SHA-256 challenge derived from the PKCE verifier.", CodeChallenge, required: false); - AddParameter(operation, "code_challenge_method", "Use `S256` for PKCE.", "S256", required: false); - - if (HttpMethods.IsPost(method)) - { - SetFormRequest(operation, "Authorization request (form POST)", new Dictionary - { - ["client_id"] = "orders-web", - ["redirect_uri"] = RedirectUri, - ["response_type"] = "code", - ["scope"] = "openid profile orders.read", - ["state"] = "order-login-7f3a", - ["code_challenge"] = CodeChallenge, - ["code_challenge_method"] = "S256" - }); - } - } - else if (path.Equals("connect/token", StringComparison.OrdinalIgnoreCase)) - { - AddTokenForm(operation); + case AuthorizePath: + ConfigureAuthorization(operation, method); + break; + case TokenPath: + AddTokenForm(operation); + break; + case LogoutPath: + ConfigureLogout(operation, method); + break; + case UserInfoPath: + operation.OperationId = HttpMethods.IsPost(method) ? "UserInfoPost" : "UserInfoGet"; + AddBearerAuthentication(operation, context.Document); + break; } - else if (path.Equals("connect/logout", StringComparison.OrdinalIgnoreCase)) - { - operation.OperationId = HttpMethods.IsPost(method) ? "LogoutPost" : "LogoutGet"; - AddParameter(operation, "id_token_hint", "Previously issued ID token for the session being ended.", "eyJhbGciOi...", required: false); - AddParameter(operation, "post_logout_redirect_uri", "Must exactly match a post-logout URI registered for the client.", "https://orders.example.com/signed-out", required: false); - AddParameter(operation, "state", "Opaque value returned unchanged to the post-logout redirect URI.", "logout-4c82", required: false); - if (HttpMethods.IsPost(method)) + return Task.CompletedTask; + } + + private static void ConfigureAuthorization(OpenApiOperation operation, string method) + { + operation.OperationId = HttpMethods.IsPost(method) ? "AuthorizePost" : "AuthorizeGet"; + AddParameter(operation, ClientIdParameter, "Registered OAuth client identifier.", "orders-web", required: true); + AddParameter(operation, RedirectUriParameter, "Must exactly match a callback URI registered for the client.", RedirectUri, required: true); + AddParameter(operation, "response_type", "Use `code` for the authorization-code flow.", "code", required: true); + AddParameter(operation, ScopeParameter, "Space-separated OIDC scopes and API permissions.", "openid profile orders.read", required: true); + AddParameter(operation, StateParameter, "Opaque value returned unchanged to the client callback.", "order-login-7f3a", required: false); + AddParameter(operation, "code_challenge", "Base64url-encoded SHA-256 challenge derived from the PKCE verifier.", CodeChallenge, required: false); + AddParameter(operation, "code_challenge_method", "Use `S256` for PKCE.", "S256", required: false); + + if (HttpMethods.IsPost(method)) + { + SetFormRequest(operation, "Authorization request (form POST)", new Dictionary { - SetFormRequest(operation, "End-session request (form POST)", new Dictionary - { - ["id_token_hint"] = "eyJhbGciOi...", - ["post_logout_redirect_uri"] = "https://orders.example.com/signed-out", - ["state"] = "logout-4c82" - }); - } + [ClientIdParameter] = "orders-web", + [RedirectUriParameter] = RedirectUri, + ["response_type"] = "code", + [ScopeParameter] = "openid profile orders.read", + [StateParameter] = "order-login-7f3a", + ["code_challenge"] = CodeChallenge, + ["code_challenge_method"] = "S256" + }); } - else if (path.Equals("connect/userinfo", StringComparison.OrdinalIgnoreCase)) + } + + private static void ConfigureLogout(OpenApiOperation operation, string method) + { + operation.OperationId = HttpMethods.IsPost(method) ? "LogoutPost" : "LogoutGet"; + AddParameter(operation, "id_token_hint", "Previously issued ID token for the session being ended.", "eyJhbGciOi...", required: false); + AddParameter(operation, "post_logout_redirect_uri", "Must exactly match a post-logout URI registered for the client.", "https://orders.example.com/signed-out", required: false); + AddParameter(operation, StateParameter, "Opaque value returned unchanged to the post-logout redirect URI.", "logout-4c82", required: false); + + if (HttpMethods.IsPost(method)) { - operation.OperationId = HttpMethods.IsPost(method) ? "UserInfoPost" : "UserInfoGet"; - AddBearerAuthentication(operation, context.Document); + SetFormRequest(operation, "End-session request (form POST)", new Dictionary + { + ["id_token_hint"] = "eyJhbGciOi...", + ["post_logout_redirect_uri"] = "https://orders.example.com/signed-out", + [StateParameter] = "logout-4c82" + }); } - - return Task.CompletedTask; } private static void AddParameter(OpenApiOperation operation, string name, string description, string value, bool required) @@ -97,14 +116,14 @@ private static void AddTokenForm(OpenApiOperation operation) Type = JsonSchemaType.Object, Properties = new Dictionary { - ["grant_type"] = StringField("OAuth grant to exchange, such as authorization_code, client_credentials, or refresh_token."), - ["client_id"] = StringField("Registered OAuth client identifier."), + [GrantTypeParameter] = StringField("OAuth grant to exchange, such as authorization_code, client_credentials, or refresh_token."), + [ClientIdParameter] = StringField("Registered OAuth client identifier."), ["client_secret"] = StringField("Required for confidential clients using client authentication."), ["code"] = StringField("Authorization code returned to the registered callback."), - ["redirect_uri"] = StringField("Callback URI used in the authorization request; must match exactly."), + [RedirectUriParameter] = StringField("Callback URI used in the authorization request; must match exactly."), ["code_verifier"] = StringField("Original PKCE verifier corresponding to code_challenge."), - ["scope"] = StringField("Space-separated permissions requested for the token."), - ["refresh_token"] = StringField("Refresh token previously issued to this client.") + [ScopeParameter] = StringField("Space-separated permissions requested for the token."), + [RefreshTokenParameter] = StringField("Refresh token previously issued to this client.") } }; @@ -115,24 +134,24 @@ private static void AddTokenForm(OpenApiOperation operation) { ["authorization_code"] = FormExample("Authorization code with PKCE", new Dictionary { - ["grant_type"] = "authorization_code", - ["client_id"] = "orders-web", + [GrantTypeParameter] = "authorization_code", + [ClientIdParameter] = "orders-web", ["code"] = "authorization-code-from-callback", - ["redirect_uri"] = RedirectUri, + [RedirectUriParameter] = RedirectUri, ["code_verifier"] = CodeVerifier }), ["client_credentials"] = FormExample("Client credentials", new Dictionary { - ["grant_type"] = "client_credentials", - ["client_id"] = "orders-worker", + [GrantTypeParameter] = "client_credentials", + [ClientIdParameter] = "orders-worker", ["client_secret"] = "replace-with-client-secret", - ["scope"] = "orders.read" + [ScopeParameter] = "orders.read" }), - ["refresh_token"] = FormExample("Refresh an access token", new Dictionary + [RefreshTokenParameter] = FormExample("Refresh an access token", new Dictionary { - ["grant_type"] = "refresh_token", - ["client_id"] = "orders-web", - ["refresh_token"] = "replace-with-refresh-token" + [GrantTypeParameter] = "refresh_token", + [ClientIdParameter] = "orders-web", + [RefreshTokenParameter] = "replace-with-refresh-token" }) } }; From 5bcccb3f0a118284d134aa208f1100f068219961 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Thu, 22 Oct 2026 15:55:16 +0100 Subject: [PATCH 4/4] Clear remaining Codacy findings --- .../OpenApi/OidcRequestDocumentationTransformer.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs index 69cb9af9..eb96f6b0 100644 --- a/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs +++ b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs @@ -17,6 +17,7 @@ public sealed class OidcRequestDocumentationTransformer : IOpenApiOperationTrans private const string StateParameter = "state"; private const string GrantTypeParameter = "grant_type"; private const string RefreshTokenParameter = "refresh_token"; + private const string SampleWebClientId = "orders-web"; private const string RedirectUri = "https://orders.example.com/signin-oidc"; private const string CodeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; private const string CodeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"; @@ -41,6 +42,8 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform operation.OperationId = HttpMethods.IsPost(method) ? "UserInfoPost" : "UserInfoGet"; AddBearerAuthentication(operation, context.Document); break; + default: + break; } return Task.CompletedTask; @@ -49,7 +52,7 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform private static void ConfigureAuthorization(OpenApiOperation operation, string method) { operation.OperationId = HttpMethods.IsPost(method) ? "AuthorizePost" : "AuthorizeGet"; - AddParameter(operation, ClientIdParameter, "Registered OAuth client identifier.", "orders-web", required: true); + AddParameter(operation, ClientIdParameter, "Registered OAuth client identifier.", SampleWebClientId, required: true); AddParameter(operation, RedirectUriParameter, "Must exactly match a callback URI registered for the client.", RedirectUri, required: true); AddParameter(operation, "response_type", "Use `code` for the authorization-code flow.", "code", required: true); AddParameter(operation, ScopeParameter, "Space-separated OIDC scopes and API permissions.", "openid profile orders.read", required: true); @@ -61,7 +64,7 @@ private static void ConfigureAuthorization(OpenApiOperation operation, string me { SetFormRequest(operation, "Authorization request (form POST)", new Dictionary { - [ClientIdParameter] = "orders-web", + [ClientIdParameter] = SampleWebClientId, [RedirectUriParameter] = RedirectUri, ["response_type"] = "code", [ScopeParameter] = "openid profile orders.read", @@ -135,7 +138,7 @@ private static void AddTokenForm(OpenApiOperation operation) ["authorization_code"] = FormExample("Authorization code with PKCE", new Dictionary { [GrantTypeParameter] = "authorization_code", - [ClientIdParameter] = "orders-web", + [ClientIdParameter] = SampleWebClientId, ["code"] = "authorization-code-from-callback", [RedirectUriParameter] = RedirectUri, ["code_verifier"] = CodeVerifier @@ -150,7 +153,7 @@ private static void AddTokenForm(OpenApiOperation operation) [RefreshTokenParameter] = FormExample("Refresh an access token", new Dictionary { [GrantTypeParameter] = "refresh_token", - [ClientIdParameter] = "orders-web", + [ClientIdParameter] = SampleWebClientId, [RefreshTokenParameter] = "replace-with-refresh-token" }) }