Skip to content

Commit

Permalink
Implement RFC 8414 compliant Authorization Server Metadata endpoint (#12
Browse files Browse the repository at this point in the history
)

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
  • Loading branch information
vamsii777 authored Oct 29, 2024
1 parent 90777b9 commit 7f40245
Show file tree
Hide file tree
Showing 10 changed files with 500 additions and 6 deletions.
5 changes: 4 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
}
}
64 changes: 64 additions & 0 deletions Sources/VaporOAuth/Models/OAuthServerMetadata.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
24 changes: 21 additions & 3 deletions Sources/VaporOAuth/OAuth2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions Sources/VaporOAuth/Protocols/ServerMetadataProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Vapor

public protocol ServerMetadataProvider: Sendable {
func getMetadata() async throws -> OAuthServerMetadata
}
53 changes: 53 additions & 0 deletions Sources/VaporOAuth/RouteHandlers/MetadataHandler.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
27 changes: 27 additions & 0 deletions Tests/VaporOAuthTests/Fakes/FakeAuthorizationHandler.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 3 additions & 1 deletion Tests/VaporOAuthTests/Helpers/TestDataBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 7f40245

Please sign in to comment.