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") + } + } +}