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..8daa2c68 100644
--- a/SecurityService/Endpoints/OidcEndpoints.cs
+++ b/SecurityService/Endpoints/OidcEndpoints.cs
@@ -1,12 +1,38 @@
namespace SecurityService.Endpoints;
+using SecurityService.OpenApi;
+
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);
- 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(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(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(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(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)
+ .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..1ec3968e
--- /dev/null
+++ b/SecurityService/OpenApi/DtoXmlSchemaTransformer.cs
@@ -0,0 +1,85 @@
+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)
+ {
+ XmlDocument? documentation = XmlDocumentation.Value;
+ string? memberName = GetMemberName(context);
+ if (documentation is null || memberName is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ 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))
+ {
+ 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.
+ }
+ }
+ }
+
+ 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..079467b6
--- /dev/null
+++ b/SecurityService/OpenApi/EndpointExampleMetadata.cs
@@ -0,0 +1,79 @@
+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.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..eb96f6b0
--- /dev/null
+++ b/SecurityService/OpenApi/OidcRequestDocumentationTransformer.cs
@@ -0,0 +1,230 @@
+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 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 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";
+
+ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken)
+ {
+ string path = context.Description.RelativePath?.TrimStart('/') ?? string.Empty;
+ string method = context.Description.HttpMethod ?? string.Empty;
+
+ switch (path.ToLowerInvariant())
+ {
+ 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;
+ default:
+ break;
+ }
+
+ 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.", 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);
+ 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
+ {
+ [ClientIdParameter] = SampleWebClientId,
+ [RedirectUriParameter] = RedirectUri,
+ ["response_type"] = "code",
+ [ScopeParameter] = "openid profile orders.read",
+ [StateParameter] = "order-login-7f3a",
+ ["code_challenge"] = CodeChallenge,
+ ["code_challenge_method"] = "S256"
+ });
+ }
+ }
+
+ 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))
+ {
+ 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"
+ });
+ }
+ }
+
+ 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
+ {
+ [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."),
+ [RedirectUriParameter] = StringField("Callback URI used in the authorization request; must match exactly."),
+ ["code_verifier"] = StringField("Original PKCE verifier corresponding to code_challenge."),
+ [ScopeParameter] = StringField("Space-separated permissions requested for the token."),
+ [RefreshTokenParameter] = 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
+ {
+ [GrantTypeParameter] = "authorization_code",
+ [ClientIdParameter] = SampleWebClientId,
+ ["code"] = "authorization-code-from-callback",
+ [RedirectUriParameter] = RedirectUri,
+ ["code_verifier"] = CodeVerifier
+ }),
+ ["client_credentials"] = FormExample("Client credentials", new Dictionary
+ {
+ [GrantTypeParameter] = "client_credentials",
+ [ClientIdParameter] = "orders-worker",
+ ["client_secret"] = "replace-with-client-secret",
+ [ScopeParameter] = "orders.read"
+ }),
+ [RefreshTokenParameter] = FormExample("Refresh an access token", new Dictionary
+ {
+ [GrantTypeParameter] = "refresh_token",
+ [ClientIdParameter] = SampleWebClientId,
+ [RefreshTokenParameter] = "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,
+ 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 @@
-