* feat: improvement to oauth resources resolution * feat: improvements to refreshToken procedure and updates to unit tests * feat: improvements to documentation of clien_id
142 lines
5.8 KiB
Swift
142 lines
5.8 KiB
Swift
import Foundation
|
|
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
/// Internal protocol for fetching OAuth discovery metadata.
|
|
protocol OAuthDiscoveryFetching: Sendable {
|
|
var metadataDiscovery: any OAuthMetadataDiscovering { get }
|
|
func fetchProtectedResourceMetadata(candidates: [URL], fallbackIssuer: URL?, session: URLSession) async throws -> OAuthProtectedResourceMetadata
|
|
func fetchAuthorizationServerMetadata(candidates: [URL], session: URLSession) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata)
|
|
}
|
|
|
|
/// Stateless OAuth metadata fetcher.
|
|
///
|
|
/// Fetches Protected Resource Metadata (RFC 9728) and Authorization Server Metadata
|
|
/// (RFC 8414 / OIDC Discovery 1.0) from ordered candidate URL lists.
|
|
/// Cache management is the caller's responsibility.
|
|
struct OAuthDiscoveryClient: Sendable {
|
|
let metadataDiscovery: any OAuthMetadataDiscovering
|
|
let urlValidator: OAuthURLValidator
|
|
|
|
init(
|
|
metadataDiscovery: any OAuthMetadataDiscovering,
|
|
urlValidator: OAuthURLValidator
|
|
) {
|
|
self.metadataDiscovery = metadataDiscovery
|
|
self.urlValidator = urlValidator
|
|
}
|
|
|
|
/// Fetches Protected Resource Metadata from the first candidate that returns a valid response.
|
|
///
|
|
/// If all candidates fail and `fallbackIssuer` is provided, returns synthetic metadata
|
|
/// using that issuer as the authorization server — for servers that do not expose a
|
|
/// PRM document at any well-known path.
|
|
func fetchProtectedResourceMetadata(
|
|
candidates: [URL],
|
|
fallbackIssuer: URL?,
|
|
session: URLSession
|
|
) async throws -> OAuthProtectedResourceMetadata {
|
|
let decoder = JSONDecoder()
|
|
for url in candidates {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
(200..<300).contains(httpResponse.statusCode)
|
|
else {
|
|
continue
|
|
}
|
|
|
|
let metadata = try decoder.decode(OAuthProtectedResourceMetadata.self, from: data)
|
|
guard !metadata.authorizationServers.isEmpty else { continue }
|
|
return metadata
|
|
} catch let error as OAuthAuthorizationError {
|
|
throw error
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
if let fallbackIssuer {
|
|
return OAuthProtectedResourceMetadata(
|
|
resource: nil,
|
|
authorizationServers: [fallbackIssuer],
|
|
scopesSupported: nil
|
|
)
|
|
}
|
|
throw OAuthAuthorizationError.metadataDiscoveryFailed
|
|
}
|
|
|
|
/// Fetches Authorization Server Metadata from candidates, preferring a response whose
|
|
/// `issuer` matches the candidate URL (RFC 8414 §3). If no candidate produces a matching
|
|
/// issuer, the first valid response is accepted and its own `issuer` is used as the
|
|
/// server identity — accommodating servers that serve metadata at one host but advertise
|
|
/// a different issuer.
|
|
func fetchAuthorizationServerMetadata(
|
|
candidates: [URL],
|
|
session: URLSession
|
|
) async throws -> (server: URL, metadata: OAuthAuthorizationServerMetadata) {
|
|
let decoder = JSONDecoder()
|
|
var firstValid: (server: URL, metadata: OAuthAuthorizationServerMetadata)?
|
|
|
|
for candidateServer in candidates {
|
|
guard (try? urlValidator.validateAuthorizationServer(
|
|
candidateServer, context: "Authorization server issuer")) != nil
|
|
else {
|
|
continue
|
|
}
|
|
if let host = URLComponents(url: candidateServer, resolvingAgainstBaseURL: false)?
|
|
.host?.lowercased(), urlValidator.isPrivateIPHost(host)
|
|
{
|
|
continue
|
|
}
|
|
|
|
for metadataURL in metadataDiscovery.authorizationServerMetadataURLs(
|
|
for: candidateServer)
|
|
{
|
|
var request = URLRequest(url: metadataURL)
|
|
request.httpMethod = "GET"
|
|
request.setValue(ContentType.json, forHTTPHeaderField: HTTPHeaderName.accept)
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
(200..<300).contains(httpResponse.statusCode)
|
|
else {
|
|
continue
|
|
}
|
|
|
|
let asMetadata = try decoder.decode(
|
|
OAuthAuthorizationServerMetadata.self, from: data)
|
|
|
|
// Prefer metadata whose issuer matches the candidate URL (RFC 8414 §3).
|
|
let issuerMatches =
|
|
asMetadata.issuer == nil
|
|
|| asMetadata.issuer?.absoluteString.lowercased()
|
|
== candidateServer.absoluteString.lowercased()
|
|
if issuerMatches {
|
|
return (server: candidateServer, metadata: asMetadata)
|
|
}
|
|
// Keep as fallback in case no issuer-matching candidate is found.
|
|
if firstValid == nil {
|
|
let server = asMetadata.issuer ?? candidateServer
|
|
firstValid = (server: server, metadata: asMetadata)
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if let firstValid {
|
|
return firstValid
|
|
}
|
|
throw OAuthAuthorizationError.authorizationServerMetadataDiscoveryFailed
|
|
}
|
|
}
|
|
|
|
extension OAuthDiscoveryClient: OAuthDiscoveryFetching {}
|