From 19a62ded581305b66b44643b62192b65b98072d6 Mon Sep 17 00:00:00 2001 From: vamsii777 Date: Wed, 30 Oct 2024 03:08:41 +0530 Subject: [PATCH] Implement RFC 8414 compliant Authorization Server Metadata endpoint This commit introduces a new endpoint at /.well-known/oauth-authorization-server to expose OAuth 2.0 server metadata conforming to RFC 8414. The endpoint provides information about the server's configuration, including supported grant types, response types, scopes, and endpoints. This allows clients to dynamically discover the server's capabilities and adapt their interactions accordingly. The metadata endpoint implementation includes: - Required fields per RFC 8414 - Recommended fields with appropriate default values - Optional fields with configurable presence based on feature support - Custom metadata provider for overriding default values - Error handling for invalid requests The endpoint also includes proper HTTP headers to ensure cache control and content type compliance. Closes #11 --- Package.swift | 5 +- .../DefaultServerMetadataProvider.swift | 106 +++++++++ .../Models/OAuthServerMetadata.swift | 64 ++++++ Sources/VaporOAuth/OAuth2.swift | 24 +- .../Protocols/ServerMetadataProvider.swift | 5 + .../RouteHandlers/MetadataHandler.swift | 53 +++++ .../Fakes/FakeAuthorizationHandler.swift | 27 +++ .../Helpers/TestDataBuilder.swift | 4 +- .../AuthCodeResourceServerTests.swift | 3 +- .../MetadataTests/MetadataEndpointTests.swift | 215 ++++++++++++++++++ 10 files changed, 500 insertions(+), 6 deletions(-) create mode 100644 Sources/VaporOAuth/DefaultImplementations/DefaultServerMetadataProvider.swift create mode 100644 Sources/VaporOAuth/Models/OAuthServerMetadata.swift create mode 100644 Sources/VaporOAuth/Protocols/ServerMetadataProvider.swift create mode 100644 Sources/VaporOAuth/RouteHandlers/MetadataHandler.swift create mode 100644 Tests/VaporOAuthTests/Fakes/FakeAuthorizationHandler.swift create mode 100644 Tests/VaporOAuthTests/MetadataTests/MetadataEndpointTests.swift diff --git a/Package.swift b/Package.swift index 5f97c4d..3050ba9 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,10 @@ let package = Package( targets: [ .target( name: "VaporOAuth", - dependencies: [.product(name: "Vapor", package: "vapor"), .product(name: "Crypto", package: "swift-crypto")], + dependencies: [ + .product(name: "Vapor", package: "vapor"), + .product(name: "Crypto", package: "swift-crypto") + ], swiftSettings: [ .enableUpcomingFeature("BareSlashRegexLiterals"), .enableExperimentalFeature("StrictConcurrency=complete"), diff --git a/Sources/VaporOAuth/DefaultImplementations/DefaultServerMetadataProvider.swift b/Sources/VaporOAuth/DefaultImplementations/DefaultServerMetadataProvider.swift new file mode 100644 index 0000000..0ee10c3 --- /dev/null +++ b/Sources/VaporOAuth/DefaultImplementations/DefaultServerMetadataProvider.swift @@ -0,0 +1,106 @@ +import Vapor + +/// Default implementation that automatically derives OAuth 2.0 server metadata from configuration +public struct DefaultServerMetadataProvider: ServerMetadataProvider { + private let issuer: String + private let validScopes: [String]? + private let clientRetriever: ClientRetriever + private let hasCodeManager: Bool + private let hasDeviceCodeManager: Bool + private let hasTokenIntrospection: Bool + private let hasUserManager: Bool + private let jwksEndpoint: String + + /// Initialize the metadata provider with OAuth 2.0 server configuration + /// - Parameters: + /// - issuer: The issuer identifier for the OAuth 2.0 authorization server + /// - validScopes: List of supported OAuth scopes, if any + /// - clientRetriever: Service for retrieving OAuth client information + /// - hasCodeManager: Whether authorization code flow is supported + /// - hasDeviceCodeManager: Whether device authorization flow is supported + /// - hasTokenIntrospection: Whether token introspection is supported + /// - hasUserManager: Whether resource owner password credentials flow is supported + /// - jwksEndpoint: Optional custom JWKS endpoint URL. If nil, defaults to /.well-known/jwks.json + init( + issuer: String, + validScopes: [String]?, + clientRetriever: ClientRetriever, + hasCodeManager: Bool, + hasDeviceCodeManager: Bool, + hasTokenIntrospection: Bool, + hasUserManager: Bool, + jwksEndpoint: String? = nil + ) { + self.issuer = issuer + self.validScopes = validScopes + self.clientRetriever = clientRetriever + self.hasCodeManager = hasCodeManager + self.hasDeviceCodeManager = hasDeviceCodeManager + self.hasTokenIntrospection = hasTokenIntrospection + self.hasUserManager = hasUserManager + + let baseURL = issuer.hasSuffix("/") ? String(issuer.dropLast()) : issuer + self.jwksEndpoint = jwksEndpoint ?? "\(baseURL)/.well-known/jwks.json" + } + + public func getMetadata() async throws -> OAuthServerMetadata { + let baseURL = issuer.hasSuffix("/") ? String(issuer.dropLast()) : issuer + + // Build list of supported grant types based on configuration + var supportedGrantTypes = [ + OAuthFlowType.clientCredentials.rawValue, + OAuthFlowType.refresh.rawValue + ] + + if hasCodeManager { + supportedGrantTypes.append(OAuthFlowType.authorization.rawValue) + } + + if hasDeviceCodeManager { + supportedGrantTypes.append(OAuthFlowType.deviceCode.rawValue) + } + + if hasUserManager { + supportedGrantTypes.append(OAuthFlowType.password.rawValue) + } + + // Configure supported response types per OAuth 2.0 spec + var responseTypes = ["code"] + if hasCodeManager { + responseTypes.append("token") + } + + return OAuthServerMetadata( + // Required metadata fields per RFC 8414 + issuer: issuer, + authorizationEndpoint: "\(baseURL)/oauth/authorize", + tokenEndpoint: "\(baseURL)/oauth/token", + jwksUri: jwksEndpoint, + responseTypesSupported: responseTypes, + subjectTypesSupported: ["public"], + idTokenSigningAlgValuesSupported: ["RS256"], + + // Recommended metadata fields + scopesSupported: validScopes, + tokenEndpointAuthMethodsSupported: ["client_secret_basic", "client_secret_post"], + grantTypesSupported: supportedGrantTypes, + userinfoEndpoint: nil, + registrationEndpoint: nil, + claimsSupported: nil, + + // Optional metadata fields + tokenIntrospectionEndpoint: hasTokenIntrospection ? "\(baseURL)/oauth/token_info" : nil, + tokenRevocationEndpoint: "\(baseURL)/oauth/revoke", + serviceDocumentation: nil, + uiLocalesSupported: nil, + opPolicyUri: nil, + opTosUri: nil, + revocationEndpointAuthMethodsSupported: ["client_secret_basic", "client_secret_post"], + revocationEndpointAuthSigningAlgValuesSupported: nil, + introspectionEndpointAuthMethodsSupported: hasTokenIntrospection ? ["client_secret_basic"] : nil, + introspectionEndpointAuthSigningAlgValuesSupported: nil, + codeChallengeMethodsSupported: hasCodeManager ? ["S256", "plain"] : nil, + deviceAuthorizationEndpoint: hasDeviceCodeManager ? "\(baseURL)/oauth/device_authorization" : nil + ) + } +} \ No newline at end of file diff --git a/Sources/VaporOAuth/Models/OAuthServerMetadata.swift b/Sources/VaporOAuth/Models/OAuthServerMetadata.swift new file mode 100644 index 0000000..ab9c4c9 --- /dev/null +++ b/Sources/VaporOAuth/Models/OAuthServerMetadata.swift @@ -0,0 +1,64 @@ +import Vapor + +/// RFC 8414 compliant Authorization Server Metadata +/// https://datatracker.ietf.org/doc/html/rfc8414#section-2 +public struct OAuthServerMetadata: Content, Sendable { + // Required fields per RFC 8414 + let issuer: String + let authorizationEndpoint: String + let tokenEndpoint: String + let jwksUri: String + let responseTypesSupported: [String] + let subjectTypesSupported: [String] + let idTokenSigningAlgValuesSupported: [String] + + // Recommended fields + let scopesSupported: [String]? + let tokenEndpointAuthMethodsSupported: [String]? + let grantTypesSupported: [String]? + let userinfoEndpoint: String? + let registrationEndpoint: String? + let claimsSupported: [String]? + + // Optional fields + let tokenIntrospectionEndpoint: String? + let tokenRevocationEndpoint: String? + let serviceDocumentation: String? + let uiLocalesSupported: [String]? + let opPolicyUri: String? + let opTosUri: String? + let revocationEndpointAuthMethodsSupported: [String]? + let revocationEndpointAuthSigningAlgValuesSupported: [String]? + let introspectionEndpointAuthMethodsSupported: [String]? + let introspectionEndpointAuthSigningAlgValuesSupported: [String]? + let codeChallengeMethodsSupported: [String]? + let deviceAuthorizationEndpoint: String? + + enum CodingKeys: String, CodingKey { + case issuer + case authorizationEndpoint = "authorization_endpoint" + case tokenEndpoint = "token_endpoint" + case jwksUri = "jwks_uri" + case responseTypesSupported = "response_types_supported" + case subjectTypesSupported = "subject_types_supported" + case idTokenSigningAlgValuesSupported = "id_token_signing_alg_values_supported" + case scopesSupported = "scopes_supported" + case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" + case grantTypesSupported = "grant_types_supported" + case userinfoEndpoint = "userinfo_endpoint" + case registrationEndpoint = "registration_endpoint" + case claimsSupported = "claims_supported" + case tokenIntrospectionEndpoint = "introspection_endpoint" + case tokenRevocationEndpoint = "revocation_endpoint" + case serviceDocumentation = "service_documentation" + case uiLocalesSupported = "ui_locales_supported" + case opPolicyUri = "op_policy_uri" + case opTosUri = "op_tos_uri" + case revocationEndpointAuthMethodsSupported = "revocation_endpoint_auth_methods_supported" + case revocationEndpointAuthSigningAlgValuesSupported = "revocation_endpoint_auth_signing_alg_values_supported" + case introspectionEndpointAuthMethodsSupported = "introspection_endpoint_auth_methods_supported" + case introspectionEndpointAuthSigningAlgValuesSupported = "introspection_endpoint_auth_signing_alg_values_supported" + case codeChallengeMethodsSupported = "code_challenge_methods_supported" + case deviceAuthorizationEndpoint = "device_authorization_endpoint" + } +} \ No newline at end of file diff --git a/Sources/VaporOAuth/OAuth2.swift b/Sources/VaporOAuth/OAuth2.swift index e41a92f..b66049a 100644 --- a/Sources/VaporOAuth/OAuth2.swift +++ b/Sources/VaporOAuth/OAuth2.swift @@ -10,8 +10,11 @@ public struct OAuth2: LifecycleHandler { let validScopes: [String]? let resourceServerRetriever: ResourceServerRetriever let oAuthHelper: OAuthHelper + let metadataProvider: ServerMetadataProvider public init( + issuer: String, + jwksEndpoint: String? = nil, codeManager: CodeManager = EmptyCodeManager(), tokenManager: TokenManager, deviceCodeManager: DeviceCodeManager = EmptyDeviceCodeManager(), @@ -20,13 +23,24 @@ public struct OAuth2: LifecycleHandler { userManager: UserManager = EmptyUserManager(), validScopes: [String]? = nil, resourceServerRetriever: ResourceServerRetriever = EmptyResourceServerRetriever(), - oAuthHelper: OAuthHelper + oAuthHelper: OAuthHelper, + metadataProvider: ServerMetadataProvider? = nil ) { + self.metadataProvider = metadataProvider ?? DefaultServerMetadataProvider( + issuer: issuer, + validScopes: validScopes, + clientRetriever: clientRetriever, + hasCodeManager: !(codeManager is EmptyCodeManager), + hasDeviceCodeManager: !(deviceCodeManager is EmptyDeviceCodeManager), + hasTokenIntrospection: !(resourceServerRetriever is EmptyResourceServerRetriever), + hasUserManager: !(userManager is EmptyUserManager), + jwksEndpoint: jwksEndpoint + ) self.codeManager = codeManager - self.clientRetriever = clientRetriever - self.authorizeHandler = authorizeHandler self.tokenManager = tokenManager self.deviceCodeManager = deviceCodeManager + self.clientRetriever = clientRetriever + self.authorizeHandler = authorizeHandler self.userManager = userManager self.validScopes = validScopes self.resourceServerRetriever = resourceServerRetriever @@ -84,6 +98,8 @@ public struct OAuth2: LifecycleHandler { tokenManager: tokenManager ) + let metadataHandler = MetadataHandler(metadataProvider: metadataProvider) + let resourceServerAuthenticator = ResourceServerAuthenticator(resourceServerRetriever: resourceServerRetriever) // returning something like "Authenticate with GitHub page" @@ -99,6 +115,8 @@ public struct OAuth2: LifecycleHandler { // Revoke a token app.post("oauth", "revoke", use: tokenRevocationHandler.handleRequest) + // RFC 8414 required endpoints + app.get(".well-known", "oauth-authorization-server", use: metadataHandler.handleRequest) let tokenIntrospectionAuthMiddleware = TokenIntrospectionAuthMiddleware(resourceServerAuthenticator: resourceServerAuthenticator) let resourceServerProtected = app.routes.grouped(tokenIntrospectionAuthMiddleware) diff --git a/Sources/VaporOAuth/Protocols/ServerMetadataProvider.swift b/Sources/VaporOAuth/Protocols/ServerMetadataProvider.swift new file mode 100644 index 0000000..92b99fb --- /dev/null +++ b/Sources/VaporOAuth/Protocols/ServerMetadataProvider.swift @@ -0,0 +1,5 @@ +import Vapor + +public protocol ServerMetadataProvider: Sendable { + func getMetadata() async throws -> OAuthServerMetadata +} \ No newline at end of file diff --git a/Sources/VaporOAuth/RouteHandlers/MetadataHandler.swift b/Sources/VaporOAuth/RouteHandlers/MetadataHandler.swift new file mode 100644 index 0000000..942fbd5 --- /dev/null +++ b/Sources/VaporOAuth/RouteHandlers/MetadataHandler.swift @@ -0,0 +1,53 @@ +import Vapor +import NIOHTTP1 + +struct MetadataHandler: Sendable { + let metadataProvider: ServerMetadataProvider + + @Sendable + func handleRequest(request: Request) async throws -> Response { + let metadata = try await metadataProvider.getMetadata() + return try createMetadataResponse(metadata: metadata) + } + + private func createMetadataResponse(metadata: OAuthServerMetadata) throws -> Response { + let response = Response(status: .ok) + try response.content.encode(metadata) + + // Set required headers per RFC 8414 Section 3 + response.headers.contentType = .json + // Set all cache control directives explicitly + response.headers.replaceOrAdd( + name: .cacheControl, + value: "no-store, no-cache, max-age=0, must-revalidate" + ) + response.headers.replaceOrAdd(name: .pragma, value: "no-cache") + + return response + } + + private func createErrorResponse( + status: HTTPStatus, + errorMessage: String, + errorDescription: String + ) throws -> Response { + let response = Response(status: status) + try response.content.encode(ErrorResponse( + error: errorMessage, + errorDescription: errorDescription + )) + return response + } +} + +extension MetadataHandler { + struct ErrorResponse: Content { + let error: String + let errorDescription: String + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + } + } +} diff --git a/Tests/VaporOAuthTests/Fakes/FakeAuthorizationHandler.swift b/Tests/VaporOAuthTests/Fakes/FakeAuthorizationHandler.swift new file mode 100644 index 0000000..78fb60c --- /dev/null +++ b/Tests/VaporOAuthTests/Fakes/FakeAuthorizationHandler.swift @@ -0,0 +1,27 @@ +import Vapor +@testable import VaporOAuth + +final class FakeAuthorizationHandler: AuthorizeHandler, @unchecked Sendable { + var capturedRequest: Request? + var capturedAuthorizationRequestObject: AuthorizationRequestObject? + var shouldAuthorize: Bool + + init(shouldAuthorize: Bool = true) { + self.shouldAuthorize = shouldAuthorize + } + + func handleAuthorizationRequest( + _ request: Request, + authorizationRequestObject: AuthorizationRequestObject + ) async throws -> Response { + capturedRequest = request + capturedAuthorizationRequestObject = authorizationRequestObject + + let response = Response(status: shouldAuthorize ? .ok : .unauthorized) + return response + } + + func handleAuthorizationError(_ errorType: AuthorizationError) async throws -> Response { + return Response(status: .badRequest) + } +} diff --git a/Tests/VaporOAuthTests/Helpers/TestDataBuilder.swift b/Tests/VaporOAuthTests/Helpers/TestDataBuilder.swift index 7faadfb..5f6a391 100644 --- a/Tests/VaporOAuthTests/Helpers/TestDataBuilder.swift +++ b/Tests/VaporOAuthTests/Helpers/TestDataBuilder.swift @@ -33,9 +33,11 @@ class TestDataBuilder { app.oauth = OAuthConfiguration(deviceVerificationURI: "") } + let issuer = "https://auth.example.com" + app.lifecycle.use( OAuth2( - codeManager: codeManager, + issuer: issuer, codeManager: codeManager, tokenManager: tokenManager, deviceCodeManager: deviceCodeManager, clientRetriever: clientRetriever, diff --git a/Tests/VaporOAuthTests/IntegrationTests/AuthCodeResourceServerTests.swift b/Tests/VaporOAuthTests/IntegrationTests/AuthCodeResourceServerTests.swift index a9c6610..a709c9d 100644 --- a/Tests/VaporOAuthTests/IntegrationTests/AuthCodeResourceServerTests.swift +++ b/Tests/VaporOAuthTests/IntegrationTests/AuthCodeResourceServerTests.swift @@ -15,6 +15,7 @@ class AuthCodeResourceServerTests: XCTestCase { let userID = "user-id" let username = "han" let email = "han.solo@therebelalliance.com" + let issuer = "https://auth.example.com" var newUser: OAuthUser! var resourceApp: Application! @@ -42,7 +43,7 @@ class AuthCodeResourceServerTests: XCTestCase { fakeUserManager.users.append(newUser) let oauthProvider = OAuth2( - codeManager: fakeCodeManager, + issuer: issuer, codeManager: fakeCodeManager, tokenManager: fakeTokenManager, clientRetriever: clientRetriever, authorizeHandler: capturingAuthouriseHandler, diff --git a/Tests/VaporOAuthTests/MetadataTests/MetadataEndpointTests.swift b/Tests/VaporOAuthTests/MetadataTests/MetadataEndpointTests.swift new file mode 100644 index 0000000..33bc835 --- /dev/null +++ b/Tests/VaporOAuthTests/MetadataTests/MetadataEndpointTests.swift @@ -0,0 +1,215 @@ +import XCTVapor +@testable import VaporOAuth + +class MetadataEndpointTests: XCTestCase { + var app: Application! + let issuer = "https://auth.example.com" + let jwksEndpoint = "https://auth.example.com/.well-known/jwks.json" + + override func setUp() async throws { + app = try await Application.make(.testing) + } + + override func tearDown() async throws { + try await app.asyncShutdown() + try await super.tearDown() + } + + // MARK: - RFC Compliance Tests + + func testRequiredRFCFields() async throws { + let oauthProvider = OAuth2( + issuer: issuer, + jwksEndpoint: jwksEndpoint, + tokenManager: FakeTokenManager(), + clientRetriever: StaticClientRetriever(clients: []), + oAuthHelper: .local( + tokenAuthenticator: TokenAuthenticator(), + userManager: FakeUserManager(), + tokenManager: FakeTokenManager() + ) + ) + + app.lifecycle.use(oauthProvider) + + try app.test(.GET, ".well-known/oauth-authorization-server") { response in + XCTAssertEqual(response.status, .ok) + XCTAssertEqual(response.headers.contentType, .json) + + let metadata = try response.content.decode(OAuthServerMetadata.self) + + // Required fields per RFC 8414 + XCTAssertEqual(metadata.issuer, issuer) + XCTAssertEqual(metadata.authorizationEndpoint, "\(issuer)/oauth/authorize") + XCTAssertEqual(metadata.tokenEndpoint, "\(issuer)/oauth/token") + XCTAssertEqual(metadata.jwksUri, jwksEndpoint) + XCTAssertFalse(metadata.responseTypesSupported.isEmpty) + XCTAssertFalse(metadata.subjectTypesSupported.isEmpty) + XCTAssertFalse(metadata.idTokenSigningAlgValuesSupported.isEmpty) + } + } + + func testFullConfigurationWithAllFeatures() async throws { + let validScopes = ["profile", "email"] + + let oauthProvider = OAuth2( + issuer: issuer, + jwksEndpoint: jwksEndpoint, + codeManager: FakeCodeManager(), + tokenManager: FakeTokenManager(), + deviceCodeManager: FakeDeviceCodeManager(), + clientRetriever: StaticClientRetriever(clients: []), + authorizeHandler: FakeAuthorizationHandler(), + userManager: FakeUserManager(), + validScopes: validScopes, + resourceServerRetriever: FakeResourceServerRetriever(), + oAuthHelper: .local( + tokenAuthenticator: TokenAuthenticator(), + userManager: FakeUserManager(), + tokenManager: FakeTokenManager() + ) + ) + + app.lifecycle.use(oauthProvider) + + try app.test(.GET, ".well-known/oauth-authorization-server") { response in + let metadata = try response.content.decode(OAuthServerMetadata.self) + + // Verify all supported features are included + XCTAssertEqual(Set(metadata.grantTypesSupported ?? []), [ + OAuthFlowType.authorization.rawValue, + OAuthFlowType.clientCredentials.rawValue, + OAuthFlowType.deviceCode.rawValue, + OAuthFlowType.refresh.rawValue, + OAuthFlowType.password.rawValue + ]) + + XCTAssertEqual(metadata.scopesSupported, validScopes) + XCTAssertEqual(Set(metadata.responseTypesSupported), ["code", "token"]) + XCTAssertEqual(metadata.codeChallengeMethodsSupported, ["S256", "plain"]) + + // Verify all endpoints are present + XCTAssertEqual(metadata.tokenIntrospectionEndpoint, "\(issuer)/oauth/token_info") + XCTAssertEqual(metadata.tokenRevocationEndpoint, "\(issuer)/oauth/revoke") + XCTAssertEqual(metadata.deviceAuthorizationEndpoint, "\(issuer)/oauth/device_authorization") + } + } + + // MARK: - Custom Override Tests + + func testCustomMetadataProvider() async throws { + // Custom metadata provider with non-standard endpoints and configurations + struct CustomMetadataProvider: ServerMetadataProvider { + func getMetadata() async throws -> OAuthServerMetadata { + return OAuthServerMetadata( + issuer: "https://custom.example.com", + authorizationEndpoint: "https://auth.custom.example.com/v2/authorize", + tokenEndpoint: "https://api.custom.example.com/v2/token", + jwksUri: "https://keys.custom.example.com/v2/jwks", + responseTypesSupported: ["code", "jwt"], + subjectTypesSupported: ["pairwise"], + idTokenSigningAlgValuesSupported: ["ES256", "PS256"], + + // Recommended fields with custom values + scopesSupported: ["custom.read", "custom.write"], + tokenEndpointAuthMethodsSupported: ["private_key_jwt", "client_secret_jwt"], + grantTypesSupported: ["authorization_code", "custom_grant"], + userinfoEndpoint: "https://api.custom.example.com/v2/userinfo", + registrationEndpoint: "https://api.custom.example.com/v2/register", + claimsSupported: ["sub", "custom_claim"], + + // Optional fields with custom configurations + tokenIntrospectionEndpoint: "https://api.custom.example.com/v2/introspect", + tokenRevocationEndpoint: "https://api.custom.example.com/v2/revoke", + serviceDocumentation: "https://docs.custom.example.com", + uiLocalesSupported: ["en-US", "es-ES"], + opPolicyUri: "https://custom.example.com/policy", + opTosUri: "https://custom.example.com/terms", + revocationEndpointAuthMethodsSupported: ["private_key_jwt"], + revocationEndpointAuthSigningAlgValuesSupported: ["ES256"], + introspectionEndpointAuthMethodsSupported: ["private_key_jwt"], + introspectionEndpointAuthSigningAlgValuesSupported: ["ES256"], + codeChallengeMethodsSupported: ["S256"], + deviceAuthorizationEndpoint: "https://api.custom.example.com/v2/device" + ) + } + } + + let oauthProvider = OAuth2( + issuer: issuer, + jwksEndpoint: jwksEndpoint, + tokenManager: FakeTokenManager(), + clientRetriever: StaticClientRetriever(clients: []), + oAuthHelper: .local( + tokenAuthenticator: TokenAuthenticator(), + userManager: FakeUserManager(), + tokenManager: FakeTokenManager() + ), + metadataProvider: CustomMetadataProvider() + ) + + app.lifecycle.use(oauthProvider) + + try app.test(.GET, ".well-known/oauth-authorization-server") { response in + XCTAssertEqual(response.status, .ok) + + let metadata = try response.content.decode(OAuthServerMetadata.self) + + // Verify custom issuer and endpoints + XCTAssertEqual(metadata.issuer, "https://custom.example.com") + XCTAssertEqual(metadata.authorizationEndpoint, "https://auth.custom.example.com/v2/authorize") + XCTAssertEqual(metadata.tokenEndpoint, "https://api.custom.example.com/v2/token") + XCTAssertEqual(metadata.jwksUri, "https://keys.custom.example.com/v2/jwks") + + // Verify custom response types and subject types + XCTAssertEqual(Set(metadata.responseTypesSupported), ["code", "jwt"]) + XCTAssertEqual(metadata.subjectTypesSupported, ["pairwise"]) + + // Verify custom scopes and grant types + XCTAssertEqual(metadata.scopesSupported, ["custom.read", "custom.write"]) + XCTAssertEqual(Set(metadata.grantTypesSupported ?? []), ["authorization_code", "custom_grant"]) + + // Verify custom endpoints + XCTAssertEqual(metadata.userinfoEndpoint, "https://api.custom.example.com/v2/userinfo") + XCTAssertEqual(metadata.registrationEndpoint, "https://api.custom.example.com/v2/register") + + // Verify custom auth methods + XCTAssertEqual(Set(metadata.tokenEndpointAuthMethodsSupported ?? []), ["private_key_jwt", "client_secret_jwt"]) + + // Verify additional custom fields + XCTAssertEqual(metadata.serviceDocumentation, "https://docs.custom.example.com") + XCTAssertEqual(metadata.uiLocalesSupported, ["en-US", "es-ES"]) + } + } + // MARK: - Error Cases + func testMetadataEndpointRequiredHeaders() async throws { + let oauthProvider = OAuth2( + issuer: issuer, + jwksEndpoint: jwksEndpoint, + tokenManager: FakeTokenManager(), + clientRetriever: StaticClientRetriever(clients: []), + oAuthHelper: .local( + tokenAuthenticator: TokenAuthenticator(), + userManager: FakeUserManager(), + tokenManager: FakeTokenManager() + ) + ) + + app.lifecycle.use(oauthProvider) + + try app.test(.GET, ".well-known/oauth-authorization-server") { response in + // Verify content type + XCTAssertEqual(response.headers.contentType, .json) + + // Verify cache control headers + let cacheControl = response.headers[.cacheControl].first + XCTAssertNotNil(cacheControl) + XCTAssertTrue(cacheControl?.contains("no-store") ?? false) + XCTAssertTrue(cacheControl?.contains("no-cache") ?? false) + XCTAssertTrue(cacheControl?.contains("must-revalidate") ?? false) + + // Verify pragma header + XCTAssertEqual(response.headers[.pragma].first, "no-cache") + } + } +}