* feat: improvement to oauth resources resolution * feat: improvements to refreshToken procedure and updates to unit tests * feat: improvements to documentation of clien_id
873 lines
36 KiB
Swift
873 lines
36 KiB
Swift
import Foundation
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
#if canImport(CryptoKit)
|
|
import CryptoKit
|
|
#endif
|
|
|
|
// MARK: - HTTPClientAuthorizer Protocol
|
|
|
|
/// Abstraction used by ``HTTPClientTransport`` to handle OAuth authorization challenges.
|
|
///
|
|
/// Implement this protocol to provide custom token acquisition strategies,
|
|
/// or use the built-in ``OAuthAuthorizer`` for a full OAuth 2.1 implementation.
|
|
///
|
|
/// ``HTTPClientTransport`` calls these methods automatically when it receives
|
|
/// `401 Unauthorized` or `403 Forbidden` responses from the server.
|
|
public protocol HTTPClientAuthorizer: AnyObject, Sendable {
|
|
/// The maximum number of authorization retries permitted for a single request.
|
|
///
|
|
/// ``HTTPClientTransport`` will not call ``handleChallenge(statusCode:headers:endpoint:operationKey:session:)``
|
|
/// more than this many times for a single outgoing request.
|
|
var maxAuthorizationAttempts: Int { get }
|
|
|
|
/// Validates that the MCP endpoint URL satisfies the security requirements for OAuth.
|
|
///
|
|
/// Called once before the first request is sent. Throw ``OAuthAuthorizationError/insecureOAuthEndpoint(context:url:)``
|
|
/// if the URL does not meet the requirements (e.g., non-HTTPS non-loopback).
|
|
/// - Parameter endpoint: The MCP endpoint URL to validate.
|
|
func validateEndpointSecurity(for endpoint: URL) throws
|
|
|
|
/// Returns the `Authorization` header value to attach to the next request, if a valid token is available.
|
|
///
|
|
/// - Parameter endpoint: The MCP endpoint being requested.
|
|
/// - Returns: A `"Bearer <token>"` string, or `nil` if no valid token is cached.
|
|
func authorizationHeader(for endpoint: URL) -> String?
|
|
|
|
/// Handles an authorization challenge received from the server and attempts to acquire a new token.
|
|
///
|
|
/// Called by ``HTTPClientTransport`` when a `401` or `403` response is received.
|
|
/// The implementation should attempt to obtain a valid access token and store it
|
|
/// so that a subsequent call to ``authorizationHeader(for:)`` returns the new value.
|
|
///
|
|
/// - Parameters:
|
|
/// - statusCode: The HTTP status code (401 or 403).
|
|
/// - headers: All response headers from the challenge response.
|
|
/// - endpoint: The MCP endpoint that returned the challenge.
|
|
/// - operationKey: An optional identifier for the MCP operation (e.g., the JSON-RPC method),
|
|
/// used to track step-up attempts per operation.
|
|
/// - session: The `URLSession` to use for discovery and token requests.
|
|
/// - Returns: `true` if a new token was acquired and the original request should be retried;
|
|
/// `false` if the challenge cannot be handled.
|
|
func handleChallenge(
|
|
statusCode: Int,
|
|
headers: [String: String],
|
|
endpoint: URL,
|
|
operationKey: String?,
|
|
session: URLSession
|
|
) async throws -> Bool
|
|
|
|
/// Proactively refreshes the access token if it is close to expiry.
|
|
///
|
|
/// Called by ``HTTPClientTransport`` before sending each request and before opening
|
|
/// an SSE stream, allowing the token to be silently renewed without a 401 round-trip.
|
|
/// Implementations should swallow refresh errors — if refresh fails, the normal
|
|
/// ``handleChallenge(statusCode:headers:endpoint:operationKey:session:)`` path recovers.
|
|
///
|
|
/// - Parameters:
|
|
/// - endpoint: The MCP endpoint about to be contacted.
|
|
/// - session: The `URLSession` to use for token refresh requests.
|
|
func prepareAuthorization(for endpoint: URL, session: URLSession) async throws
|
|
}
|
|
|
|
extension HTTPClientAuthorizer {
|
|
public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {}
|
|
}
|
|
|
|
// MARK: - OAuthAuthorizer
|
|
|
|
/// Full OAuth 2.1 implementation of ``HTTPClientAuthorizer``.
|
|
///
|
|
/// `OAuthAuthorizer` orchestrates the complete MCP authorization flow on behalf of an HTTP client:
|
|
///
|
|
/// 1. **Protected Resource Metadata discovery** (RFC 9728) — fetches
|
|
/// `/.well-known/oauth-protected-resource` to locate the authorization server.
|
|
/// 2. **Authorization Server Metadata discovery** (RFC 8414 / OIDC Discovery 1.0) — fetches
|
|
/// `/.well-known/oauth-authorization-server` or `/.well-known/openid-configuration`.
|
|
/// 3. **Dynamic Client Registration** (RFC 7591) — registers the client if no credentials are
|
|
/// pre-configured and the AS advertises a registration endpoint.
|
|
/// 4. **Token acquisition** — performs the configured grant flow (`authorization_code` with PKCE,
|
|
/// or `client_credentials`), binding tokens to the resource indicator (RFC 8707).
|
|
/// 5. **Token refresh** — attempts a `refresh_token` grant before a full re-authorization.
|
|
/// 6. **Scope step-up** — handles `403 insufficient_scope` challenges by re-requesting with
|
|
/// the union of existing and required scopes.
|
|
///
|
|
/// Pass an instance to `HTTPClientTransport(authorizer:)` to enable automatic authorization:
|
|
///
|
|
/// ```swift
|
|
/// let config = OAuthConfiguration(
|
|
/// grantType: .clientCredentials,
|
|
/// authentication: .clientSecretBasic(clientID: "my-app", clientSecret: "s3cr3t")
|
|
/// )
|
|
/// let authorizer = OAuthAuthorizer(configuration: config)
|
|
/// let transport = HTTPClientTransport(endpoint: serverURL, authorizer: authorizer)
|
|
/// ```
|
|
///
|
|
/// - Important: This type is `@unchecked Sendable`. All mutable state is accessed
|
|
/// exclusively through the `HTTPClientTransport` actor, which serializes every call.
|
|
/// Do **not** share a single `OAuthAuthorizer` instance across multiple transports —
|
|
/// doing so would violate the isolation contract and risk concurrent mutation.
|
|
public final class OAuthAuthorizer: HTTPClientAuthorizer, @unchecked Sendable {
|
|
|
|
// MARK: - Mutable State
|
|
|
|
private var configuration: OAuthConfiguration
|
|
private let tokenStorage: TokenStorage
|
|
private var selectedAuthorizationServer: URL?
|
|
private var protectedResourceMetadata: OAuthProtectedResourceMetadata?
|
|
private var authorizationServerMetadata: OAuthAuthorizationServerMetadata?
|
|
private var cachedProtectedResourceMetadataURL: URL?
|
|
private var stepUpAttempts: [String: Int] = [:]
|
|
private var clientRegistrationAttempted = false
|
|
private var clientSecretExpiresAt: Date?
|
|
|
|
// MARK: - Composable Dependencies
|
|
|
|
private let scopeSelector: any OAuthScopeSelecting
|
|
private let challengeParser: any OAuthWWWAuthenticateParsing
|
|
private let urlValidator: any OAuthURLValidating
|
|
private let discoveryClient: any OAuthDiscoveryFetching
|
|
private let tokenEndpointClient: any OAuthTokenRequesting
|
|
private let clientRegistrar: any OAuthClientRegistering
|
|
private let authCodeFlow: any OAuthAuthorizationCodeFlowing
|
|
|
|
/// Creates an `OAuthAuthorizer` with the given configuration and optional injectable dependencies.
|
|
///
|
|
/// - Parameters:
|
|
/// - configuration: OAuth 2.1 configuration controlling the grant type, authentication method,
|
|
/// endpoint discovery overrides, and retry policy.
|
|
/// - tokenStorage: Stores acquired access tokens. Defaults to ``InMemoryTokenStorage``,
|
|
/// which loses tokens when the process exits. Supply a Keychain-backed implementation
|
|
/// to persist tokens across sessions.
|
|
/// - scopeSelector: Strategy for selecting OAuth scopes from challenge and metadata hints.
|
|
/// Defaults to ``DefaultOAuthScopeSelector``.
|
|
/// - challengeParser: Parses `WWW-Authenticate: Bearer` challenge headers.
|
|
/// Defaults to ``DefaultOAuthWWWAuthenticateParser``.
|
|
/// - metadataDiscovery: Constructs well-known discovery URLs and validates resource URI matching.
|
|
/// Defaults to ``DefaultOAuthMetadataDiscovery``.
|
|
public convenience init(
|
|
configuration: OAuthConfiguration,
|
|
tokenStorage: TokenStorage = InMemoryTokenStorage(),
|
|
scopeSelector: any OAuthScopeSelecting = DefaultOAuthScopeSelector(),
|
|
challengeParser: any OAuthWWWAuthenticateParsing = DefaultOAuthWWWAuthenticateParser(),
|
|
metadataDiscovery: any OAuthMetadataDiscovering = DefaultOAuthMetadataDiscovery()
|
|
) {
|
|
let urlValidator = OAuthURLValidator(
|
|
allowLoopbackHTTPForAuthorizationServer:
|
|
configuration.allowLoopbackHTTPAuthorizationServerEndpoints
|
|
)
|
|
self.init(
|
|
configuration: configuration,
|
|
tokenStorage: tokenStorage,
|
|
scopeSelector: scopeSelector,
|
|
challengeParser: challengeParser,
|
|
urlValidator: urlValidator,
|
|
discoveryClient: OAuthDiscoveryClient(
|
|
metadataDiscovery: metadataDiscovery,
|
|
urlValidator: urlValidator
|
|
),
|
|
tokenEndpointClient: OAuthTokenEndpointClient(urlValidator: urlValidator),
|
|
clientRegistrar: OAuthClientRegistrar(urlValidator: urlValidator),
|
|
authCodeFlow: OAuthAuthorizationCodeFlow()
|
|
)
|
|
}
|
|
|
|
init(
|
|
configuration: OAuthConfiguration,
|
|
tokenStorage: TokenStorage = InMemoryTokenStorage(),
|
|
scopeSelector: any OAuthScopeSelecting = DefaultOAuthScopeSelector(),
|
|
challengeParser: any OAuthWWWAuthenticateParsing = DefaultOAuthWWWAuthenticateParser(),
|
|
urlValidator: any OAuthURLValidating,
|
|
discoveryClient: any OAuthDiscoveryFetching,
|
|
tokenEndpointClient: any OAuthTokenRequesting,
|
|
clientRegistrar: any OAuthClientRegistering,
|
|
authCodeFlow: any OAuthAuthorizationCodeFlowing
|
|
) {
|
|
self.configuration = configuration
|
|
self.tokenStorage = tokenStorage
|
|
self.scopeSelector = scopeSelector
|
|
self.challengeParser = challengeParser
|
|
self.urlValidator = urlValidator
|
|
self.discoveryClient = discoveryClient
|
|
self.tokenEndpointClient = tokenEndpointClient
|
|
self.clientRegistrar = clientRegistrar
|
|
self.authCodeFlow = authCodeFlow
|
|
}
|
|
|
|
// MARK: - HTTPClientAuthorizer
|
|
|
|
public var maxAuthorizationAttempts: Int {
|
|
configuration.retryPolicy.maxAuthorizationAttempts
|
|
}
|
|
|
|
public func validateEndpointSecurity(for endpoint: URL) throws {
|
|
try urlValidator.validateHTTPSOrLoopback(endpoint, context: "MCP endpoint")
|
|
}
|
|
|
|
public func authorizationHeader(for endpoint: URL) -> String? {
|
|
guard let accessToken = tokenStorage.load() else { return nil }
|
|
if let tokenAuthorizationServer = accessToken.authorizationServer,
|
|
let selectedAuthorizationServer,
|
|
!authorizationServersMatch(tokenAuthorizationServer, selectedAuthorizationServer)
|
|
{
|
|
tokenStorage.clear()
|
|
return nil
|
|
}
|
|
if accessToken.isExpired() {
|
|
return nil
|
|
}
|
|
return "\(OAuthTokenType.bearer) \(accessToken.value)"
|
|
}
|
|
|
|
public func handleChallenge(
|
|
statusCode: Int,
|
|
headers: [String: String],
|
|
endpoint: URL,
|
|
operationKey: String? = nil,
|
|
session: URLSession
|
|
) async throws -> Bool {
|
|
try validateEndpointSecurity(for: endpoint)
|
|
let challenge = challengeParser.parseBearer(from: headers)
|
|
|
|
switch statusCode {
|
|
case 401:
|
|
if let refreshToken = tokenStorage.load()?.refreshToken {
|
|
tokenStorage.clear()
|
|
let metadata = try await discoverProtectedResourceMetadata(
|
|
endpoint: endpoint,
|
|
challenge: challenge,
|
|
session: session
|
|
)
|
|
let asMetadata = try await resolveAuthorizationServerMetadata(
|
|
metadata: metadata,
|
|
session: session
|
|
)
|
|
let resource = try canonicalResource(for: endpoint)
|
|
let requestedScopes = scopeSelector.selectScopes(
|
|
challengeScope: challenge?.scope,
|
|
scopesSupported: metadata.scopesSupported
|
|
)
|
|
if try await refreshAccessToken(
|
|
refreshToken: refreshToken,
|
|
resource: resource,
|
|
requestedScopes: requestedScopes,
|
|
asMetadata: asMetadata,
|
|
session: session
|
|
) {
|
|
return true
|
|
}
|
|
} else {
|
|
tokenStorage.clear()
|
|
}
|
|
|
|
let metadata = try await discoverProtectedResourceMetadata(
|
|
endpoint: endpoint,
|
|
challenge: challenge,
|
|
session: session
|
|
)
|
|
let requestedScopes = scopeSelector.selectScopes(
|
|
challengeScope: challenge?.scope,
|
|
scopesSupported: metadata.scopesSupported
|
|
)
|
|
|
|
let providerContext = try await makeAccessTokenProviderContext(
|
|
statusCode: statusCode,
|
|
endpoint: endpoint,
|
|
challenge: challenge,
|
|
metadata: metadata,
|
|
requestedScopes: requestedScopes,
|
|
session: session
|
|
)
|
|
if let externalToken = try await fetchAccessTokenFromProvider(
|
|
context: providerContext,
|
|
session: session
|
|
) {
|
|
storeExternalAccessToken(
|
|
externalToken,
|
|
requestedScopes: providerContext.requestedScopes,
|
|
authorizationServer: providerContext.authorizationServer
|
|
)
|
|
return true
|
|
}
|
|
|
|
try await acquireToken(
|
|
endpoint: endpoint,
|
|
metadata: metadata,
|
|
requestedScopes: requestedScopes,
|
|
session: session
|
|
)
|
|
return true
|
|
|
|
case 403:
|
|
guard challenge?.error?.lowercased() == "insufficient_scope" else { return false }
|
|
|
|
let metadata = try await discoverProtectedResourceMetadata(
|
|
endpoint: endpoint,
|
|
challenge: challenge,
|
|
session: session
|
|
)
|
|
let requiredScopes =
|
|
scopeSelector.selectScopes(
|
|
challengeScope: challenge?.scope,
|
|
scopesSupported: metadata.scopesSupported
|
|
) ?? []
|
|
|
|
let existingScopes = tokenStorage.load()?.scopes ?? []
|
|
let upgradedScopes = existingScopes.union(requiredScopes)
|
|
let resourceKey = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
|
from: endpoint
|
|
).absoluteString
|
|
let operationAttemptKey = normalizedOperationKey(operationKey)
|
|
let attemptKey =
|
|
"\(resourceKey)|\(operationAttemptKey)|\(upgradedScopes.sorted().joined(separator: " "))"
|
|
let attempts = stepUpAttempts[attemptKey, default: 0]
|
|
guard attempts < configuration.retryPolicy.maxScopeUpgradeAttempts else {
|
|
return false
|
|
}
|
|
stepUpAttempts[attemptKey] = attempts + 1
|
|
|
|
let providerRequestedScopes = upgradedScopes.isEmpty ? nil : upgradedScopes
|
|
let providerContext = try await makeAccessTokenProviderContext(
|
|
statusCode: statusCode,
|
|
endpoint: endpoint,
|
|
challenge: challenge,
|
|
metadata: metadata,
|
|
requestedScopes: providerRequestedScopes,
|
|
session: session
|
|
)
|
|
if let externalToken = try await fetchAccessTokenFromProvider(
|
|
context: providerContext,
|
|
session: session
|
|
) {
|
|
storeExternalAccessToken(
|
|
externalToken,
|
|
requestedScopes: providerContext.requestedScopes,
|
|
authorizationServer: providerContext.authorizationServer
|
|
)
|
|
return true
|
|
}
|
|
|
|
try await acquireToken(
|
|
endpoint: endpoint,
|
|
metadata: metadata,
|
|
requestedScopes: upgradedScopes,
|
|
session: session
|
|
)
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public func prepareAuthorization(for endpoint: URL, session: URLSession) async throws {
|
|
guard configuration.proactiveRefreshWindowSeconds > 0 else { return }
|
|
guard let token = tokenStorage.load() else { return }
|
|
guard token.isExpired(skewSeconds: configuration.proactiveRefreshWindowSeconds) else {
|
|
return
|
|
}
|
|
guard let refreshToken = token.refreshToken else { return }
|
|
guard let asMeta = authorizationServerMetadata, asMeta.tokenEndpoint != nil else { return }
|
|
|
|
let resource: URL
|
|
do {
|
|
resource = try canonicalResource(for: endpoint)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
let requestedScopes = token.scopes.isEmpty ? nil : token.scopes
|
|
_ = try? await refreshAccessToken(
|
|
refreshToken: refreshToken,
|
|
resource: resource,
|
|
requestedScopes: requestedScopes,
|
|
asMetadata: asMeta,
|
|
session: session
|
|
)
|
|
}
|
|
|
|
// MARK: - Discovery
|
|
|
|
private func discoverProtectedResourceMetadata(
|
|
endpoint: URL,
|
|
challenge: OAuthBearerChallenge?,
|
|
session: URLSession
|
|
) async throws -> OAuthProtectedResourceMetadata {
|
|
if let protectedResourceMetadata {
|
|
let incomingURL = challenge?.resourceMetadataURL
|
|
if let incomingURL, incomingURL != cachedProtectedResourceMetadataURL {
|
|
self.protectedResourceMetadata = nil
|
|
self.authorizationServerMetadata = nil
|
|
self.selectedAuthorizationServer = nil
|
|
self.cachedProtectedResourceMetadataURL = nil
|
|
} else {
|
|
return protectedResourceMetadata
|
|
}
|
|
}
|
|
|
|
var candidates: [URL] = []
|
|
|
|
if let challengeURL = challenge?.resourceMetadataURL {
|
|
try urlValidator.validateHTTPSOrLoopback(
|
|
challengeURL, context: "Protected resource metadata URL")
|
|
if let host = URLComponents(url: challengeURL, resolvingAgainstBaseURL: false)?.host?
|
|
.lowercased(), urlValidator.isPrivateIPHost(host)
|
|
{
|
|
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
|
context: "Protected resource metadata URL",
|
|
url: challengeURL.absoluteString
|
|
)
|
|
}
|
|
candidates.append(challengeURL)
|
|
}
|
|
if let configuredURL = configuration.endpointOverrides.protectedResourceMetadataURL,
|
|
!candidates.contains(configuredURL)
|
|
{
|
|
try urlValidator.validateHTTPSOrLoopback(
|
|
configuredURL,
|
|
context: "Configured protected resource metadata URL"
|
|
)
|
|
candidates.append(configuredURL)
|
|
}
|
|
|
|
for fallback in discoveryClient.metadataDiscovery.protectedResourceMetadataURLs(
|
|
for: endpoint)
|
|
where !candidates.contains(fallback) {
|
|
candidates.append(fallback)
|
|
}
|
|
|
|
let fallbackIssuer = try? discoveryClient.metadataDiscovery
|
|
.authorizationServerFallbackIssuer(from: endpoint)
|
|
let metadata = try await discoveryClient.fetchProtectedResourceMetadata(
|
|
candidates: candidates, fallbackIssuer: fallbackIssuer, session: session)
|
|
try validateProtectedResource(metadata: metadata, endpoint: endpoint)
|
|
|
|
self.protectedResourceMetadata = metadata
|
|
self.cachedProtectedResourceMetadataURL = candidates.first
|
|
return metadata
|
|
}
|
|
|
|
private func validateProtectedResource(
|
|
metadata: OAuthProtectedResourceMetadata, endpoint: URL
|
|
) throws {
|
|
guard let resource = metadata.resource?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!resource.isEmpty
|
|
else {
|
|
return
|
|
}
|
|
|
|
guard let resourceURL = URL(string: resource) else {
|
|
throw OAuthAuthorizationError.invalidResourceURI(
|
|
"Protected resource metadata contains an invalid resource URI: \(resource)"
|
|
)
|
|
}
|
|
|
|
let expected = try discoveryClient.metadataDiscovery.canonicalResourceURI(from: endpoint)
|
|
let actual = try discoveryClient.metadataDiscovery.canonicalResourceURI(from: resourceURL)
|
|
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
|
resource: actual, endpoint: expected)
|
|
else {
|
|
throw OAuthAuthorizationError.protectedResourceMismatch(
|
|
expected: expected.absoluteString,
|
|
actual: actual.absoluteString
|
|
)
|
|
}
|
|
}
|
|
|
|
private func resolveAuthorizationServerMetadata(
|
|
metadata: OAuthProtectedResourceMetadata,
|
|
session: URLSession
|
|
) async throws -> OAuthAuthorizationServerMetadata {
|
|
if let cached = authorizationServerMetadata {
|
|
return cached
|
|
}
|
|
|
|
let candidates: [URL]
|
|
if let override = configuration.endpointOverrides.authorizationServerURL {
|
|
try urlValidator.validateAuthorizationServer(
|
|
override, context: "Authorization server issuer")
|
|
candidates = [override]
|
|
} else if let selected = selectedAuthorizationServer {
|
|
candidates = [selected]
|
|
} else {
|
|
guard !metadata.authorizationServers.isEmpty else {
|
|
throw OAuthAuthorizationError.missingAuthorizationServer
|
|
}
|
|
candidates = metadata.authorizationServers
|
|
}
|
|
|
|
let (server, asMetadata) = try await discoveryClient.fetchAuthorizationServerMetadata(
|
|
candidates: candidates, session: session)
|
|
self.selectedAuthorizationServer = server
|
|
self.authorizationServerMetadata = asMetadata
|
|
return asMetadata
|
|
}
|
|
|
|
// MARK: - Token Acquisition
|
|
|
|
private func acquireToken(
|
|
endpoint: URL,
|
|
metadata: OAuthProtectedResourceMetadata,
|
|
requestedScopes: Set<String>?,
|
|
session: URLSession
|
|
) async throws {
|
|
let asMetadata = try await resolveAuthorizationServerMetadata(
|
|
metadata: metadata, session: session)
|
|
try await maybeRegisterClient(asMetadata: asMetadata, session: session)
|
|
let resource = try canonicalResource(for: endpoint)
|
|
|
|
switch configuration.grantType {
|
|
case .clientCredentials:
|
|
try await acquireTokenViaClientCredentials(
|
|
resource: resource,
|
|
requestedScopes: requestedScopes,
|
|
asMetadata: asMetadata,
|
|
session: session
|
|
)
|
|
case .authorizationCode:
|
|
try await acquireTokenViaAuthorizationCode(
|
|
resource: resource,
|
|
requestedScopes: requestedScopes,
|
|
asMetadata: asMetadata,
|
|
session: session
|
|
)
|
|
}
|
|
}
|
|
|
|
private func acquireTokenViaClientCredentials(
|
|
resource: URL,
|
|
requestedScopes: Set<String>?,
|
|
asMetadata: OAuthAuthorizationServerMetadata,
|
|
session: URLSession
|
|
) async throws {
|
|
let tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
|
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
|
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.clientCredentials
|
|
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
|
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
|
bodyParameters[OAuthParameterName.scope] = scope
|
|
}
|
|
let decoded = try await tokenEndpointClient.request(
|
|
parameters: &bodyParameters,
|
|
endpoint: tokenEndpoint,
|
|
authentication: configuration.authentication,
|
|
session: session
|
|
)
|
|
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
|
}
|
|
|
|
private func acquireTokenViaAuthorizationCode(
|
|
resource: URL,
|
|
requestedScopes: Set<String>?,
|
|
asMetadata: OAuthAuthorizationServerMetadata,
|
|
session: URLSession
|
|
) async throws {
|
|
guard let authorizationEndpoint = asMetadata.authorizationEndpoint else {
|
|
throw OAuthAuthorizationError.tokenEndpointMissing
|
|
}
|
|
try urlValidator.validateAuthorizationServer(
|
|
authorizationEndpoint, context: "Authorization endpoint")
|
|
if let host = URLComponents(url: authorizationEndpoint, resolvingAgainstBaseURL: false)?
|
|
.host?.lowercased(), urlValidator.isPrivateIPHost(host)
|
|
{
|
|
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
|
context: "Authorization endpoint",
|
|
url: authorizationEndpoint.absoluteString
|
|
)
|
|
}
|
|
try urlValidator.validateRedirectURI(configuration.authorizationRedirectURI)
|
|
try PKCE.checkSupport(in: asMetadata)
|
|
|
|
let verifier = PKCE.makeVerifier()
|
|
let challenge = try PKCE.makeChallenge(from: verifier)
|
|
let state = UUID().uuidString
|
|
|
|
let authorizationURL = try authCodeFlow.buildURL(
|
|
authorizationEndpoint: authorizationEndpoint,
|
|
resource: resource,
|
|
redirectURI: configuration.authorizationRedirectURI,
|
|
clientID: configuration.authentication.clientID,
|
|
codeChallenge: challenge,
|
|
scopes: requestedScopes,
|
|
state: state,
|
|
scopeSerializer: scopeSelector
|
|
)
|
|
|
|
let authorizationCode = try await authCodeFlow.perform(
|
|
authorizationURL: authorizationURL,
|
|
redirectURI: configuration.authorizationRedirectURI,
|
|
state: state,
|
|
delegate: configuration.authorizationDelegate,
|
|
session: session
|
|
)
|
|
|
|
let tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
|
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
|
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.authorizationCode
|
|
bodyParameters[OAuthParameterName.code] = authorizationCode
|
|
bodyParameters[OAuthParameterName.codeVerifier] = verifier
|
|
bodyParameters[OAuthParameterName.redirectURI] =
|
|
configuration.authorizationRedirectURI.absoluteString
|
|
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
|
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
|
bodyParameters[OAuthParameterName.scope] = scope
|
|
}
|
|
|
|
let decoded = try await tokenEndpointClient.request(
|
|
parameters: &bodyParameters,
|
|
endpoint: tokenEndpoint,
|
|
authentication: configuration.authentication,
|
|
session: session
|
|
)
|
|
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
|
}
|
|
|
|
// MARK: - Token Refresh
|
|
|
|
private func refreshAccessToken(
|
|
refreshToken: String,
|
|
resource: URL,
|
|
requestedScopes: Set<String>?,
|
|
asMetadata: OAuthAuthorizationServerMetadata,
|
|
session: URLSession
|
|
) async throws -> Bool {
|
|
let tokenEndpoint: URL
|
|
do {
|
|
tokenEndpoint = try resolveTokenEndpoint(asMetadata: asMetadata)
|
|
} catch {
|
|
return false
|
|
}
|
|
|
|
var bodyParameters: [String: String] = configuration.additionalTokenRequestParameters
|
|
bodyParameters[OAuthParameterName.grantType] = OAuthGrantTypeValue.refreshToken
|
|
bodyParameters[OAuthParameterName.refreshToken] = refreshToken
|
|
bodyParameters[OAuthParameterName.resource] = resource.absoluteString
|
|
if let scope = requestedScopes.flatMap(scopeSelector.serialize) {
|
|
bodyParameters[OAuthParameterName.scope] = scope
|
|
}
|
|
|
|
do {
|
|
let decoded = try await tokenEndpointClient.request(
|
|
parameters: &bodyParameters,
|
|
endpoint: tokenEndpoint,
|
|
authentication: configuration.authentication,
|
|
session: session
|
|
)
|
|
storeTokenResponse(decoded, requestedScopes: requestedScopes)
|
|
return true
|
|
} catch let error as OAuthAuthorizationError {
|
|
if case .tokenRequestFailed(let statusCode, _) = error,
|
|
(400..<500).contains(statusCode)
|
|
{
|
|
return false
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - Client Registration
|
|
|
|
private func maybeRegisterClient(
|
|
asMetadata: OAuthAuthorizationServerMetadata,
|
|
session: URLSession
|
|
) async throws {
|
|
if let expiry = clientSecretExpiresAt, Date() >= expiry {
|
|
clientSecretExpiresAt = nil
|
|
clientRegistrationAttempted = false
|
|
configuration.authentication = .none(clientID: configuration.authentication.clientID)
|
|
}
|
|
|
|
guard !clientRegistrationAttempted else { return }
|
|
guard case .none = configuration.authentication else { return }
|
|
|
|
clientRegistrationAttempted = true
|
|
|
|
if let (registration, updatedAuth) = try await clientRegistrar.register(
|
|
configuration: configuration,
|
|
asMetadata: asMetadata,
|
|
session: session
|
|
) {
|
|
configuration.authentication = updatedAuth
|
|
if let expiresAt = registration.clientSecretExpiresAt, expiresAt > 0 {
|
|
clientSecretExpiresAt = Date(timeIntervalSince1970: Double(expiresAt))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - State Helpers
|
|
|
|
private func storeTokenResponse(
|
|
_ decoded: OAuthTokenResponse,
|
|
requestedScopes: Set<String>?
|
|
) {
|
|
let scopeSet: Set<String>
|
|
if let scope = decoded.scope {
|
|
scopeSet = scopeSelector.parseScopeString(scope)
|
|
} else {
|
|
scopeSet = requestedScopes ?? []
|
|
}
|
|
let expiresAt = decoded.expiresIn.map { Date().addingTimeInterval(TimeInterval($0)) }
|
|
tokenStorage.save(OAuthAccessToken(
|
|
value: decoded.accessToken,
|
|
tokenType: OAuthTokenType.bearer,
|
|
expiresAt: expiresAt,
|
|
scopes: scopeSet,
|
|
authorizationServer: selectedAuthorizationServer,
|
|
refreshToken: decoded.refreshToken,
|
|
clientID: nonEmptyClientID()
|
|
))
|
|
}
|
|
|
|
/// Returns the configured `client_id` or `nil` if the authorizer has not yet been assigned one.
|
|
private func nonEmptyClientID() -> String? {
|
|
let id = configuration.authentication.clientID
|
|
return id.isEmpty ? nil : id
|
|
}
|
|
|
|
private func resolveTokenEndpoint(
|
|
asMetadata: OAuthAuthorizationServerMetadata
|
|
) throws -> URL {
|
|
if let configuredEndpoint = configuration.endpointOverrides.tokenEndpoint {
|
|
try urlValidator.validateAuthorizationServer(
|
|
configuredEndpoint, context: "Configured token endpoint")
|
|
return configuredEndpoint
|
|
}
|
|
|
|
guard let tokenEndpoint = asMetadata.tokenEndpoint else {
|
|
throw OAuthAuthorizationError.tokenEndpointMissing
|
|
}
|
|
try urlValidator.validateAuthorizationServer(tokenEndpoint, context: "Token endpoint")
|
|
if let host = URLComponents(url: tokenEndpoint, resolvingAgainstBaseURL: false)?.host?
|
|
.lowercased(), urlValidator.isPrivateIPHost(host)
|
|
{
|
|
throw OAuthAuthorizationError.privateIPAddressBlocked(
|
|
context: "Token endpoint",
|
|
url: tokenEndpoint.absoluteString
|
|
)
|
|
}
|
|
return tokenEndpoint
|
|
}
|
|
|
|
private func canonicalResource(for endpoint: URL) throws -> URL {
|
|
let endpointCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
|
from: endpoint)
|
|
|
|
if let configuredResource = configuration.endpointOverrides.resource {
|
|
let configuredCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
|
from: configuredResource)
|
|
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
|
resource: configuredCanonical, endpoint: endpointCanonical)
|
|
else {
|
|
throw OAuthAuthorizationError.protectedResourceMismatch(
|
|
expected: endpointCanonical.absoluteString,
|
|
actual: configuredCanonical.absoluteString
|
|
)
|
|
}
|
|
return configuredCanonical
|
|
}
|
|
|
|
if let prmResourceString = protectedResourceMetadata?.resource,
|
|
let prmResourceURL = URL(string: prmResourceString)
|
|
{
|
|
let prmCanonical = try discoveryClient.metadataDiscovery.canonicalResourceURI(
|
|
from: prmResourceURL)
|
|
guard discoveryClient.metadataDiscovery.protectedResourceMatches(
|
|
resource: prmCanonical, endpoint: endpointCanonical)
|
|
else {
|
|
throw OAuthAuthorizationError.protectedResourceMismatch(
|
|
expected: endpointCanonical.absoluteString,
|
|
actual: prmCanonical.absoluteString
|
|
)
|
|
}
|
|
return prmCanonical
|
|
}
|
|
|
|
return endpointCanonical
|
|
}
|
|
|
|
private func authorizationServersMatch(_ lhs: URL, _ rhs: URL) -> Bool {
|
|
normalizedAuthorizationServer(lhs) == normalizedAuthorizationServer(rhs)
|
|
}
|
|
|
|
private func normalizedAuthorizationServer(_ url: URL) -> URL? {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let scheme = components.scheme?.lowercased(),
|
|
let host = components.host?.lowercased(),
|
|
scheme == OAuthURLScheme.http || scheme == OAuthURLScheme.https
|
|
else {
|
|
return nil
|
|
}
|
|
components.scheme = scheme
|
|
components.host = host
|
|
components.query = nil
|
|
components.fragment = nil
|
|
if components.path == "/" { components.path = "" }
|
|
return components.url
|
|
}
|
|
|
|
// MARK: - External Token Provider
|
|
|
|
private func fetchAccessTokenFromProvider(
|
|
context: OAuthConfiguration.AccessTokenProviderContext,
|
|
session: URLSession
|
|
) async throws -> String? {
|
|
guard let provider = configuration.accessTokenProvider else { return nil }
|
|
guard let token = try await provider(context, session), !token.isEmpty else { return nil }
|
|
return token
|
|
}
|
|
|
|
private func storeExternalAccessToken(
|
|
_ token: String,
|
|
requestedScopes: Set<String>?,
|
|
authorizationServer: URL?
|
|
) {
|
|
tokenStorage.save(OAuthAccessToken(
|
|
value: token,
|
|
tokenType: OAuthTokenType.bearer,
|
|
expiresAt: nil,
|
|
scopes: requestedScopes ?? [],
|
|
authorizationServer: authorizationServer,
|
|
refreshToken: nil,
|
|
clientID: nonEmptyClientID()
|
|
))
|
|
}
|
|
|
|
private func makeAccessTokenProviderContext(
|
|
statusCode: Int,
|
|
endpoint: URL,
|
|
challenge: OAuthBearerChallenge?,
|
|
metadata: OAuthProtectedResourceMetadata,
|
|
requestedScopes: Set<String>?,
|
|
session: URLSession
|
|
) async throws -> OAuthConfiguration.AccessTokenProviderContext {
|
|
let asMetadata = try await resolveAuthorizationServerMetadata(
|
|
metadata: metadata, session: session)
|
|
let resource = try canonicalResource(for: endpoint)
|
|
let authorizationServer = configuration.endpointOverrides.authorizationServerURL
|
|
?? selectedAuthorizationServer
|
|
?? metadata.authorizationServers.first
|
|
let tokenEndpoint = configuration.endpointOverrides.tokenEndpoint ?? asMetadata.tokenEndpoint
|
|
|
|
return OAuthConfiguration.AccessTokenProviderContext(
|
|
statusCode: statusCode,
|
|
endpoint: endpoint,
|
|
resource: resource,
|
|
authorizationServer: authorizationServer,
|
|
authorizationEndpoint: asMetadata.authorizationEndpoint,
|
|
tokenEndpoint: tokenEndpoint,
|
|
registrationEndpoint: asMetadata.registrationEndpoint,
|
|
challengedScope: challenge?.scope,
|
|
scopesSupported: metadata.scopesSupported,
|
|
requestedScopes: requestedScopes
|
|
)
|
|
}
|
|
|
|
private func normalizedOperationKey(_ operationKey: String?) -> String {
|
|
guard let operationKey else { return "<unknown>" }
|
|
let normalized = operationKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return normalized.isEmpty ? "<unknown>" : normalized
|
|
}
|
|
}
|