Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC 8414 OAuth 2.0 Authorization Server Metadata Support #12

Merged
merged 1 commit into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading