From d50743be9f403398bcfe6fa62e60d1f301b58cc1 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Thu, 5 Mar 2026 04:10:15 +0800 Subject: [PATCH 01/48] sketch refresh codepath from oauth4web --- .../CachedAuthenticated/LoginVM.swift | 2 + .../Login/LoginDemoVM.swift | 2 + .../GermConvenience/HTTPDataResponse.swift | 12 +- LocalPackages/oauth4swift/Package.swift | 2 +- .../Sources/OAuth/DPoP/DPoPResponse.swift | 10 ++ .../Models/Metadata/AuthorizationServer.swift | 106 +++++++++++++++++ .../Models/{ => Metadata}/Metadata.swift | 58 ---------- .../OAuth/Models/TokenEndpointResponse.swift | 85 ++++++++++++++ .../oauth4swift/Sources/OAuth/OAuth.swift | 30 +++++ .../Session/OAuthSession+AuthRequest.swift | 25 +++- .../OAuth/Session/SessionCapabilities.swift | 2 +- .../Sources/OAuth/Session/SessionState.swift | 15 ++- .../Sources/OAuth/TokenEndpointRequest.swift | 109 ++++++++++++++++++ .../Apple/URLSession+HTTPRequester.swift | 20 ++++ .../AtprotoOAuth/Session/SessionImpl.swift | 77 +++++++++++++ 15 files changed, 489 insertions(+), 66 deletions(-) create mode 100644 LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift rename LocalPackages/oauth4swift/Sources/OAuth/Models/{ => Metadata}/Metadata.swift (59%) create mode 100644 LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift create mode 100644 LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift diff --git a/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift b/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift index 0988075..85b06b0 100644 --- a/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift +++ b/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift @@ -71,6 +71,7 @@ import os ), appCredentials: oauthClient.appCredentials, httpRequester: URLSession.defaultProvider, + manualRedirectFetch: URLSession.manualRefreshFetcher, atprotoClient: AtprotoClient( responseProvider: URLSession.defaultProvider ), @@ -147,6 +148,7 @@ import os ), appCredentials: oauthClient.appCredentials, httpRequester: URLSession.defaultProvider, + manualRedirectFetch: URLSession.manualRefreshFetcher, atprotoClient: AtprotoClient( responseProvider: URLSession.defaultProvider ), diff --git a/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift b/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift index d2c102a..59a68c4 100644 --- a/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift +++ b/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift @@ -74,6 +74,8 @@ import SwiftUI ), appCredentials: oauthClient.appCredentials, httpRequester: URLSession.defaultProvider, + manualRedirectFetch: URLSession + .manualRefreshFetcher, atprotoClient: AtprotoClient( responseProvider: URLSession.defaultProvider ), diff --git a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift index fe25f8f..fb0d83b 100644 --- a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift +++ b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift @@ -20,8 +20,16 @@ public struct HTTPDataResponse: Sendable { self.response = response } - public func successDecode() throws -> R { - guard response.statusCode >= 200 && response.statusCode < 300 else { + public func successDecode( + successCode: Int + ) throws -> R { + try successDecode(successCodes: successCode...successCode) + } + + public func successDecode( + successCodes: RangeExpression = 200..<300 + ) throws -> R { + guard successCodes.contains(response.statusCode) else { if let stringResponse = String(data: data, encoding: .utf8) { throw HTTPResponseError diff --git a/LocalPackages/oauth4swift/Package.swift b/LocalPackages/oauth4swift/Package.swift index 83aa7e0..0694b30 100644 --- a/LocalPackages/oauth4swift/Package.swift +++ b/LocalPackages/oauth4swift/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "OAuth", - platforms: [.iOS(.v15), .macOS(.v12)], + platforms: [.iOS(.v16), .macOS(.v13)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift index 99c8212..3293ff0 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift @@ -9,6 +9,16 @@ import Crypto import Foundation import GermConvenience +public protocol DPoPSigning { + func cacheNonce(response: URLResponse, requestUrl: URL) +} + +extension DPoPSigning { + func addProof(request: inout URLRequest) throws { + + } +} + public protocol DPoPNonceHolding: Actor { var dpopKey: DPoPKey { get throws } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift new file mode 100644 index 0000000..48d012a --- /dev/null +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift @@ -0,0 +1,106 @@ +// +// AuthorizationServer.swift +// OAuth +// +// Created by Mark @ Germ on 3/4/26. +// + +import Foundation +import GermConvenience + +// See: https://www.rfc-editor.org/rfc/rfc8414.html +public struct AuthServerMetadata: Codable, Hashable, Sendable { + public let issuer: String + public let authorizationEndpoint: String + public let tokenEndpoint: String + public let responseTypesSupported: [String] + public let grantTypesSupported: [String] + public let codeChallengeMethodsSupported: [String] + public let tokenEndpointAuthMethodsSupported: [String] + public let tokenEndpointAuthSigningAlgValuesSupported: [String] + public let scopesSupported: [String] + public let authorizationResponseIssParameterSupported: Bool + public let requirePushedAuthorizationRequests: Bool + public let pushedAuthorizationRequestEndpoint: String + public let dpopSigningAlgValuesSupported: [String] + public let requireRequestUriRegistration: Bool + public let clientIdMetadataDocumentSupported: Bool + + enum CodingKeys: String, CodingKey { + case issuer + case authorizationEndpoint = "authorization_endpoint" + case tokenEndpoint = "token_endpoint" + case responseTypesSupported = "response_types_supported" + case grantTypesSupported = "grant_types_supported" + case codeChallengeMethodsSupported = "code_challenge_methods_supported" + case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" + case tokenEndpointAuthSigningAlgValuesSupported = + "token_endpoint_auth_signing_alg_values_supported" + case scopesSupported = "scopes_supported" + case authorizationResponseIssParameterSupported = + "authorization_response_iss_parameter_supported" + case requirePushedAuthorizationRequests = "require_pushed_authorization_requests" + case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" + case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" + case requireRequestUriRegistration = "require_request_uri_registration" + case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported" + } + + //deprecate + public static func load( + for host: String, + httpRequester: HTTPDataResponse.Requester + ) async throws -> AuthServerMetadata { + var components = URLComponents() + + components.scheme = URLScheme.https.rawValue + components.host = host + components.path = "/.well-known/oauth-authorization-server" + + let url = try components.url.tryUnwrap(MetadataError.urlInvalid) + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + return try await httpRequester(request) + .successDecode() + } + + enum Endpoint { + case authorization + case token + + var metadataPath: KeyPath { + switch self { + case .authorization: + \.authorizationEndpoint + case .token: + \.tokenEndpoint + } + } + } + + //for our purposes require secure + func resolve(endpoint: Endpoint) throws -> URL { + let url = try URL( + string: self[keyPath: endpoint.metadataPath] + ).tryUnwrap + + guard url.scheme == "https" else { + throw OAuthError.insecureScheme + } + + return url + } +} + +extension AuthServerMetadata { + static func mock() throws -> Self { + let data = + """ + {"issuer":"https://bsky.social","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://bsky.social/oauth/jwks","authorization_endpoint":"https://bsky.social/oauth/authorize","token_endpoint":"https://bsky.social/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://bsky.social/oauth/revoke","pushed_authorization_request_endpoint":"https://bsky.social/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true} + """.utf8Data + + return try JSONDecoder().decode(AuthServerMetadata.self, from: data) + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift similarity index 59% rename from LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata.swift rename to LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift index 98d4cbb..716eb40 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift @@ -12,64 +12,6 @@ enum MetadataError: Error { case urlInvalid } -// See: https://www.rfc-editor.org/rfc/rfc8414.html -public struct AuthServerMetadata: Codable, Hashable, Sendable { - public let issuer: String - public let authorizationEndpoint: String - public let tokenEndpoint: String - public let responseTypesSupported: [String] - public let grantTypesSupported: [String] - public let codeChallengeMethodsSupported: [String] - public let tokenEndpointAuthMethodsSupported: [String] - public let tokenEndpointAuthSigningAlgValuesSupported: [String] - public let scopesSupported: [String] - public let authorizationResponseIssParameterSupported: Bool - public let requirePushedAuthorizationRequests: Bool - public let pushedAuthorizationRequestEndpoint: String - public let dpopSigningAlgValuesSupported: [String] - public let requireRequestUriRegistration: Bool - public let clientIdMetadataDocumentSupported: Bool - - enum CodingKeys: String, CodingKey { - case issuer - case authorizationEndpoint = "authorization_endpoint" - case tokenEndpoint = "token_endpoint" - case responseTypesSupported = "response_types_supported" - case grantTypesSupported = "grant_types_supported" - case codeChallengeMethodsSupported = "code_challenge_methods_supported" - case tokenEndpointAuthMethodsSupported = "token_endpoint_auth_methods_supported" - case tokenEndpointAuthSigningAlgValuesSupported = - "token_endpoint_auth_signing_alg_values_supported" - case scopesSupported = "scopes_supported" - case authorizationResponseIssParameterSupported = - "authorization_response_iss_parameter_supported" - case requirePushedAuthorizationRequests = "require_pushed_authorization_requests" - case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" - case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" - case requireRequestUriRegistration = "require_request_uri_registration" - case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported" - } - - public static func load( - for host: String, - httpRequester: HTTPDataResponse.Requester - ) async throws -> AuthServerMetadata { - var components = URLComponents() - - components.scheme = URLScheme.https.rawValue - components.host = host - components.path = "/.well-known/oauth-authorization-server" - - let url = try components.url.tryUnwrap(MetadataError.urlInvalid) - - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Accept") - - return try await httpRequester(request) - .successDecode() - } -} - // See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ public struct ClientMetadata: Hashable, Codable, Sendable { public let clientId: String diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift new file mode 100644 index 0000000..b043bcd --- /dev/null +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift @@ -0,0 +1,85 @@ +// +// TokenEndpointResponse.swift +// OAuth +// +// Created by Mark @ Germ on 3/5/26. +// + +import Foundation + +public struct TokenEndpointResponse { + public let accessToken: String + public let expiresIn: Int? + let idToken: String? + public let refreshToken: String? + public let scope: String? + // let authorizationDetails: + let tokenType: TokenType + + //capture additional fields + let additionalFields: [String: Any]? + + enum TokenType: String, Decodable { + // case bearer + case dpop + + init(string: String) throws { + switch string { + case TokenType.dpop.rawValue: + self = .dpop + default: + throw OAuthError.unrecognizedTokenType + } + } + } +} +extension TokenEndpointResponse: Decodable { + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case expiresIn = "expires_in" + case idToken = "id_token" + case refreshToken = "refresh_token" + case scope + case tokenType = "token_type" + } + + public init(from decoder: Decoder) throws { + // 1. Decode standard keys + let container = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try container.decode(String.self, forKey: .accessToken) + expiresIn = try container.decode(Int?.self, forKey: .expiresIn) + idToken = try container.decode(String?.self, forKey: .idToken) + refreshToken = try container.decode(String?.self, forKey: .refreshToken) + scope = try container.decode(String?.self, forKey: .scope) + let tokenString = try container.decode(String.self, forKey: .tokenType) + tokenType = try .init(string: tokenString) + + // 2. Capture everything else + let extraContainer = try decoder.container(keyedBy: DynamicCodingKeys.self) + var tempExtraFields = [String: Any]() + + for key in extraContainer.allKeys { + // Skip keys already decoded + if CodingKeys(rawValue: key.stringValue) == nil { + // Decode value as a generic type (requires flexibility) + if let value = try? extraContainer.decode(String.self, forKey: key) + { + tempExtraFields[key.stringValue] = value + } else if let value = try? extraContainer.decode( + Int.self, forKey: key) + { + tempExtraFields[key.stringValue] = value + } + // Add more types as needed + } + } + self.additionalFields = tempExtraFields + } + + struct DynamicCodingKeys: CodingKey { + var stringValue: String + init?(stringValue: String) { self.stringValue = stringValue } + var intValue: Int? + init?(intValue: Int) { return nil } + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift index 849777c..bb0198f 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift @@ -1,10 +1,13 @@ import Foundation +import GermConvenience enum OAuthError: Error { case missingScheme case missingHTTPMethod case missingUrl case missingDPoPKey + case insecureScheme + case unrecognizedTokenType case httpResponse(response: HTTPURLResponse) case notImplemented } @@ -16,6 +19,8 @@ extension OAuthError: LocalizedError { case .missingHTTPMethod: "Missing HTTP method" case .missingUrl: "Missing URL" case .missingDPoPKey: "Missing dPoP key" + case .insecureScheme: "Insecure scheme" + case .unrecognizedTokenType: "Unrecognized Token Type" case .httpResponse(let response): "HTTP error with status code: \(response.statusCode), response: \(response)" case .notImplemented: "Not implemented" @@ -25,3 +30,28 @@ extension OAuthError: LocalizedError { //Abstraction of ASWebAuthentication or AuthTabIntent public typealias UserAuthenticator = @Sendable (URL, String) async throws -> URL + +//parking place for oauth4web analogs +enum OAuth { + static func processGenericAccessToken( + response: HTTPDataResponse + ) throws -> TokenEndpointResponse { + let decoded: TokenEndpointResponse = try response.successDecode(successCode: 200) + + return decoded + } + + struct TokenResponse: Decodable { + let accessToken: String + let tokenType: String + let scope: String? + let idToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case scope + case idToken = "id_token" + } + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index a389a2e..03ce9eb 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -36,7 +36,7 @@ extension OAuthSessionCapabilities { } //try to refresh the token - let refreshed = try await refresh(state: sessionState) + let refreshed = try await conservingRefresh(state: sessionState) //try again return try await dpopResponse( @@ -47,7 +47,8 @@ extension OAuthSessionCapabilities { ) } - private func refresh(state: SessionState) async throws -> SessionState.Mutable { + //conserving in that it reuses result if a refresh is alread in flght + private func conservingRefresh(state: SessionState) async throws -> SessionState.Mutable { if let refreshTask { return try await refreshTask.value } @@ -68,4 +69,24 @@ extension OAuthSessionCapabilities { //handle successful refresh return try await newRefreshTask.value } + + //compare to refreshTokenGrantRequest + //and processRefreshTokenResponse in + private func refresh( + state: SessionState, + appCredentials: AppCredentials, + ) async throws -> SessionState.Mutable { + let authServerMetadata = try await getAuthServerMetadata() + let httpResponse = try await refreshTokenGrantRequest( + authServerMetadata: authServerMetadata, + refreshToken: state.mutable.refreshToken.tryUnwrap.value + ) + let response = try await processRefreshTokenResponse(response: httpResponse) + + return try validate( + authMetadata: authServerMetadata, + tokenResponse: response + ) + } + } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift index 70ca247..b5168e1 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift @@ -8,7 +8,7 @@ import Crypto import Foundation -public protocol OAuthSessionCapabilities: Actor, TokenHandling, DPoPNonceHolding { +public protocol OAuthSessionCapabilities: Actor, TokenHandling, DPoPNonceHolding, AuthRequestable { var appCredentials: AppCredentials { get } var lazyServerMetadata: LazyResource { get } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionState.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionState.swift index eac2b58..873d844 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionState.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionState.swift @@ -15,14 +15,25 @@ public struct Token: Codable, Hashable, Sendable { /// An optional expiry. public let expiry: Date? + public init?(refreshToken: String?) { + guard let refreshToken else { + return nil + } + self.value = refreshToken + self.expiry = nil + } public init(value: String, expiry: Date? = nil) { self.value = value self.expiry = expiry } - public init(value: String, expiresIn seconds: Int) { + public init(value: String, expiresIn seconds: Int?) { self.value = value - self.expiry = Date(timeIntervalSinceNow: TimeInterval(seconds)) + if let seconds { + self.expiry = Date(timeIntervalSinceNow: TimeInterval(seconds)) + } else { + self.expiry = nil + } } /// Determines if the token object is valid. diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift new file mode 100644 index 0000000..591d710 --- /dev/null +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -0,0 +1,109 @@ +// +// TokenEndpointRequest.swift +// OAuth +// +// Created by Mark @ Germ on 3/4/26. +// + +import Foundation +import GermConvenience + +enum GrantType: String { + case authorizationCode = "authorization_code" + case refreshToken = "refresh_token" +} + +//make this a protocol so both the Authorizer and Session can use it +public protocol AuthRequestable: Actor { + var additionalParameters: [String: String] { get } + func manualRedirectFetch(request: URLRequest) async throws -> HTTPDataResponse + func validate( + authMetadata: AuthServerMetadata, + tokenResponse: TokenEndpointResponse + ) throws -> SessionState.Mutable + var lazyIssuer: LazyResource { get } +} + +extension AuthRequestable { + //initially rely on network stack caching + func getAuthServerMetadata() async throws -> AuthServerMetadata { + try await authServerDiscovery( + issuer: lazyIssuer.lazyValue(isolation: self) + ) + .successDecode(successCode: 200) + } + + private func authServerDiscovery(issuer: URL) async throws -> HTTPDataResponse { + guard issuer.scheme == "https" else { + throw OAuthError.insecureScheme + } + //NOTE: oauth4web prepends this to the incoming path, + let requestUrl = issuer.appending(path: "/.well-known/oauth-authorization-server") + var request = URLRequest(url: requestUrl) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpMethod = HTTPMethod.get.rawValue + + return try await manualRedirectFetch(request: request) + } + + func refreshTokenGrantRequest( + authServerMetadata: AuthServerMetadata, + refreshToken: String + ) async throws -> HTTPDataResponse { + var parameters = additionalParameters + parameters["refresh_token"] = refreshToken + + return try await tokenEndpointRequest( + authServerMetadata: authServerMetadata, + grantType: .refreshToken, + parameters: parameters, + headers: [:] + ) + } + + func processRefreshTokenResponse( + response: HTTPDataResponse + ) throws -> TokenEndpointResponse { + try OAuth.processGenericAccessToken(response: response) + } + + func tokenEndpointRequest( + authServerMetadata: AuthServerMetadata, + grantType: GrantType, + parameters: [String: String], + headers: [String: String] + ) async throws -> HTTPDataResponse { + let url = try authServerMetadata.resolve(endpoint: .token) + + var modifiedParams = parameters + modifiedParams["grant_type"] = grantType.rawValue + + var headers = headers + //swift4web sets the "accept" header, but both may be appropriate + headers["accept"] = "application/json" + headers["Content-Type"] = "application/json" + //swift4web sets the "accept" header, but Content-Type seems + + var request = URLRequest(url: url) + + if let dpopSigner = self as? DPoPSigning { + try dpopSigner.addProof(request: &request) + } + + request.httpMethod = HTTPMethod.post.rawValue + request.httpBody = try JSONEncoder().encode(headers) + + let response = try await authenticated(request: request) + if let dpopSigner = self as? DPoPSigning { + try dpopSigner.cacheNonce(response: response.response, requestUrl: url) + } + + return response + } + + //here for shadowing of oauth4web.authenticatedRequest + //but most functionality has been lifted out + func authenticated(request: URLRequest) async throws -> HTTPDataResponse { + return try await manualRedirectFetch(request: request) + } +} diff --git a/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift b/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift index 902bc92..0cfb7ce 100644 --- a/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift +++ b/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift @@ -29,4 +29,24 @@ extension URLSession { public static var defaultProvider: HTTPDataResponse.Requester { URLSession(configuration: .default).responseProvider } + + public static var manualRefreshFetcher: HTTPDataResponse.Requester { + URLSession( + configuration: .default, + delegate: ManualRedirect(), + delegateQueue: nil + ) + .responseProvider + } +} + +final class ManualRedirect: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest + ) async -> URLRequest? { + nil + } } diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index 80ebf3f..89caf12 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -15,6 +15,7 @@ public actor AtprotoOAuthSessionImpl { public nonisolated let did: Atproto.DID public let appCredentials: AppCredentials public let httpRequester: HTTPDataResponse.Requester + public let manualRedirectFetcher: HTTPDataResponse.Requester let atprotoClient: AtprotoClientInterface let oauthMetadataFetcher: OAuthMetadataFetcher @@ -34,6 +35,7 @@ public actor AtprotoOAuthSessionImpl { } var state: State public var lazyServerMetadata: LazyResource + public var lazyIssuer: LazyResource public var refreshTask: Task? private let saveStream: AsyncStream @@ -49,6 +51,7 @@ public actor AtprotoOAuthSessionImpl { appCredentials: AppCredentials, state: State, httpRequester: @escaping HTTPDataResponse.Requester, + manualRedirectFetch: @escaping HTTPDataResponse.Requester, atprotoClient: AtprotoClientInterface, oauthMetadataFetcher: OAuthMetadataFetcher ) { @@ -56,6 +59,7 @@ public actor AtprotoOAuthSessionImpl { self.appCredentials = appCredentials self.state = state self.httpRequester = httpRequester + self.manualRedirectFetcher = manualRedirectFetch self.atprotoClient = atprotoClient self.oauthMetadataFetcher = oauthMetadataFetcher @@ -98,6 +102,42 @@ public actor AtprotoOAuthSessionImpl { } }) + self.lazyIssuer = .init( + fetchTaskGenerator: { + Task { + let pdsHost = try await atprotoClient.plcDirectoryQuery(did) + .pdsUrl + let pdsMetadata = + try await oauthMetadataFetcher + .fetchMetadata( + protectedResourceHost: pdsHost.host() + .tryUnwrap + ) + + //https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 + //PDS doesn't actually fill this field, so we only check it if present + if let supportedAlgs = pdsMetadata + .dpopSigningAlgValuesSupported + { + guard supportedAlgs.contains("ES256") + else { + throw OAuthSessionError.unsupported + } + } + + guard + let authorizationServerUrl = pdsMetadata + .authorizationServers?.first, + let authorizationServer = URL( + string: authorizationServerUrl) + else { + throw OAuthSessionError.cantFormURL + } + + return authorizationServer + } + }) + nonceCache.countLimit = 25 (saveStream, saveContinuation) = AsyncStream @@ -165,6 +205,7 @@ extension AtprotoOAuthSessionImpl { archive: Archive, appCredentials: AppCredentials, httpRequester: @escaping HTTPDataResponse.Requester, + manualRedirectFetch: @escaping HTTPDataResponse.Requester, atprotoClient: AtprotoClientInterface, oauthMetadataFetcher: OAuthMetadataFetcher ) throws -> (AtprotoOAuthSession, AsyncStream) { @@ -172,6 +213,7 @@ extension AtprotoOAuthSessionImpl { archive: archive, appCredentials: appCredentials, httpRequester: httpRequester, + manualRedirectFetch: manualRedirectFetch, atprotoClient: atprotoClient, oauthMetadataFetcher: oauthMetadataFetcher ) @@ -182,6 +224,7 @@ extension AtprotoOAuthSessionImpl { archive: Archive, appCredentials: AppCredentials, httpRequester: @escaping HTTPDataResponse.Requester, + manualRedirectFetch: @escaping HTTPDataResponse.Requester, atprotoClient: AtprotoClientInterface, oauthMetadataFetcher: OAuthMetadataFetcher ) throws { @@ -190,6 +233,7 @@ extension AtprotoOAuthSessionImpl { appCredentials: appCredentials, state: .init(archive: archive.session), httpRequester: httpRequester, + manualRedirectFetch: manualRedirectFetch, atprotoClient: atprotoClient, oauthMetadataFetcher: oauthMetadataFetcher ) @@ -265,3 +309,36 @@ extension AtprotoOAuthSessionImpl { .pdsUrl } } + +extension AtprotoOAuthSessionImpl: AuthRequestable { + public var additionalParameters: [String: String] { + [ + "client_id": appCredentials.clientId, + "redirect_url": appCredentials.callbackURL.absoluteString, + ] + } + + public func manualRedirectFetch(request: URLRequest) async throws + -> GermConvenience.HTTPDataResponse + { + try await manualRedirectFetcher(request) + } + + public func validate( + authMetadata: AuthServerMetadata, + tokenResponse: OAuth.TokenEndpointResponse + ) throws -> OAuth.SessionState.Mutable { + //TODO: finish validation + + .init( + accessToken: .init( + value: tokenResponse.accessToken, expiresIn: tokenResponse.expiresIn + ), + refreshToken: .init(refreshToken: tokenResponse.refreshToken), + scopes: tokenResponse.scope, + //REVIEW: where should this come from? + issuingServer: authMetadata.issuer + ) + } + +} From 23a474b7697da0927e8aebb4734215b119184f7b Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Thu, 5 Mar 2026 06:35:21 +0800 Subject: [PATCH 02/48] sketch out auth path --- .../OAuth/AuthorizerCapabilities.swift | 4 +- .../oauth4swift/Sources/OAuth/OAuth.swift | 37 ++-- .../Sources/OAuth/OAuthComponents.swift | 177 ++++++++++++++++++ .../Sources/OAuth/TokenEndpointRequest.swift | 132 ++++++++----- .../AtprotoOAuth/AuthorizerCapabilities.swift | 4 +- .../Session/Session+TokenHandling.swift | 2 +- .../AtprotoOAuth/Session/SessionImpl.swift | 14 +- 7 files changed, 282 insertions(+), 88 deletions(-) create mode 100644 LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift diff --git a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift index 9420e10..8eff01e 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift @@ -20,7 +20,7 @@ public protocol AuthorizerCapabilities: DPoPNonceHolding { clientId: String, ) throws -> URL - static func validateAuthResponse( + static func finishAuthorization( authorizationUrl: URL, stateToken: String, redirectURI: URL, @@ -60,7 +60,7 @@ extension AuthorizerCapabilities { let callbackURL = try await userAuthenticator(tokenURL, scheme) - return try await Self.validateAuthResponse( + return try await Self.finishAuthorization( authorizationUrl: tokenURL, stateToken: stateToken, redirectURI: callbackURL, diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift index bb0198f..8f1e6d9 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift @@ -8,6 +8,10 @@ enum OAuthError: Error { case missingDPoPKey case insecureScheme case unrecognizedTokenType + case redirectMissingComponents + case redirectError(String) + case stateTokenMismatch(String, String) + case issuingServerMismatch(String, String) case httpResponse(response: HTTPURLResponse) case notImplemented } @@ -21,6 +25,14 @@ extension OAuthError: LocalizedError { case .missingDPoPKey: "Missing dPoP key" case .insecureScheme: "Insecure scheme" case .unrecognizedTokenType: "Unrecognized Token Type" + case .redirectMissingComponents: "Redirect missing components" + case .stateTokenMismatch( + let expected, + let got + ): "State token did not match, expected \(expected), got \(got)" + case .issuingServerMismatch(let expected, let got): + "Issuing server did not match, expected \(expected), got \(got)" + case .redirectError(let errorString): "Redirect error: \(errorString)" case .httpResponse(let response): "HTTP error with status code: \(response.statusCode), response: \(response)" case .notImplemented: "Not implemented" @@ -30,28 +42,3 @@ extension OAuthError: LocalizedError { //Abstraction of ASWebAuthentication or AuthTabIntent public typealias UserAuthenticator = @Sendable (URL, String) async throws -> URL - -//parking place for oauth4web analogs -enum OAuth { - static func processGenericAccessToken( - response: HTTPDataResponse - ) throws -> TokenEndpointResponse { - let decoded: TokenEndpointResponse = try response.successDecode(successCode: 200) - - return decoded - } - - struct TokenResponse: Decodable { - let accessToken: String - let tokenType: String - let scope: String? - let idToken: String? - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case tokenType = "token_type" - case scope - case idToken = "id_token" - } - } -} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift new file mode 100644 index 0000000..f4137da --- /dev/null +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -0,0 +1,177 @@ +// +// OAuthComponents.swift +// OAuth +// +// Created by Mark @ Germ on 3/5/26. +// + +import Foundation +import GermConvenience + +///Direct analog to oauth4web's OAuth module in providing stateless API as building blocks for a full client +public enum OAuthComponents { + static public func validateAuthResponse( + authServerMetadata: AuthServerMetadata, + redirectURL: URL, + expectedState: String + ) throws -> ParsedRedirect { + // decode the params in the redirectURL + let redirectComponents = try URLComponents( + url: redirectURL, + resolvingAgainstBaseURL: false + ).tryUnwrap + + guard + let authCode = redirectComponents.queryItems?.first(where: { + $0.name == "code" + })?.value, + let iss = redirectComponents.queryItems?.first(where: { + $0.name == "iss" + })?.value, + let state = redirectComponents.queryItems?.first(where: { + $0.name == "state" + })?.value + else { + throw OAuthError.redirectMissingComponents + } + + guard state == expectedState else { + throw OAuthError.stateTokenMismatch(state, expectedState) + } + + guard iss == authServerMetadata.issuer else { + throw + OAuthError + .issuingServerMismatch(iss, authServerMetadata.issuer) + } + + if let errorItem = redirectComponents.queryItems?.first(where: { + $0.name == "error" + }) { + throw OAuthError.redirectError(errorItem.value ?? "") + } + + return .init( + authCode: authCode, + issuer: iss, + components: redirectComponents + ) + } + + public struct ParsedRedirect { + public let authCode: String + public let issuer: String + + public let components: URLComponents + } + + static func processGenericAccessToken( + response: HTTPDataResponse + ) throws -> TokenEndpointResponse { + let decoded: TokenEndpointResponse = try response.successDecode(successCode: 200) + + return decoded + } + + struct TokenResponse: Decodable { + let accessToken: String + let tokenType: String + let scope: String? + let idToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case scope + case idToken = "id_token" + } + } +} + +extension AuthRequestable { + public func authorizationCodeGrantRequest( + authServerMetadata: AuthServerMetadata, + redirectUrl: URL, + parsedRedirect: OAuthComponents.ParsedRedirect, + verifier: String?, + additionalParameters: [String: String], + manualRedirectFetch: HTTPDataResponse.Requester + ) async throws -> HTTPDataResponse { + var parameters = additionalParameters + parameters["redirect_url"] = redirectUrl.absoluteString + parameters["code"] = parsedRedirect.authCode + + if let verifier { + parameters["code_verifier"] = verifier + } + + return try await tokenEndpointRequest( + authServerMetadata: authServerMetadata, + grantType: .authorizationCode, + parameters: parameters, + headers: [:], + manualRedirectFetch: manualRedirectFetch + ) + } + + func refreshTokenGrantRequest( + authServerMetadata: AuthServerMetadata, + refreshToken: String, + ) async throws -> HTTPDataResponse { + var parameters = additionalParameters + parameters["refresh_token"] = refreshToken + + return try await tokenEndpointRequest( + authServerMetadata: authServerMetadata, + grantType: .refreshToken, + parameters: parameters, + headers: [:], + manualRedirectFetch: manualRedirectFetch + ) + } + + func tokenEndpointRequest( + authServerMetadata: AuthServerMetadata, + grantType: GrantType, + parameters: [String: String], + headers: [String: String], + manualRedirectFetch: HTTPDataResponse.Requester + ) async throws -> HTTPDataResponse { + let url = try authServerMetadata.resolve(endpoint: .token) + + var modifiedParams = parameters + modifiedParams["grant_type"] = grantType.rawValue + + var headers = headers + //swift4web sets the "accept" header, but both may be appropriate + headers["accept"] = "application/json" + headers["Content-Type"] = "application/json" + //swift4web sets the "accept" header, but Content-Type seems + + var request = URLRequest(url: url) + + if let dpopSigner = self as? DPoPSigning { + try dpopSigner.addProof(request: &request) + } + + request.httpMethod = HTTPMethod.post.rawValue + request.httpBody = try JSONEncoder().encode(headers) + + let response = try await authenticated( + request: request, + ) + if let dpopSigner = self as? DPoPSigning { + try dpopSigner.cacheNonce(response: response.response, requestUrl: url) + } + + return response + } + + //here for shadowing of oauth4web.authenticatedRequest + //but most functionality has been lifted out + func authenticated( + request: URLRequest, + ) async throws -> HTTPDataResponse { + try await manualRedirectFetch(request: request) + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index 591d710..5576261 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -33,6 +33,74 @@ extension AuthRequestable { .successDecode(successCode: 200) } + func finishAuthorization( + authorizationUrl: URL, + stateToken: String, + redirectURI: URL, + pkceVerifier: PKCEVerifier, + appCredentials: AppCredentials, + authServerMetadata: AuthServerMetadata, + dpopKey: DPoPKey, + dpopRequester: (URLRequest) async throws -> HTTPDataResponse + ) async throws -> SessionState.Archive { + let parsedRedirect = try OAuthComponents.validateAuthResponse( + authServerMetadata: authServerMetadata, + redirectURL: redirectURI, + expectedState: stateToken + ) + + let httpResponse = try await authorizationCodeGrantRequest( + authServerMetadata: authServerMetadata, + redirectUrl: redirectURI, + parsedRedirect: parsedRedirect, + verifier: pkceVerifier.verifier, + additionalParameters: additionalParameters, + manualRedirectFetch: manualRedirectFetch + ) + + let result = try processAuthorizationCodeOAuth2Response( + authServerMetadata: authServerMetadata, + response: httpResponse + ) + + let mutable = try validate(authMetadata: authServerMetadata, tokenResponse: result) + + return .init(dPopKey: dpopKey, additionalParams: nil, mutable: mutable) + + // let result = try await dpopRequester(request) + // .successErrorDecode( + // resultType: Atproto.TokenResponse.self, + // errorType: Atproto.TokenError.self, + // ) + + // switch result { + // case .result(let tokenResponse): + // guard tokenResponse.tokenType == "DPoP" else { + // throw OAuthClientError.dpopTokenExpected( + // tokenResponse.tokenType) + // } + // + // try await Self.tokenSubscriberValidator( + // response: tokenResponse, + // sub: authServerMetadata.issuer + // ) + // + // return tokenResponse.session( + // for: parsedRedirect.issuer, + // dpopKey: dpopKey + // ) + // case .error(let tokenError, let statusCode): + + // if tokenError.errorDescription == "Code challenge already used" { + // throw OAuthClientError.codeChallengeAlreadyUsed + // } + // Self.logger.error( + // "Login error: \(tokenError.errorDescription), with status code \(statusCode)" + // ) + // throw OAuthClientError.remoteTokenError(tokenError) + // } + } + private func authServerDiscovery(issuer: URL) async throws -> HTTPDataResponse { guard issuer.scheme == "https" else { throw OAuthError.insecureScheme @@ -46,64 +114,26 @@ extension AuthRequestable { return try await manualRedirectFetch(request: request) } - func refreshTokenGrantRequest( - authServerMetadata: AuthServerMetadata, - refreshToken: String - ) async throws -> HTTPDataResponse { - var parameters = additionalParameters - parameters["refresh_token"] = refreshToken - - return try await tokenEndpointRequest( - authServerMetadata: authServerMetadata, - grantType: .refreshToken, - parameters: parameters, - headers: [:] - ) - } - func processRefreshTokenResponse( response: HTTPDataResponse ) throws -> TokenEndpointResponse { - try OAuth.processGenericAccessToken(response: response) + try OAuthComponents.processGenericAccessToken(response: response) } - func tokenEndpointRequest( + func processAuthorizationCodeOAuth2Response( authServerMetadata: AuthServerMetadata, - grantType: GrantType, - parameters: [String: String], - headers: [String: String] - ) async throws -> HTTPDataResponse { - let url = try authServerMetadata.resolve(endpoint: .token) - - var modifiedParams = parameters - modifiedParams["grant_type"] = grantType.rawValue - - var headers = headers - //swift4web sets the "accept" header, but both may be appropriate - headers["accept"] = "application/json" - headers["Content-Type"] = "application/json" - //swift4web sets the "accept" header, but Content-Type seems - - var request = URLRequest(url: url) - - if let dpopSigner = self as? DPoPSigning { - try dpopSigner.addProof(request: &request) - } - - request.httpMethod = HTTPMethod.post.rawValue - request.httpBody = try JSONEncoder().encode(headers) - - let response = try await authenticated(request: request) - if let dpopSigner = self as? DPoPSigning { - try dpopSigner.cacheNonce(response: response.response, requestUrl: url) - } + response: HTTPDataResponse + ) throws -> TokenEndpointResponse { + let result = try OAuthComponents.processGenericAccessToken(response: response) - return response - } + //check the claims + try validate(authMetadata: authServerMetadata, tokenResponse: result) + // TODO: GER-1343 - Implement validator + // after a token is issued, it is critical that the returned + // identity be resolved and its PDS match the issuing server + // + // check out draft-ietf-oauth-v2-1 section 7.3.1 for details - //here for shadowing of oauth4web.authenticatedRequest - //but most functionality has been lifted out - func authenticated(request: URLRequest) async throws -> HTTPDataResponse { - return try await manualRedirectFetch(request: request) + return result } } diff --git a/Sources/AtprotoOAuth/AuthorizerCapabilities.swift b/Sources/AtprotoOAuth/AuthorizerCapabilities.swift index 51f9a95..865b09c 100644 --- a/Sources/AtprotoOAuth/AuthorizerCapabilities.swift +++ b/Sources/AtprotoOAuth/AuthorizerCapabilities.swift @@ -49,7 +49,7 @@ extension AuthorizerImpl: DPoPNonceHolding { public static func decode( dataResponse: HTTPDataResponse, requestUrl: URL - ) throws -> OAuth.IndexedNonce? { + ) throws -> IndexedNonce? { try AtprotoOAuthSessionImpl.decode( dataResponse: dataResponse, requestUrl: requestUrl @@ -77,7 +77,7 @@ extension AuthorizerImpl: AuthorizerCapabilities { return url } - static func validateAuthResponse( + static func finishAuthorization( authorizationUrl: URL, stateToken: String, redirectURI: URL, diff --git a/Sources/AtprotoOAuth/Session/Session+TokenHandling.swift b/Sources/AtprotoOAuth/Session/Session+TokenHandling.swift index 58dcd4e..d2dd1e1 100644 --- a/Sources/AtprotoOAuth/Session/Session+TokenHandling.swift +++ b/Sources/AtprotoOAuth/Session/Session+TokenHandling.swift @@ -141,7 +141,7 @@ extension Atproto { public func session( for issuingServer: String, - dpopKey: OAuth.DPoPKey + dpopKey: DPoPKey ) -> SessionState.Archive { .init( dPopKey: dpopKey, diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index 89caf12..3a8375d 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -174,7 +174,7 @@ public actor AtprotoOAuthSessionImpl { //propagate new state to our in-memory opject properties //then through the async streams - private func save(sessionMutable: OAuth.SessionState.Mutable) throws { + private func save(sessionMutable: SessionState.Mutable) throws { try session.updated(mutable: sessionMutable) saveContinuation.yield(sessionMutable) @@ -251,7 +251,7 @@ extension AtprotoOAuthSessionImpl { extension AtprotoOAuthSessionImpl: AtprotoSession {} extension AtprotoOAuthSessionImpl: OAuthSessionCapabilities { - public var session: OAuth.SessionState { + public var session: SessionState { get throws { guard case .active(let sessionState) = state else { throw OAuthSessionError.sessionInactive @@ -260,13 +260,13 @@ extension AtprotoOAuthSessionImpl: OAuthSessionCapabilities { } } - public func refreshed(sessionMutable: OAuth.SessionState.Mutable) throws { + public func refreshed(sessionMutable: SessionState.Mutable) throws { try save(sessionMutable: sessionMutable) } } extension AtprotoOAuthSessionImpl: DPoPNonceHolding { - public var dpopKey: OAuth.DPoPKey { + public var dpopKey: DPoPKey { get throws { try session.dPopKey.tryUnwrap } @@ -276,7 +276,7 @@ extension AtprotoOAuthSessionImpl: DPoPNonceHolding { public static func decode( dataResponse: HTTPDataResponse, requestUrl: URL, - ) throws -> OAuth.IndexedNonce? { + ) throws -> IndexedNonce? { guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") else { return nil @@ -326,8 +326,8 @@ extension AtprotoOAuthSessionImpl: AuthRequestable { public func validate( authMetadata: AuthServerMetadata, - tokenResponse: OAuth.TokenEndpointResponse - ) throws -> OAuth.SessionState.Mutable { + tokenResponse: TokenEndpointResponse + ) throws -> SessionState.Mutable { //TODO: finish validation .init( From a48f1d42bdb7d5a38af0713f76b958b037bd12f0 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Thu, 5 Mar 2026 12:22:48 +0800 Subject: [PATCH 03/48] working use of low-level oauth primitives to authenticate --- .../CachedAuthenticated/LoginVM.swift | 1 + .../Login/LoginDemoVM.swift | 1 + .../OAuth/AuthorizerCapabilities.swift | 4 +- .../Sources/OAuth/DPoP/DPoPResponse.swift | 38 +++++++++- .../OAuth/Models/TokenEndpointResponse.swift | 10 +-- .../Sources/OAuth/OAuthComponents.swift | 25 ++++--- .../Sources/OAuth/TokenEndpointRequest.swift | 9 ++- .../AuthorizerImpl+AuthRequestable.swift | 71 +++++++++++++++++++ ...apabilities.swift => AuthorizerImpl.swift} | 22 +++--- .../AtprotoOAuth/OAuthClient+Interface.swift | 4 +- Sources/AtprotoOAuth/OAuthClient.swift | 3 + .../AtprotoOAuth/Session/SessionImpl.swift | 6 ++ 12 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift rename Sources/AtprotoOAuth/{AuthorizerCapabilities.swift => AuthorizerImpl.swift} (92%) diff --git a/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift b/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift index 85b06b0..e5fa4b5 100644 --- a/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift +++ b/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift @@ -28,6 +28,7 @@ import os ), userAuthenticator: ASWebAuthenticationSession.userAuthenticator(), responseProvider: URLSession.defaultProvider, + manualRedirectFetcher: URLSession.manualRefreshFetcher, atprotoClient: AtprotoClient( responseProvider: URLSession.defaultProvider ), diff --git a/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift b/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift index 59a68c4..3b8f055 100644 --- a/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift +++ b/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift @@ -23,6 +23,7 @@ import SwiftUI ), userAuthenticator: ASWebAuthenticationSession.userAuthenticator(), responseProvider: URLSession.defaultProvider, + manualRedirectFetcher: URLSession.manualRefreshFetcher, atprotoClient: AtprotoClient( responseProvider: URLSession.defaultProvider ), diff --git a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift index 8eff01e..2a3ecf4 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift @@ -9,7 +9,7 @@ import Foundation import GermConvenience import Logging -public protocol AuthorizerCapabilities: DPoPNonceHolding { +public protocol AuthorizerCapabilities: DPoPNonceHolding, AuthRequestable { var appCredentials: AppCredentials { get } var stateToken: String { get } var pkceVerifier: PKCEVerifier { get } @@ -60,7 +60,7 @@ extension AuthorizerCapabilities { let callbackURL = try await userAuthenticator(tokenURL, scheme) - return try await Self.finishAuthorization( + return try await finishAuthorization( authorizationUrl: tokenURL, stateToken: stateToken, redirectURI: callbackURL, diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift index 3293ff0..dac7ef9 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift @@ -9,13 +9,45 @@ import Crypto import Foundation import GermConvenience -public protocol DPoPSigning { - func cacheNonce(response: URLResponse, requestUrl: URL) +public protocol DPoPSigning: Actor { + var dpopKey: DPoPKey { get throws } + + func getNonce(origin: String) -> IndexedNonce? + func cacheNonce(response: HTTPDataResponse, requestUrl: URL) throws } extension DPoPSigning { - func addProof(request: inout URLRequest) throws { + func addProof( + request: URLRequest, + issuerOrigin: String?, + token: String? + ) throws -> URLRequest { + let requestOrigin = try (request.url?.origin) + .tryUnwrap(DPoPError.requestInvalid(request)) + + let nonce = getNonce(origin: requestOrigin) + + //right now the RFC has SHA256 baked into the RFC and a new draft needed + //to specify alg agility + let tokenHash = token.map { + SHA256.hash(data: $0.utf8Data) + .data.base64URLEncodedString() + } + let jwt = try dpopKey.sign( + payload: .init( + endpointUrl: request.url.tryUnwrap, + httpMethod: request.httpMethod.tryUnwrap( + OAuthError.missingHTTPMethod), + nonce: nonce?.nonce, + issuingServer: issuerOrigin, + accessTokenHash: tokenHash + ) + ) + + var output = request + output.setValue(jwt.string, forHTTPHeaderField: "DPoP") + return output } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift index b043bcd..f1bab29 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift @@ -24,7 +24,7 @@ public struct TokenEndpointResponse { case dpop init(string: String) throws { - switch string { + switch string.lowercased() { case TokenType.dpop.rawValue: self = .dpop default: @@ -47,10 +47,10 @@ extension TokenEndpointResponse: Decodable { // 1. Decode standard keys let container = try decoder.container(keyedBy: CodingKeys.self) accessToken = try container.decode(String.self, forKey: .accessToken) - expiresIn = try container.decode(Int?.self, forKey: .expiresIn) - idToken = try container.decode(String?.self, forKey: .idToken) - refreshToken = try container.decode(String?.self, forKey: .refreshToken) - scope = try container.decode(String?.self, forKey: .scope) + expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn) + idToken = try container.decodeIfPresent(String.self, forKey: .idToken) + refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) + scope = try container.decodeIfPresent(String.self, forKey: .scope) let tokenString = try container.decode(String.self, forKey: .tokenType) tokenType = try .init(string: tokenString) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index f4137da..91e9d8a 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -98,7 +98,7 @@ extension AuthRequestable { manualRedirectFetch: HTTPDataResponse.Requester ) async throws -> HTTPDataResponse { var parameters = additionalParameters - parameters["redirect_url"] = redirectUrl.absoluteString + parameters["redirect_uri"] = redirectUrl.absoluteString parameters["code"] = parsedRedirect.authCode if let verifier { @@ -143,25 +143,32 @@ extension AuthRequestable { modifiedParams["grant_type"] = grantType.rawValue var headers = headers - //swift4web sets the "accept" header, but both may be appropriate headers["accept"] = "application/json" - headers["Content-Type"] = "application/json" - //swift4web sets the "accept" header, but Content-Type seems + headers["content-type"] = "application/json" var request = URLRequest(url: url) - - if let dpopSigner = self as? DPoPSigning { - try dpopSigner.addProof(request: &request) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) } request.httpMethod = HTTPMethod.post.rawValue - request.httpBody = try JSONEncoder().encode(headers) + request.httpBody = try JSONEncoder().encode(modifiedParams) + + //annoyingly compiler doesn't understand cast isolation is the same + if let dpopSigner = self as? DPoPSigning { + request = try await dpopSigner.addProof( + request: request, + //Review: what's correct here + issuerOrigin: authServerMetadata.issuer, + token: nil, + ) + } let response = try await authenticated( request: request, ) if let dpopSigner = self as? DPoPSigning { - try dpopSigner.cacheNonce(response: response.response, requestUrl: url) + try await dpopSigner.cacheNonce(response: response, requestUrl: url) } return response diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index 5576261..4a2f159 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -21,14 +21,17 @@ public protocol AuthRequestable: Actor { authMetadata: AuthServerMetadata, tokenResponse: TokenEndpointResponse ) throws -> SessionState.Mutable - var lazyIssuer: LazyResource { get } + // var lazyIssuer: LazyResource { get } + //want to be able to create a session offline and eventually resolve + //the issuer for the fixed session id + var retriableIssuer: URL { get async throws } } extension AuthRequestable { //initially rely on network stack caching func getAuthServerMetadata() async throws -> AuthServerMetadata { try await authServerDiscovery( - issuer: lazyIssuer.lazyValue(isolation: self) + issuer: try await retriableIssuer ) .successDecode(successCode: 200) } @@ -51,7 +54,7 @@ extension AuthRequestable { let httpResponse = try await authorizationCodeGrantRequest( authServerMetadata: authServerMetadata, - redirectUrl: redirectURI, + redirectUrl: appCredentials.callbackURL, parsedRedirect: parsedRedirect, verifier: pkceVerifier.verifier, additionalParameters: additionalParameters, diff --git a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift new file mode 100644 index 0000000..09bba5a --- /dev/null +++ b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift @@ -0,0 +1,71 @@ +// +// AuthorizerImpl+AuthRequestable.swift +// AtprotoOAuth +// +// Created by Mark @ Germ on 3/5/26. +// + +import Foundation +import GermConvenience +import OAuth + +extension AuthorizerImpl: AuthRequestable { + var retriableIssuer: URL { + issuer + } + + var additionalParameters: [String: String] { + [ + "client_id": appCredentials.clientId, + "redirect_url": appCredentials.callbackURL.absoluteString, + ] + } + + func manualRedirectFetch(request: URLRequest) async throws + -> GermConvenience.HTTPDataResponse + { + try await manualRedirectFetcher(request) + } + + func validate( + authMetadata: OAuth.AuthServerMetadata, tokenResponse: OAuth.TokenEndpointResponse + ) throws -> OAuth.SessionState.Mutable { + //TODO: finish validation + + .init( + accessToken: .init( + value: tokenResponse.accessToken, expiresIn: tokenResponse.expiresIn + ), + refreshToken: .init(refreshToken: tokenResponse.refreshToken), + scopes: tokenResponse.scope, + //REVIEW: where should this come from? + issuingServer: authMetadata.issuer + ) + } +} + +extension AuthorizerImpl: DPoPSigning { + func cacheNonce(response: HTTPDataResponse, requestUrl: URL) throws { + let indexedNonce = try Self.decode(dataResponse: response, requestUrl: requestUrl) + if let indexedNonce { + nonceCache.setObject(indexedNonce, forKey: indexedNonce.origin as NSString) + } + } + + public static func decode( + dataResponse: HTTPDataResponse, + requestUrl: URL, + ) throws -> IndexedNonce? { + guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") + else { + return nil + } + + //henceforth should throw instead of return nil as nonce is expected + return try IndexedNonce( + responseUrl: dataResponse.response.url, + requestUrl: requestUrl, + nonce: nonce + ) + } +} diff --git a/Sources/AtprotoOAuth/AuthorizerCapabilities.swift b/Sources/AtprotoOAuth/AuthorizerImpl.swift similarity index 92% rename from Sources/AtprotoOAuth/AuthorizerCapabilities.swift rename to Sources/AtprotoOAuth/AuthorizerImpl.swift index 865b09c..f2c60cb 100644 --- a/Sources/AtprotoOAuth/AuthorizerCapabilities.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl.swift @@ -1,5 +1,5 @@ // -// PreSession.swift +// AuthorizerImpl.swift // AtprotoOAuth // // Created by Mark @ Germ on 2/26/26. @@ -19,18 +19,25 @@ actor AuthorizerImpl { let appCredentials: AppCredentials let httpRequester: HTTPDataResponse.Requester + let manualRedirectFetcher: HTTPDataResponse.Requester + + let issuer: URL let stateToken = UUID().uuidString let dpopKey = DPoPKey.generateP256() - private let nonceCache: NSCache = NSCache() + let nonceCache: NSCache = NSCache() let pkceVerifier = PKCEVerifier() init( + issuer: URL, appCredentials: AppCredentials, - httpRequester: @escaping HTTPDataResponse.Requester + httpRequester: @escaping HTTPDataResponse.Requester, + manualRedirectFetcher: @escaping HTTPDataResponse.Requester ) { + self.issuer = issuer self.appCredentials = appCredentials self.httpRequester = httpRequester + self.manualRedirectFetcher = manualRedirectFetcher } } @@ -46,15 +53,6 @@ extension AuthorizerImpl: DPoPNonceHolding { ) } - public static func decode( - dataResponse: HTTPDataResponse, - requestUrl: URL - ) throws -> IndexedNonce? { - try AtprotoOAuthSessionImpl.decode( - dataResponse: dataResponse, - requestUrl: requestUrl - ) - } } extension AuthorizerImpl: AuthorizerCapabilities { diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index 7d3d613..879e23e 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -67,8 +67,10 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { ) return try await AuthorizerImpl( + issuer: authorizationServerUrl, appCredentials: appCredentials, - httpRequester: httpRequester + httpRequester: httpRequester, + manualRedirectFetcher: manualRedirectFetcher ) .performUserAuthentication( parConfig: parConfig, diff --git a/Sources/AtprotoOAuth/OAuthClient.swift b/Sources/AtprotoOAuth/OAuthClient.swift index c506749..c3393c1 100644 --- a/Sources/AtprotoOAuth/OAuthClient.swift +++ b/Sources/AtprotoOAuth/OAuthClient.swift @@ -24,6 +24,7 @@ public struct AtprotoOAuthClient: Sendable { public nonisolated let appCredentials: AppCredentials public let userAuthenticator: UserAuthenticator public let httpRequester: HTTPDataResponse.Requester + let manualRedirectFetcher: HTTPDataResponse.Requester public let atprotoClient: AtprotoClientInterface let oauthMetadataFetcher: OAuthMetadataFetcher @@ -34,6 +35,7 @@ public struct AtprotoOAuthClient: Sendable { appCredentials: AppCredentials, userAuthenticator: @escaping UserAuthenticator, responseProvider: @escaping HTTPDataResponse.Requester, + manualRedirectFetcher: @escaping HTTPDataResponse.Requester, atprotoClient: AtprotoClientInterface, oauthMetadataFetcher: OAuthMetadataFetcher, ) { @@ -42,5 +44,6 @@ public struct AtprotoOAuthClient: Sendable { self.httpRequester = responseProvider self.atprotoClient = atprotoClient self.oauthMetadataFetcher = oauthMetadataFetcher + self.manualRedirectFetcher = manualRedirectFetcher } } diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index 3a8375d..613bb28 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -311,6 +311,12 @@ extension AtprotoOAuthSessionImpl { } extension AtprotoOAuthSessionImpl: AuthRequestable { + public var retriableIssuer: URL { + get async throws { + try await lazyIssuer.lazyValue(isolation: self) + } + } + public var additionalParameters: [String: String] { [ "client_id": appCredentials.clientId, From 4b72db6c0634c89fe32fafd026940974e6b63685 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Fri, 6 Mar 2026 00:20:07 +0800 Subject: [PATCH 04/48] demoing cut over to components mirroring oauth4web --- .../OAuth/AuthorizerCapabilities.swift | 188 ++++++----- .../Sources/OAuth/DPoP/DPoPResponse.swift | 302 +++++++++--------- .../Models/Metadata/AuthorizationServer.swift | 3 + .../OAuth/Models/PARConfiguration.swift | 5 +- .../Sources/OAuth/OAuthComponents.swift | 45 +++ .../Session/OAuthSession+AuthRequest.swift | 119 ++++++- .../OAuth/Session/SessionCapabilities.swift | 9 +- .../Sources/OAuth/TokenEndpointRequest.swift | 78 +++-- .../AuthorizerImpl+AuthRequestable.swift | 4 + Sources/AtprotoOAuth/AuthorizerImpl.swift | 224 ++++++------- .../AtprotoOAuth/OAuthClient+Interface.swift | 3 - .../Session/Session+TokenHandling.swift | 169 ---------- .../AtprotoOAuth/Session/SessionImpl.swift | 74 ++--- 13 files changed, 604 insertions(+), 619 deletions(-) delete mode 100644 Sources/AtprotoOAuth/Session/Session+TokenHandling.swift diff --git a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift index 2a3ecf4..a5ec7e0 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift @@ -9,9 +9,10 @@ import Foundation import GermConvenience import Logging -public protocol AuthorizerCapabilities: DPoPNonceHolding, AuthRequestable { +public protocol AuthorizerCapabilities: AuthRequestable, DPoPSigning { var appCredentials: AppCredentials { get } var stateToken: String { get } + //now mandatory for all var pkceVerifier: PKCEVerifier { get } static func authorizationURL( @@ -20,22 +21,22 @@ public protocol AuthorizerCapabilities: DPoPNonceHolding, AuthRequestable { clientId: String, ) throws -> URL - static func finishAuthorization( - authorizationUrl: URL, - stateToken: String, - redirectURI: URL, - pkceVerifier: PKCEVerifier, - appCredentials: AppCredentials, - authServerMetadata: AuthServerMetadata, - dpopKey: DPoPKey, //only for archiving - dpopRequester: HTTPDataResponse.Requester - ) async throws -> SessionState.Archive - - associatedtype TokenResponse - static func tokenSubscriberValidator( - response: TokenResponse, - sub: String - ) async throws + // static func finishAuthorization( + // authorizationUrl: URL, + // stateToken: String, + // redirectURI: URL, + // pkceVerifier: PKCEVerifier, + // appCredentials: AppCredentials, + // authServerMetadata: AuthServerMetadata, + // dpopKey: DPoPKey, //only for archiving + // dpopRequester: HTTPDataResponse.Requester + // ) async throws -> SessionState.Archive + + // associatedtype TokenResponse + // static func tokenSubscriberValidator( + // response: TokenResponse, + // sub: String + // ) async throws } extension AuthorizerCapabilities { @@ -44,15 +45,35 @@ extension AuthorizerCapabilities { authServerMetadata: AuthServerMetadata, userAuthenticator: UserAuthenticator ) async throws -> SessionState.Archive { - let parRequestURI = try await getPARRequestURI( + let challenge = pkceVerifier.challenge + let scopes = appCredentials.requestedScopes.joined(separator: " ") + let callbackURI = appCredentials.callbackURL + let clientId = appCredentials.clientId + + let parParams = [ + "client_id": clientId, + "state": stateToken, + "scope": scopes, + "response_type": "code", + "redirect_uri": callbackURI.absoluteString, + "code_challenge": challenge.value, + "code_challenge_method": challenge.method, + ].merging(parConfig.parameters, uniquingKeysWith: { a, b in a }) + + let parHTTPResponse = try await pushedAuthorizationRequest( + authServerMetadata: authServerMetadata, appCredentials: appCredentials, - parConfig: parConfig, - stateToken: stateToken, + params: parParams, + headers: [:], + ) + + let parResponse = try OAuthComponents.processPushedAuthorizationResponse( + response: parHTTPResponse ) let tokenURL = try Self.authorizationURL( authEndpoint: authServerMetadata.authorizationEndpoint, - parRequestURI: parRequestURI, + parRequestURI: parResponse.requestURI, clientId: appCredentials.clientId ) @@ -68,73 +89,66 @@ extension AuthorizerCapabilities { appCredentials: appCredentials, authServerMetadata: authServerMetadata, dpopKey: dpopKey, - dpopRequester: { - try await dpopResponse( - for: $0, - issuerOrigin: nil, - token: nil, - ) - } - ) - } - - private func getPARRequestURI( - appCredentials: AppCredentials, - parConfig: PARConfiguration, - stateToken: String, - ) async throws -> String { - let result = try await parRequest( - appCredentials: appCredentials, - url: parConfig.url, - params: parConfig.parameters, - stateToken: stateToken, ) - - Logger(label: "PreSessionInterface") - .debug("Received PAR response that expires in \(result.expiresIn)") - - return result.requestURI } - private func parRequest( - appCredentials: AppCredentials, - url: URL, - params: [String: String], - stateToken: String, - ) async throws -> PARResponse { - let challenge = pkceVerifier.challenge - let scopes = appCredentials.requestedScopes.joined(separator: " ") - let callbackURI = appCredentials.callbackURL - let clientId = appCredentials.clientId - - var request = URLRequest(url: url) - request.httpMethod = HTTPMethod.post.rawValue - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue( - "application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - let base: [String: String] = [ - "client_id": clientId, - "state": stateToken, - "scope": scopes, - "response_type": "code", - "redirect_uri": callbackURI.absoluteString, - "code_challenge": challenge.value, - "code_challenge_method": challenge.method, - ] - - let body = - params - .merging(base, uniquingKeysWith: { a, b in a }) - .map({ [$0, $1].joined(separator: "=") }) - .joined(separator: "&") - - request.httpBody = body.utf8Data - - return try await dpopResponse( - for: request, - issuerOrigin: nil, - token: nil, - ).successDecode() - } + // private func getPARRequestURI( + // appCredentials: AppCredentials, + // parConfig: PARConfiguration, + // stateToken: String, + // ) async throws -> String { + // let result = try await parRequest( + // appCredentials: appCredentials, + // url: parConfig.url, + // params: parConfig.parameters, + // stateToken: stateToken, + // ) + // + // Logger(label: "PreSessionInterface") + // .debug("Received PAR response that expires in \(result.expiresIn)") + // + // return result.requestURI + // } + + // private func parRequest( + // appCredentials: AppCredentials, + // url: URL, + // params: [String: String], + // stateToken: String, + // ) async throws -> PARResponse { + // let challenge = pkceVerifier.challenge + // let scopes = appCredentials.requestedScopes.joined(separator: " ") + // let callbackURI = appCredentials.callbackURL + // let clientId = appCredentials.clientId + // + // var request = URLRequest(url: url) + // request.httpMethod = HTTPMethod.post.rawValue + // request.setValue("application/json", forHTTPHeaderField: "Accept") + // request.setValue( + // "application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + // + // let base: [String: String] = [ + // "client_id": clientId, + // "state": stateToken, + // "scope": scopes, + // "response_type": "code", + // "redirect_uri": callbackURI.absoluteString, + // "code_challenge": challenge.value, + // "code_challenge_method": challenge.method, + // ] + // + // let body = + // params + // .merging(base, uniquingKeysWith: { a, b in a }) + // .map({ [$0, $1].joined(separator: "=") }) + // .joined(separator: "&") + // + // request.httpBody = body.utf8Data + // + // return try await dpopResponse( + // for: request, + // issuerOrigin: nil, + // token: nil, + // ).successDecode() + // } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift index dac7ef9..3ab5599 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift @@ -51,154 +51,154 @@ extension DPoPSigning { } } -public protocol DPoPNonceHolding: Actor { - var dpopKey: DPoPKey { get throws } - - func getNonce(origin: String) -> IndexedNonce? - func store(indexedNonce: IndexedNonce) - var httpRequester: HTTPDataResponse.Requester { get } - - //should return nil if nonce is not present and throw if it - //incorrectly parses - static func decode( - dataResponse: HTTPDataResponse, - requestUrl: URL //if the response object is missing a URL, as fallback - ) throws -> IndexedNonce? -} - -extension DPoPNonceHolding { - //needs to be actor constrained so it can safely mutate the nonce cache - //takes a base request, adds a dpop token, retrying if needed - - //this method is shared with the session object and the initial login - func dpopResponse( - for request: URLRequest, - issuerOrigin: String?, - token: String?, - ) async throws -> HTTPDataResponse { - var request = request - - //right now the RFC has SHA256 baked into the RFC and a new draft needed - //to specify alg agility - let tokenHash = token.map { - SHA256.hash(data: $0.utf8Data) - .data.base64URLEncodedString() - } - - // Requests must have a URL with an origin: - let requestOrigin = try (request.url?.origin) - .tryUnwrap(DPoPError.requestInvalid(request)) - - let initNonce = getNonce(origin: requestOrigin) - - let method = try request.httpMethod.tryUnwrap(OAuthError.missingHTTPMethod) - let requestUrl = try request.url.tryUnwrap(OAuthError.missingUrl) - - let jwt = try dpopKey.sign( - payload: .init( - endpointUrl: requestUrl, - httpMethod: method, - nonce: initNonce?.nonce, - issuingServer: issuerOrigin, - accessTokenHash: tokenHash - ) - ) - - request.setValue(jwt.string, forHTTPHeaderField: "DPoP") - - if let token { - request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") - } - - let dataResponse = try await httpRequester(request) - - // Extract the next nonce value if any; if we don't have a new nonce, return the response: - let nextNonce = try Self.decode( - dataResponse: dataResponse, - requestUrl: requestUrl - ) - guard let nextNonce else { - return dataResponse - } - - // If the response doesn't have a new nonce, or the new nonce is the same as - // the current nonce for the same origin, return the response: - if nextNonce.origin == initNonce?.origin && nextNonce.nonce == initNonce?.nonce { - return dataResponse - } - store(indexedNonce: nextNonce) - - //FIXME: revised logic - let isAuthServer: Bool? = { - if let issuerOrigin { - issuerOrigin == requestOrigin - } else { - nil - } - }() - - //fixme: adopt logic from OAuthenticator pr 50 - let shouldRetry = Self.isUseDpopError( - dataResponse: dataResponse, isAuthServer: isAuthServer - ) - if !shouldRetry { - return dataResponse - } - - // repeat once, using newly-established nonce - let secondJwt = try dpopKey.sign( - payload: .init( - endpointUrl: requestUrl, - httpMethod: method, - nonce: nextNonce.nonce, - issuingServer: issuerOrigin, - accessTokenHash: tokenHash - ) - ) - request.setValue(secondJwt.string, forHTTPHeaderField: "DPoP") - let retryDataResponse = try await httpRequester(request) - - let retryNonce = try Self.decode( - dataResponse: retryDataResponse, - requestUrl: requestUrl - ) - if let retryNonce { - store(indexedNonce: retryNonce) - } - - return retryDataResponse - } - - // The logic here is taken from: - // https://github.com/bluesky-social/atproto/blob/4e96e2c7/packages/oauth/oauth-client/src/fetch-dpop.ts#L195 - private static func isUseDpopError( - dataResponse: HTTPDataResponse, isAuthServer: Bool? - ) -> Bool { - // https://datatracker.ietf.org/doc/html/rfc6750#section-3 - // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no - - switch (isAuthServer, dataResponse.response.statusCode) { - case (let authServer, 401) where authServer != true: - if let wwwAuthHeader = dataResponse.response.value( - forHTTPHeaderField: "WWW-Authenticate") - { - if wwwAuthHeader.starts(with: "DPoP") { - return wwwAuthHeader.contains("error=\"use_dpop_nonce\"") - } - } - // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid - case (let authServer, 400) where authServer != false: - do { - let err = try JSONDecoder().decode( - OAuthErrorResponse.self, from: dataResponse.data) - return err.error == "use_dpop_nonce" - } catch { - return false - } - default: - return false - } - - return false - } -} +//public protocol DPoPNonceHolding: Actor { +// var dpopKey: DPoPKey { get throws } +// +// func getNonce(origin: String) -> IndexedNonce? +// func store(indexedNonce: IndexedNonce) +// var httpRequester: HTTPDataResponse.Requester { get } +// +// //should return nil if nonce is not present and throw if it +// //incorrectly parses +// static func decode( +// dataResponse: HTTPDataResponse, +// requestUrl: URL //if the response object is missing a URL, as fallback +// ) throws -> IndexedNonce? +//} +// +//extension DPoPNonceHolding { +// //needs to be actor constrained so it can safely mutate the nonce cache +// //takes a base request, adds a dpop token, retrying if needed +// +// //this method is shared with the session object and the initial login +// func dpopResponse( +// for request: URLRequest, +// issuerOrigin: String?, +// token: String?, +// ) async throws -> HTTPDataResponse { +// var request = request +// +// //right now the RFC has SHA256 baked into the RFC and a new draft needed +// //to specify alg agility +// let tokenHash = token.map { +// SHA256.hash(data: $0.utf8Data) +// .data.base64URLEncodedString() +// } +// +// // Requests must have a URL with an origin: +// let requestOrigin = try (request.url?.origin) +// .tryUnwrap(DPoPError.requestInvalid(request)) +// +// let initNonce = getNonce(origin: requestOrigin) +// +// let method = try request.httpMethod.tryUnwrap(OAuthError.missingHTTPMethod) +// let requestUrl = try request.url.tryUnwrap(OAuthError.missingUrl) +// +// let jwt = try dpopKey.sign( +// payload: .init( +// endpointUrl: requestUrl, +// httpMethod: method, +// nonce: initNonce?.nonce, +// issuingServer: issuerOrigin, +// accessTokenHash: tokenHash +// ) +// ) +// +// request.setValue(jwt.string, forHTTPHeaderField: "DPoP") +// +// if let token { +// request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") +// } +// +// let dataResponse = try await httpRequester(request) +// +// // Extract the next nonce value if any; if we don't have a new nonce, return the response: +// let nextNonce = try Self.decode( +// dataResponse: dataResponse, +// requestUrl: requestUrl +// ) +// guard let nextNonce else { +// return dataResponse +// } +// +// // If the response doesn't have a new nonce, or the new nonce is the same as +// // the current nonce for the same origin, return the response: +// if nextNonce.origin == initNonce?.origin && nextNonce.nonce == initNonce?.nonce { +// return dataResponse +// } +// store(indexedNonce: nextNonce) +// +// //FIXME: revised logic +// let isAuthServer: Bool? = { +// if let issuerOrigin { +// issuerOrigin == requestOrigin +// } else { +// nil +// } +// }() +// +// //fixme: adopt logic from OAuthenticator pr 50 +// let shouldRetry = Self.isUseDpopError( +// dataResponse: dataResponse, isAuthServer: isAuthServer +// ) +// if !shouldRetry { +// return dataResponse +// } +// +// // repeat once, using newly-established nonce +// let secondJwt = try dpopKey.sign( +// payload: .init( +// endpointUrl: requestUrl, +// httpMethod: method, +// nonce: nextNonce.nonce, +// issuingServer: issuerOrigin, +// accessTokenHash: tokenHash +// ) +// ) +// request.setValue(secondJwt.string, forHTTPHeaderField: "DPoP") +// let retryDataResponse = try await httpRequester(request) +// +// let retryNonce = try Self.decode( +// dataResponse: retryDataResponse, +// requestUrl: requestUrl +// ) +// if let retryNonce { +// store(indexedNonce: retryNonce) +// } +// +// return retryDataResponse +// } +// +// The logic here is taken from: +// https://github.com/bluesky-social/atproto/blob/4e96e2c7/packages/oauth/oauth-client/src/fetch-dpop.ts#L195 +// private static func isUseDpopError( +// dataResponse: HTTPDataResponse, isAuthServer: Bool? +// ) -> Bool { +// // https://datatracker.ietf.org/doc/html/rfc6750#section-3 +// // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no +// +// switch (isAuthServer, dataResponse.response.statusCode) { +// case (let authServer, 401) where authServer != true: +// if let wwwAuthHeader = dataResponse.response.value( +// forHTTPHeaderField: "WWW-Authenticate") +// { +// if wwwAuthHeader.starts(with: "DPoP") { +// return wwwAuthHeader.contains("error=\"use_dpop_nonce\"") +// } +// } +// // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid +// case (let authServer, 400) where authServer != false: +// do { +// let err = try JSONDecoder().decode( +// OAuthErrorResponse.self, from: dataResponse.data) +// return err.error == "use_dpop_nonce" +// } catch { +// return false +// } +// default: +// return false +// } +// +// return false +// } +//} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift index 48d012a..34d32e4 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift @@ -69,6 +69,7 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { enum Endpoint { case authorization case token + case pushedAuthorizationRequest var metadataPath: KeyPath { switch self { @@ -76,6 +77,8 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { \.authorizationEndpoint case .token: \.tokenEndpoint + case .pushedAuthorizationRequest: + \.pushedAuthorizationRequestEndpoint } } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/PARConfiguration.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/PARConfiguration.swift index 44b99af..9058d38 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/PARConfiguration.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/PARConfiguration.swift @@ -8,15 +8,14 @@ import Foundation public struct PARConfiguration: Hashable, Sendable { - public let url: URL public let parameters: [String: String] - public init(url: URL, parameters: [String: String] = [:]) { - self.url = url + public init(parameters: [String: String] = [:]) { self.parameters = parameters } } +///https://datatracker.ietf.org/doc/html/rfc9126#name-successful-response public struct PARResponse: Codable, Hashable, Sendable { public let requestURI: String public let expiresIn: Int diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 91e9d8a..f4d269b 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -10,6 +10,12 @@ import GermConvenience ///Direct analog to oauth4web's OAuth module in providing stateless API as building blocks for a full client public enum OAuthComponents { + static public func processPushedAuthorizationResponse( + response: HTTPDataResponse + ) throws -> PARResponse { + try response.successDecode(successCode: 201) + } + static public func validateAuthResponse( authServerMetadata: AuthServerMetadata, redirectURL: URL, @@ -174,6 +180,45 @@ extension AuthRequestable { return response } + //todo: unify with OAuthSessionCapabilities.retryNonceRequest + func nonceRetryAuthenticated( + request: URLRequest, + issuerOrigin: String?, + token: String? + ) async throws -> HTTPDataResponse { + let response = try await authenticated( + request: request + ) + + if let dpopSigner = self as? DPoPSigning { + try await dpopSigner.cacheNonce( + response: response, + requestUrl: request.url.tryUnwrap + ) + + //retry if nonceError + if response.isDPoPNonceError { + let request = try await dpopSigner.addProof( + request: request, + issuerOrigin: issuerOrigin, + token: token + ) + + let secondResponse = try await authenticated( + request: request + ) + + try await dpopSigner.cacheNonce( + response: response, + requestUrl: request.url.tryUnwrap + ) + return secondResponse + } + } + + return response + } + //here for shadowing of oauth4web.authenticatedRequest //but most functionality has been lifted out func authenticated( diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index 03ce9eb..197a1e5 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -18,35 +18,93 @@ extension OAuthSessionCapabilities { ) let issuerOrigin = try URL(string: serverMetadata.issuer).tryUnwrap.origin - let dataResponse = try await dpopResponse( - for: request, - issuerOrigin: issuerOrigin, - token: sessionState.mutable.accessToken.value, - ) + let result = try await retryNonceRequest(request: request) // FIXME: This isn't really to spec: 401 doesn't mean "refresh", it just means unauthorized. - switch dataResponse.response.statusCode { + switch result.response.statusCode { case 200..<300: - return dataResponse + return result case 401: break default: - throw OAuthError.httpResponse(response: dataResponse.response) + throw OAuthError.httpResponse(response: result.response) } //try to refresh the token let refreshed = try await conservingRefresh(state: sessionState) - //try again - return try await dpopResponse( - for: request, - issuerOrigin: issuerOrigin, - token: refreshed.accessToken.value, + return try await retryNonceRequest(request: request) + } + func retryNonceRequest( + request: URLRequest, + ) async throws -> HTTPDataResponse { + let response = try await protectedResourceRequest( + request: request ) + //retry if nonceError + if response.isDPoPNonceError { + return try await protectedResourceRequest( + request: request + ) + } + return response + } + + //needs to have optional access to a dpopSigner, so it is a method + //on a OAutSessionCapabilities and not a static method + func protectedResourceRequest( + request: URLRequest, + ) async throws -> HTTPDataResponse { + let session = try session + + return try await resourceRequest( + accessToken: session.mutable.accessToken.value, + tokenIssuerOrigin: session.mutable.issuingServer, + request: request + ) + } + + func resourceRequest( + accessToken: String, + tokenIssuerOrigin: String?, + request: URLRequest, + ) async throws -> HTTPDataResponse { + var request = request + + if let dpopSigner = self as? DPoPSigning { + request = try await dpopSigner.addProof( + request: request, + issuerOrigin: tokenIssuerOrigin, + token: accessToken + ) + request.setValue("DPoP", forHTTPHeaderField: "authorization") + } else { + request.setValue("Bearer", forHTTPHeaderField: "authorization") + } + + let response = try await manualRedirectFetch(request: request) + + if let dpopSigner = self as? DPoPSigning { + try await dpopSigner.cacheNonce( + response: response, + requestUrl: request.url.tryUnwrap + ) + } + + return response } + // async function resourceRequest( + // accessToken: string, + // method: string, + // url: URL, + // headers?: Headers, + // body?: ProtectedResourceRequestBody, + // options?: ProtectedResourceRequestOptions, + // ): Promise { + //conserving in that it reuses result if a refresh is alread in flght private func conservingRefresh(state: SessionState) async throws -> SessionState.Mutable { if let refreshTask { @@ -54,8 +112,8 @@ extension OAuthSessionCapabilities { } let newRefreshTask = Task { - try await refreshProvider( - sessionState: state.archive, + try await refresh( + state: state, appCredentials: appCredentials ) } @@ -90,3 +148,34 @@ extension OAuthSessionCapabilities { } } + +extension HTTPDataResponse { + + ///is very different from oauth4web that seems to just parse the header + var isDPoPNonceError: Bool { + switch response.statusCode { + case 401: + //this only works if it is the first challenge in the header error + if let wwwAuthHeader = response.value( + forHTTPHeaderField: "WWW-Authenticate") + { + if wwwAuthHeader.starts(with: "DPoP") { + return wwwAuthHeader.contains("error=\"use_dpop_nonce\"") + } + } + // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid + case 400: + do { + let err = try JSONDecoder().decode( + OAuthErrorResponse.self, from: data) + return err.error == "use_dpop_nonce" + } catch { + return false + } + default: + return false + } + + return false + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift index b5168e1..25bcbb0 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift @@ -8,7 +8,7 @@ import Crypto import Foundation -public protocol OAuthSessionCapabilities: Actor, TokenHandling, DPoPNonceHolding, AuthRequestable { +public protocol OAuthSessionCapabilities: Actor, AuthRequestable { var appCredentials: AppCredentials { get } var lazyServerMetadata: LazyResource { get } @@ -17,10 +17,3 @@ public protocol OAuthSessionCapabilities: Actor, TokenHandling, DPoPNonceHolding func refreshed(sessionMutable: SessionState.Mutable) throws var refreshTask: Task? { get set } } - -public protocol TokenHandling { - func refreshProvider( - sessionState: SessionState.Archive, - appCredentials: AppCredentials, - ) async throws -> SessionState.Mutable -} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index 4a2f159..bdd9752 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -44,7 +44,6 @@ extension AuthRequestable { appCredentials: AppCredentials, authServerMetadata: AuthServerMetadata, dpopKey: DPoPKey, - dpopRequester: (URLRequest) async throws -> HTTPDataResponse ) async throws -> SessionState.Archive { let parsedRedirect = try OAuthComponents.validateAuthResponse( authServerMetadata: authServerMetadata, @@ -69,39 +68,6 @@ extension AuthRequestable { let mutable = try validate(authMetadata: authServerMetadata, tokenResponse: result) return .init(dPopKey: dpopKey, additionalParams: nil, mutable: mutable) - - // let result = try await dpopRequester(request) - // .successErrorDecode( - // resultType: Atproto.TokenResponse.self, - // errorType: Atproto.TokenError.self, - // ) - - // switch result { - // case .result(let tokenResponse): - // guard tokenResponse.tokenType == "DPoP" else { - // throw OAuthClientError.dpopTokenExpected( - // tokenResponse.tokenType) - // } - // - // try await Self.tokenSubscriberValidator( - // response: tokenResponse, - // sub: authServerMetadata.issuer - // ) - // - // return tokenResponse.session( - // for: parsedRedirect.issuer, - // dpopKey: dpopKey - // ) - // case .error(let tokenError, let statusCode): - - // if tokenError.errorDescription == "Code challenge already used" { - // throw OAuthClientError.codeChallengeAlreadyUsed - // } - // Self.logger.error( - // "Login error: \(tokenError.errorDescription), with status code \(statusCode)" - // ) - // throw OAuthClientError.remoteTokenError(tokenError) - // } } private func authServerDiscovery(issuer: URL) async throws -> HTTPDataResponse { @@ -139,4 +105,48 @@ extension AuthRequestable { return result } + + func pushedAuthorizationRequest( + authServerMetadata: AuthServerMetadata, + appCredentials: AppCredentials, + params: [String: String], + headers: [String: String], + ) async throws -> HTTPDataResponse { + let parEndpoint = try authServerMetadata.resolve( + endpoint: .pushedAuthorizationRequest) + + var bodyParams = params + bodyParams["client_id"] = appCredentials.clientId + + var headers = headers + headers["accept"] = "application/json" + + var request = URLRequest(url: parEndpoint) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + request.httpMethod = HTTPMethod.post.rawValue + let paramsString = + try bodyParams + .map({ [$0, $1].joined(separator: "=") }) + .joined(separator: "&") + request.httpBody = paramsString.utf8Data + + if let dpopSigner = self as? DPoPSigning { + request = try await dpopSigner.addProof( + request: request, + //Review: what's correct here + issuerOrigin: nil, + token: nil, + ) + } + + let response = try await nonceRetryAuthenticated( + request: request, + issuerOrigin: nil, + token: nil + ) + + return response + } } diff --git a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift index 09bba5a..a871604 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift @@ -45,6 +45,10 @@ extension AuthorizerImpl: AuthRequestable { } extension AuthorizerImpl: DPoPSigning { + func getNonce(origin: String) -> OAuth.IndexedNonce? { + nonceCache.object(forKey: origin as NSString) + } + func cacheNonce(response: HTTPDataResponse, requestUrl: URL) throws { let indexedNonce = try Self.decode(dataResponse: response, requestUrl: requestUrl) if let indexedNonce { diff --git a/Sources/AtprotoOAuth/AuthorizerImpl.swift b/Sources/AtprotoOAuth/AuthorizerImpl.swift index f2c60cb..a2a736e 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl.swift @@ -41,19 +41,19 @@ actor AuthorizerImpl { } } -extension AuthorizerImpl: DPoPNonceHolding { - public func getNonce(origin: String) -> IndexedNonce? { - nonceCache.object(forKey: origin as NSString) - } - - public func store(indexedNonce: IndexedNonce) { - nonceCache.setObject( - indexedNonce, - forKey: indexedNonce.origin as NSString - ) - } - -} +//extension AuthorizerImpl: DPoPNonceHolding { +// public func getNonce(origin: String) -> IndexedNonce? { +// nonceCache.object(forKey: origin as NSString) +// } +// +// public func store(indexedNonce: IndexedNonce) { +// nonceCache.setObject( +// indexedNonce, +// forKey: indexedNonce.origin as NSString +// ) +// } +// +//} extension AuthorizerImpl: AuthorizerCapabilities { public static func authorizationURL( @@ -75,103 +75,103 @@ extension AuthorizerImpl: AuthorizerCapabilities { return url } - static func finishAuthorization( - authorizationUrl: URL, - stateToken: String, - redirectURI: URL, - pkceVerifier: PKCEVerifier, - appCredentials: AppCredentials, - authServerMetadata: AuthServerMetadata, - dpopKey: DPoPKey, - dpopRequester: (URLRequest) async throws -> HTTPDataResponse - ) async throws -> SessionState.Archive { - // decode the params in the redirectURL - let redirectComponents = try URLComponents( - url: redirectURI, - resolvingAgainstBaseURL: false - ).tryUnwrap(OAuthClientError.missingTokenURL) - - guard - let authCode = redirectComponents.queryItems?.first(where: { - $0.name == "code" - })?.value, - let iss = redirectComponents.queryItems?.first(where: { - $0.name == "iss" - })?.value, - let state = redirectComponents.queryItems?.first(where: { - $0.name == "state" - })?.value - else { - throw OAuthClientError.missingAuthorizationCode - } - - if state != stateToken { - throw OAuthClientError.stateTokenMismatch(state, stateToken) - } - - if iss != authServerMetadata.issuer { - throw - OAuthClientError - .issuingServerMismatch(iss, authServerMetadata.issuer) - } - - // and use them (plus just a little more) to construct the token request - let tokenURL = try URL(string: authServerMetadata.tokenEndpoint) - .tryUnwrap(OAuthClientError.missingTokenURL) - - let tokenRequest = Atproto.TokenRequest( - code: authCode, - codeVerifier: pkceVerifier.verifier, - redirectUri: appCredentials.callbackURL.absoluteString, - grantType: "authorization_code", - clientId: appCredentials.clientId - ) - - var request = URLRequest(url: tokenURL) - - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpBody = try JSONEncoder().encode(tokenRequest) - - let result = try await dpopRequester(request) - .successErrorDecode( - resultType: Atproto.TokenResponse.self, - errorType: Atproto.TokenError.self, - ) - - switch result { - case .result(let tokenResponse): - guard tokenResponse.tokenType == "DPoP" else { - throw OAuthClientError.dpopTokenExpected( - tokenResponse.tokenType) - } - - try await Self.tokenSubscriberValidator( - response: tokenResponse, - sub: authServerMetadata.issuer - ) - - return tokenResponse.session(for: iss, dpopKey: dpopKey) - case .error(let tokenError, let statusCode): - if tokenError.errorDescription == "Code challenge already used" { - throw OAuthClientError.codeChallengeAlreadyUsed - } - Self.logger.error( - "Login error: \(tokenError.errorDescription), with status code \(statusCode)" - ) - throw OAuthClientError.remoteTokenError(tokenError) - } - } - - static func tokenSubscriberValidator( - response: Atproto.TokenResponse, - sub: String - ) async throws { - // TODO: GER-1343 - Implement validator - // after a token is issued, it is critical that the returned - // identity be resolved and its PDS match the issuing server - // - // check out draft-ietf-oauth-v2-1 section 7.3.1 for details - } + // static func finishAuthorization( + // authorizationUrl: URL, + // stateToken: String, + // redirectURI: URL, + // pkceVerifier: PKCEVerifier, + // appCredentials: AppCredentials, + // authServerMetadata: AuthServerMetadata, + // dpopKey: DPoPKey, + // dpopRequester: (URLRequest) async throws -> HTTPDataResponse + // ) async throws -> SessionState.Archive { + // // decode the params in the redirectURL + // let redirectComponents = try URLComponents( + // url: redirectURI, + // resolvingAgainstBaseURL: false + // ).tryUnwrap(OAuthClientError.missingTokenURL) + // + // guard + // let authCode = redirectComponents.queryItems?.first(where: { + // $0.name == "code" + // })?.value, + // let iss = redirectComponents.queryItems?.first(where: { + // $0.name == "iss" + // })?.value, + // let state = redirectComponents.queryItems?.first(where: { + // $0.name == "state" + // })?.value + // else { + // throw OAuthClientError.missingAuthorizationCode + // } + // + // if state != stateToken { + // throw OAuthClientError.stateTokenMismatch(state, stateToken) + // } + // + // if iss != authServerMetadata.issuer { + // throw + // OAuthClientError + // .issuingServerMismatch(iss, authServerMetadata.issuer) + // } + // + // // and use them (plus just a little more) to construct the token request + // let tokenURL = try URL(string: authServerMetadata.tokenEndpoint) + // .tryUnwrap(OAuthClientError.missingTokenURL) + // + // let tokenRequest = Atproto.TokenRequest( + // code: authCode, + // codeVerifier: pkceVerifier.verifier, + // redirectUri: appCredentials.callbackURL.absoluteString, + // grantType: "authorization_code", + // clientId: appCredentials.clientId + // ) + // + // var request = URLRequest(url: tokenURL) + // + // request.httpMethod = "POST" + // request.setValue("application/json", forHTTPHeaderField: "Content-Type") + // request.setValue("application/json", forHTTPHeaderField: "Accept") + // request.httpBody = try JSONEncoder().encode(tokenRequest) + // + // let result = try await dpopRequester(request) + // .successErrorDecode( + // resultType: Atproto.TokenResponse.self, + // errorType: Atproto.TokenError.self, + // ) + // + // switch result { + // case .result(let tokenResponse): + // guard tokenResponse.tokenType == "DPoP" else { + // throw OAuthClientError.dpopTokenExpected( + // tokenResponse.tokenType) + // } + // + // try await Self.tokenSubscriberValidator( + // response: tokenResponse, + // sub: authServerMetadata.issuer + // ) + // + // return tokenResponse.session(for: iss, dpopKey: dpopKey) + // case .error(let tokenError, let statusCode): + // if tokenError.errorDescription == "Code challenge already used" { + // throw OAuthClientError.codeChallengeAlreadyUsed + // } + // Self.logger.error( + // "Login error: \(tokenError.errorDescription), with status code \(statusCode)" + // ) + // throw OAuthClientError.remoteTokenError(tokenError) + // } + // } + + // static func tokenSubscriberValidator( + // response: Atproto.TokenResponse, + // sub: String + // ) async throws { + // // TODO: GER-1343 - Implement validator + // // after a token is issued, it is critical that the returned + // // identity be resolved and its PDS match the issuing server + // // + // // check out draft-ietf-oauth-v2-1 section 7.3.1 for details + // } } diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index 879e23e..c8e8b49 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -60,9 +60,6 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { ) let parConfig = PARConfiguration( - url: try URL( - string: authServerMetadata.pushedAuthorizationRequestEndpoint - ).tryUnwrap, parameters: ["login_hint": identity.serverHint] ) diff --git a/Sources/AtprotoOAuth/Session/Session+TokenHandling.swift b/Sources/AtprotoOAuth/Session/Session+TokenHandling.swift deleted file mode 100644 index d2dd1e1..0000000 --- a/Sources/AtprotoOAuth/Session/Session+TokenHandling.swift +++ /dev/null @@ -1,169 +0,0 @@ -// -// Session+TokenHandling.swift -// AtprotoOAuth -// -// Created by Mark @ Germ on 2/25/26. -// - -import AtprotoTypes -import Foundation -import OAuth - -extension AtprotoOAuthSessionImpl: TokenHandling { - public func refreshProvider( - sessionState: SessionState.Archive, - appCredentials: AppCredentials - ) async throws -> SessionState.Mutable { - let refreshToken = try sessionState.mutable.refreshToken.tryUnwrap - let serverMetadata = try await lazyServerMetadata.lazyValue( - isolation: self - ) - - let tokenURL = try URL(string: serverMetadata.tokenEndpoint) - .tryUnwrap(OAuthSessionError.cantFormURL) - - let tokenRequest = Atproto.RefreshTokenRequest( - refreshToken: refreshToken.value, - redirectUri: appCredentials.callbackURL.absoluteString, - grantType: "refresh_token", - clientId: appCredentials.clientId - ) - - var request = URLRequest(url: tokenURL) - - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(tokenRequest) - - let tokenResponse: Atproto.TokenResponse = try await httpRequester(request) - .successDecode() - - guard tokenResponse.tokenType == "DPoP" else { - throw - OAuthSessionError - .expectedDpopToken(tokenResponse.tokenType) - } - - try await tokenSubscriberValidator( - response: tokenResponse, - sub: serverMetadata.issuer - ) - - return tokenResponse.refreshOutput(for: serverMetadata.issuer) - } - - //throws if invalid - private func tokenSubscriberValidator( - response: Atproto.TokenResponse, - sub: String - ) async throws { - // TODO: GER-1343 - Implement validator - // after a token is issued, it is critical that the returned - // identity be resolved and its PDS match the issuing server - // - // check out draft-ietf-oauth-v2-1 section 7.3.1 for details - } -} - -extension Atproto { - struct TokenRequest: Hashable, Sendable, Codable { - public let code: String - public let codeVerifier: String - public let redirectUri: String - public let grantType: String - public let clientId: String - - public init( - code: String, - codeVerifier: String, - redirectUri: String, - grantType: String, - clientId: String - ) { - self.code = code - self.codeVerifier = codeVerifier - self.redirectUri = redirectUri - self.grantType = grantType - self.clientId = clientId - } - - public enum CodingKeys: String, CodingKey { - case code - case codeVerifier = "code_verifier" - case redirectUri = "redirect_uri" - case grantType = "grant_type" - case clientId = "client_id" - } - } - - struct RefreshTokenRequest: Hashable, Sendable, Codable { - public let refreshToken: String - public let redirectUri: String - public let grantType: String - public let clientId: String - - public init( - refreshToken: String, - redirectUri: String, - grantType: String, - clientId: String - ) { - self.refreshToken = refreshToken - self.redirectUri = redirectUri - self.grantType = grantType - self.clientId = clientId - } - - public enum CodingKeys: String, CodingKey { - case refreshToken = "refresh_token" - case redirectUri = "redirect_uri" - case grantType = "grant_type" - case clientId = "client_id" - } - } - - struct TokenResponse: Hashable, Sendable, Codable { - public let accessToken: String - public let refreshToken: String? - public let sub: String - public let scope: String - public let tokenType: String - public let expiresIn: Int - - public func refreshOutput(for issuingServer: String) -> SessionState.Mutable { - .init( - accessToken: .init(value: accessToken, expiresIn: expiresIn), - refreshToken: refreshToken.map { Token(value: $0) }, - scopes: scope, - issuingServer: issuingServer - ) - } - - public func session( - for issuingServer: String, - dpopKey: DPoPKey - ) -> SessionState.Archive { - .init( - dPopKey: dpopKey, - additionalParams: ["did": sub], - mutable: .init( - accessToken: .init( - value: accessToken, expiresIn: expiresIn), - refreshToken: refreshToken.map { .init(value: $0) }, - scopes: scope, - issuingServer: issuingServer, - ) - ) - } - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case sub - case scope - case tokenType = "token_type" - case expiresIn = "expires_in" - - } - } -} diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index 613bb28..2785960 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -265,43 +265,43 @@ extension AtprotoOAuthSessionImpl: OAuthSessionCapabilities { } } -extension AtprotoOAuthSessionImpl: DPoPNonceHolding { - public var dpopKey: DPoPKey { - get throws { - try session.dPopKey.tryUnwrap - } - } - - //throws if we are unable to construct the origin (missing host of - public static func decode( - dataResponse: HTTPDataResponse, - requestUrl: URL, - ) throws -> IndexedNonce? { - guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") - else { - return nil - } - - //henceforth should throw instead of return nil as nonce is expected - return try IndexedNonce( - responseUrl: dataResponse.response.url, - requestUrl: requestUrl, - nonce: nonce - ) - } - - public func getNonce(origin: String) -> IndexedNonce? { - nonceCache.object(forKey: origin as NSString) - } - - public func store(indexedNonce: IndexedNonce) { - nonceCache.setObject( - indexedNonce, - forKey: indexedNonce.origin as NSString - ) - - } -} +//extension AtprotoOAuthSessionImpl: DPoPNonceHolding { +// public var dpopKey: DPoPKey { +// get throws { +// try session.dPopKey.tryUnwrap +// } +// } +// +// //throws if we are unable to construct the origin (missing host of +// public static func decode( +// dataResponse: HTTPDataResponse, +// requestUrl: URL, +// ) throws -> IndexedNonce? { +// guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") +// else { +// return nil +// } +// +// //henceforth should throw instead of return nil as nonce is expected +// return try IndexedNonce( +// responseUrl: dataResponse.response.url, +// requestUrl: requestUrl, +// nonce: nonce +// ) +// } +// +// public func getNonce(origin: String) -> IndexedNonce? { +// nonceCache.object(forKey: origin as NSString) +// } +// +// public func store(indexedNonce: IndexedNonce) { +// nonceCache.setObject( +// indexedNonce, +// forKey: indexedNonce.origin as NSString +// ) +// +// } +//} extension AtprotoOAuthSessionImpl { func getPDSUrl() async throws -> URL { From 4b35555b3d1c28019b93251050486fc06122ca59 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Fri, 6 Mar 2026 16:13:34 +0800 Subject: [PATCH 05/48] shouldn't embed the issuing server in the DPoP proof --- .../oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift | 2 -- LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigner.swift | 4 ---- LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift | 4 ---- .../Sources/OAuth/Session/OAuthSession+AuthRequest.swift | 3 --- .../oauth4swift/Sources/OAuth/TokenEndpointRequest.swift | 2 -- 5 files changed, 15 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift index 3ab5599..5d612f1 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift @@ -19,7 +19,6 @@ public protocol DPoPSigning: Actor { extension DPoPSigning { func addProof( request: URLRequest, - issuerOrigin: String?, token: String? ) throws -> URLRequest { let requestOrigin = try (request.url?.origin) @@ -39,7 +38,6 @@ extension DPoPSigning { httpMethod: request.httpMethod.tryUnwrap( OAuthError.missingHTTPMethod), nonce: nonce?.nonce, - issuingServer: issuerOrigin, accessTokenHash: tokenHash ) ) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigner.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigner.swift index 97a2a5b..0a63b2e 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigner.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigner.swift @@ -19,7 +19,6 @@ struct DPoPRequestPayload: Codable, Hashable, Sendable { /// UNIX type, seconds since epoch let expiresAt: Int let nonce: String? - let authorizationServerIssuer: String? let accessTokenHash: String? enum CodingKeys: String, CodingKey { @@ -29,7 +28,6 @@ struct DPoPRequestPayload: Codable, Hashable, Sendable { case createdAt = "iat" case expiresAt = "exp" case nonce - case authorizationServerIssuer = "iss" case accessTokenHash = "ath" } @@ -37,7 +35,6 @@ struct DPoPRequestPayload: Codable, Hashable, Sendable { endpointUrl: URL, httpMethod: String, nonce: String?, - issuingServer: String?, accessTokenHash: String? ) throws { self.uniqueCode = UUID().uuidString @@ -46,7 +43,6 @@ struct DPoPRequestPayload: Codable, Hashable, Sendable { self.createdAt = Int(Date.now.timeIntervalSince1970) self.expiresAt = Int(Date.now.timeIntervalSince1970 + 3600) self.nonce = nonce - self.authorizationServerIssuer = issuingServer self.accessTokenHash = accessTokenHash } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index f4d269b..2fe68c4 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -164,8 +164,6 @@ extension AuthRequestable { if let dpopSigner = self as? DPoPSigning { request = try await dpopSigner.addProof( request: request, - //Review: what's correct here - issuerOrigin: authServerMetadata.issuer, token: nil, ) } @@ -183,7 +181,6 @@ extension AuthRequestable { //todo: unify with OAuthSessionCapabilities.retryNonceRequest func nonceRetryAuthenticated( request: URLRequest, - issuerOrigin: String?, token: String? ) async throws -> HTTPDataResponse { let response = try await authenticated( @@ -200,7 +197,6 @@ extension AuthRequestable { if response.isDPoPNonceError { let request = try await dpopSigner.addProof( request: request, - issuerOrigin: issuerOrigin, token: token ) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index 197a1e5..f347a58 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -61,14 +61,12 @@ extension OAuthSessionCapabilities { return try await resourceRequest( accessToken: session.mutable.accessToken.value, - tokenIssuerOrigin: session.mutable.issuingServer, request: request ) } func resourceRequest( accessToken: String, - tokenIssuerOrigin: String?, request: URLRequest, ) async throws -> HTTPDataResponse { var request = request @@ -76,7 +74,6 @@ extension OAuthSessionCapabilities { if let dpopSigner = self as? DPoPSigning { request = try await dpopSigner.addProof( request: request, - issuerOrigin: tokenIssuerOrigin, token: accessToken ) request.setValue("DPoP", forHTTPHeaderField: "authorization") diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index bdd9752..9b6999d 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -136,14 +136,12 @@ extension AuthRequestable { request = try await dpopSigner.addProof( request: request, //Review: what's correct here - issuerOrigin: nil, token: nil, ) } let response = try await nonceRetryAuthenticated( request: request, - issuerOrigin: nil, token: nil ) From a3896235693cfc4cc6a43ef5d0b601c26faceeca Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 06:27:05 +0800 Subject: [PATCH 06/48] separate out expectSuccess and result decoding --- .../AtprotoClient/Get/PLCDirectoryQuery.swift | 3 +- .../GermConvenience/HTTPDataResponse.swift | 53 ++++++++++--------- .../Models/Metadata/AuthorizationServer.swift | 3 +- .../OAuth/Models/Metadata/Metadata.swift | 6 ++- .../Sources/OAuth/OAuthComponents.swift | 9 +++- .../Sources/OAuth/TokenEndpointRequest.swift | 3 +- 6 files changed, 44 insertions(+), 33 deletions(-) diff --git a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/PLCDirectoryQuery.swift b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/PLCDirectoryQuery.swift index 273ef2a..b5e8c80 100644 --- a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/PLCDirectoryQuery.swift +++ b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/PLCDirectoryQuery.swift @@ -18,7 +18,8 @@ extension AtprotoClient { request.addValue("application/json", forHTTPHeaderField: "Accept") return try await responseProvider(request) - .successDecode() + .expectSuccess() + .decode() } private func constructPlcQueryUrl(did: Atproto.DID) throws -> URL { diff --git a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift index fb0d83b..df2f73a 100644 --- a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift +++ b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift @@ -7,7 +7,7 @@ import Foundation -//type the (data, responese) tuple so we can chain handlers +//type the (data, response) tuple so we can chain handlers //these patterns are available in Vapor public struct HTTPDataResponse: Sendable { public let data: Data @@ -20,16 +20,12 @@ public struct HTTPDataResponse: Sendable { self.response = response } - public func successDecode( - successCode: Int - ) throws -> R { - try successDecode(successCodes: successCode...successCode) + public func expect(successCode: Int) throws -> Data { + try expectSuccess(range: successCode...successCode) } - public func successDecode( - successCodes: RangeExpression = 200..<300 - ) throws -> R { - guard successCodes.contains(response.statusCode) else { + public func expectSuccess(range: RangeExpression = 200..<300) throws -> Data { + guard range.contains(response.statusCode) else { if let stringResponse = String(data: data, encoding: .utf8) { throw HTTPResponseError @@ -38,7 +34,7 @@ public struct HTTPDataResponse: Sendable { throw HTTPResponseError.unsuccessful(response.statusCode, data) } } - return try JSONDecoder().decode(R.self, from: data) + return data } public enum ErrorResult { @@ -48,25 +44,30 @@ public struct HTTPDataResponse: Sendable { public func successErrorDecode( resultType: R.Type, - errorType: E.Type + errorType: E.Type, + successRange: RangeExpression = 200..<300 ) throws -> ErrorResult { - guard response.statusCode >= 200 && response.statusCode < 300 else { - do { - let decoded = try JSONDecoder().decode(E.self, from: data) - return .error(decoded, response.statusCode) - } catch { - if let stringResponse = String(data: data, encoding: .utf8) { - throw - HTTPResponseError - .unsuccessfulString( - response.statusCode, stringResponse) - } else { - throw HTTPResponseError.unsuccessful( - response.statusCode, data) - } + do { + let result: R = try expectSuccess(range: successRange) + .decode() + return .result(result) + } catch { + if let stringResponse = String(data: data, encoding: .utf8) { + throw + HTTPResponseError + .unsuccessfulString( + response.statusCode, stringResponse) + } else { + throw HTTPResponseError.unsuccessful( + response.statusCode, data) } } - return try .result(JSONDecoder().decode(R.self, from: data)) + } +} + +extension Data { + public func decode() throws -> R { + try JSONDecoder().decode(R.self, from: self) } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift index 34d32e4..7394abf 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift @@ -63,7 +63,8 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") return try await httpRequester(request) - .successDecode() + .expectSuccess() + .decode() } enum Endpoint { diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift index 716eb40..855ed47 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift @@ -36,7 +36,8 @@ public struct ClientMetadata: Hashable, Codable, Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") return try await httpRequester(request) - .successDecode() + .expectSuccess() + .decode() } } @@ -110,6 +111,7 @@ public struct ProtectedResourceMetadata: Codable, Hashable, Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") return try await httpRequester(request) - .successDecode() + .expectSuccess() + .decode() } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 2fe68c4..9a82c2c 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -13,7 +13,9 @@ public enum OAuthComponents { static public func processPushedAuthorizationResponse( response: HTTPDataResponse ) throws -> PARResponse { - try response.successDecode(successCode: 201) + try response + .expect(successCode: 201) + .decode() } static public func validateAuthResponse( @@ -74,7 +76,10 @@ public enum OAuthComponents { static func processGenericAccessToken( response: HTTPDataResponse ) throws -> TokenEndpointResponse { - let decoded: TokenEndpointResponse = try response.successDecode(successCode: 200) + let decoded: TokenEndpointResponse = + try response + .expect(successCode: 200) + .decode() return decoded } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index 9b6999d..8d03b0f 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -33,7 +33,8 @@ extension AuthRequestable { try await authServerDiscovery( issuer: try await retriableIssuer ) - .successDecode(successCode: 200) + .expect(successCode: 200) + .decode() } func finishAuthorization( From b36ac0b167bb44aa356e2f58feaf1e4c55e3e6a0 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 07:29:40 +0800 Subject: [PATCH 07/48] the jwt only needs request scheme, origin, and path, but explicitly not the query parameters or hash fragment. --- .../Sources/OAuth/DPoP/DPoPResponse.swift | 2 +- .../Sources/OAuth/DPoP/URL+targetURI.swift | 32 +++++++++++++++++++ .../Sources/OAuth/Url+Origin.swift | 2 +- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 LocalPackages/oauth4swift/Sources/OAuth/DPoP/URL+targetURI.swift diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift index 5d612f1..39df51e 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift @@ -34,7 +34,7 @@ extension DPoPSigning { } let jwt = try dpopKey.sign( payload: .init( - endpointUrl: request.url.tryUnwrap, + endpointUrl: (request.url?.targetURI).tryUnwrap, httpMethod: request.httpMethod.tryUnwrap( OAuthError.missingHTTPMethod), nonce: nonce?.nonce, diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/URL+targetURI.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/URL+targetURI.swift new file mode 100644 index 0000000..9bd4d66 --- /dev/null +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/URL+targetURI.swift @@ -0,0 +1,32 @@ +// +// URL+targetURI.swift +// OAuth +// +// Created by Emelia Smith on 3/7/26. +// + +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension URL { + var targetURI: URL? { + guard + let host = self.host, + let scheme = self.scheme + else { + return nil + } + + var originComponents = URLComponents() + originComponents.scheme = scheme + originComponents.host = host + originComponents.path = self.relativePath + + originComponents.port = nonDefaultHTTPort() + + return originComponents.url + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Url+Origin.swift b/LocalPackages/oauth4swift/Sources/OAuth/Url+Origin.swift index 998921c..54c4a0d 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Url+Origin.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Url+Origin.swift @@ -18,7 +18,7 @@ extension URL { } } - private func nonDefaultHTTPort() -> Int? { + func nonDefaultHTTPort() -> Int? { switch (scheme, port) { case ("http", 80): nil case ("https", 443): nil From c62db2ceb6e2a0778f174dbb5c407f5446e9dec1 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 07:31:36 +0800 Subject: [PATCH 08/48] pushedAuthorizationRequest -> .par --- .../oauth4swift/Sources/OAuth/DPoP/IndexedNonce.swift | 2 +- .../Sources/OAuth/Models/Metadata/AuthorizationServer.swift | 4 ++-- .../oauth4swift/Sources/OAuth/TokenEndpointRequest.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/IndexedNonce.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/IndexedNonce.swift index bc0d789..2e680da 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/IndexedNonce.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/IndexedNonce.swift @@ -16,7 +16,7 @@ public final class IndexedNonce { public let nonce: String public convenience init( - responseUrl: URL?, // + responseUrl: URL?, requestUrl: URL, nonce: String ) throws { diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift index 7394abf..36c7bdf 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift @@ -70,7 +70,7 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { enum Endpoint { case authorization case token - case pushedAuthorizationRequest + case par var metadataPath: KeyPath { switch self { @@ -78,7 +78,7 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { \.authorizationEndpoint case .token: \.tokenEndpoint - case .pushedAuthorizationRequest: + case .par: \.pushedAuthorizationRequestEndpoint } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index 8d03b0f..5ba03af 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -114,7 +114,7 @@ extension AuthRequestable { headers: [String: String], ) async throws -> HTTPDataResponse { let parEndpoint = try authServerMetadata.resolve( - endpoint: .pushedAuthorizationRequest) + endpoint: .par) var bodyParams = params bodyParams["client_id"] = appCredentials.clientId From 789698415e54c25f2b994450f227e9d23dc0c6d9 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 08:44:41 +0800 Subject: [PATCH 09/48] update AuthServerMetadata with full types, courtesy of https://tangled.org/baileytownsend.dev/gulliver/blob/main/Sources/Gulliver/OAuthTypes.swift --- .../OAuth/AuthorizerCapabilities.swift | 2 +- .../Models/Metadata/AuthorizationServer.swift | 202 +++++++++++++++--- Sources/AtprotoOAuth/AuthorizerImpl.swift | 18 +- .../AtprotoOAuthTests/ATProtoOAuthTests.swift | 2 + 4 files changed, 179 insertions(+), 45 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift index a5ec7e0..444894c 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift @@ -16,7 +16,7 @@ public protocol AuthorizerCapabilities: AuthRequestable, DPoPSigning { var pkceVerifier: PKCEVerifier { get } static func authorizationURL( - authEndpoint: String, + authEndpoint: URL, parRequestURI: String, clientId: String, ) throws -> URL diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift index 36c7bdf..1ba846b 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift @@ -10,24 +10,143 @@ import GermConvenience // See: https://www.rfc-editor.org/rfc/rfc8414.html public struct AuthServerMetadata: Codable, Hashable, Sendable { + /// The authorization server's issuer identifier public let issuer: String - public let authorizationEndpoint: String - public let tokenEndpoint: String - public let responseTypesSupported: [String] - public let grantTypesSupported: [String] - public let codeChallengeMethodsSupported: [String] - public let tokenEndpointAuthMethodsSupported: [String] - public let tokenEndpointAuthSigningAlgValuesSupported: [String] - public let scopesSupported: [String] - public let authorizationResponseIssParameterSupported: Bool - public let requirePushedAuthorizationRequests: Bool - public let pushedAuthorizationRequestEndpoint: String - public let dpopSigningAlgValuesSupported: [String] - public let requireRequestUriRegistration: Bool - public let clientIdMetadataDocumentSupported: Bool + + /// Array of claim types supported + let claimsSupported: [String]? + + /// Languages and scripts supported for claims + let claimsLocalesSupported: [String]? + + /// Whether the claims parameter is supported + let claimsParameterSupported: Bool? + + /// Whether the request parameter is supported + let requestParameterSupported: Bool? + + /// Whether the request_uri parameter is supported + let requestUriParameterSupported: Bool? + + /// Whether request_uri values must be pre-registered + let requireRequestUriRegistration: Bool? + + /// Array of OAuth 2.0 scope values supported + let scopesSupported: [String]? + + /// Subject identifier types supported + let subjectTypesSupported: [String]? + + /// Response types supported + let responseTypesSupported: [String]? + + /// Response modes supported + let responseModesSupported: [String]? + + /// Grant types supported + let grantTypesSupported: [String]? + + /// PKCE code challenge methods supported + let codeChallengeMethodsSupported: [String]? + + /// Languages and scripts supported for UI + let uiLocalesSupported: [String]? + + /// Algorithms supported for signing ID tokens + let idTokenSigningAlgValuesSupported: [String]? + + /// Display values supported + let displayValuesSupported: [String]? + + /// Prompt values supported + let promptValuesSupported: [String]? + + /// Algorithms supported for signing request objects + let requestObjectSigningAlgValuesSupported: [String]? + + /// Whether authorization response issuer parameter is supported + let authorizationResponseIssParameterSupported: Bool? + + /// Authorization details types supported + let authorizationDetailsTypesSupported: [String]? + + /// Algorithms supported for encrypting request objects + let requestObjectEncryptionAlgValuesSupported: [String]? + + /// Encryption encodings supported for request objects + let requestObjectEncryptionEncValuesSupported: [String]? + + /// URL of the authorization server's JWK Set document + let jwksUri: URL? + + /// URL of the authorization endpoint + let authorizationEndpoint: URL + + /// URL of the token endpoint + let tokenEndpoint: URL + + /// Authentication methods supported at token endpoint (RFC 8414 Section 2) + let tokenEndpointAuthMethodsSupported: [String]? + + /// Signing algorithms supported for token endpoint authentication + let tokenEndpointAuthSigningAlgValuesSupported: [String]? + + /// URL of the revocation endpoint + let revocationEndpoint: URL? + + /// Authentication methods supported at revocation endpoint + let revocationEndpointAuthMethodsSupported: [String]? + + /// Signing algorithms supported for revocation endpoint authentication + let revocationEndpointAuthSigningAlgValuesSupported: [String]? + + /// URL of the introspection endpoint + let introspectionEndpoint: URL? + + /// Authentication methods supported at introspection endpoint + let introspectionEndpointAuthMethodsSupported: [String]? + + /// Signing algorithms supported for introspection endpoint authentication + let introspectionEndpointAuthSigningAlgValuesSupported: [String]? + + /// URL of the pushed authorization request endpoint + let pushedAuthorizationRequestEndpoint: URL? + + /// Authentication methods supported at PAR endpoint + let pushedAuthorizationRequestEndpointAuthMethodsSupported: [String]? + + /// Signing algorithms supported for PAR endpoint authentication + let pushedAuthorizationRequestEndpointAuthSigningAlgValuesSupported: [String]? + + /// Whether pushed authorization requests are required + let requirePushedAuthorizationRequests: Bool? + + /// URL of the UserInfo endpoint + let userinfoEndpoint: URL? + + /// URL of the end session endpoint + let endSessionEndpoint: URL? + + /// URL of the dynamic client registration endpoint + let registrationEndpoint: URL? + + /// DPoP signing algorithms supported (RFC 9449 Section 5.1) + let dpopSigningAlgValuesSupported: [String]? + + /// Protected resource URIs (RFC 9728 Section 4) + let protectedResources: [URL]? + + /// Whether client ID metadata document is supported + let clientIdMetadataDocumentSupported: Bool? enum CodingKeys: String, CodingKey { case issuer + case claimsSupported = "claims_supported" + case claimsLocalesSupported = "claims_locales_supported" + case claimsParameterSupported = "claims_parameter_supported" + case requestParameterSupported = "request_parameter_supported" + case requestUriParameterSupported = "request_uri_parameter_supported" + case requireRequestUriRegistration = "require_request_uri_registration" case authorizationEndpoint = "authorization_endpoint" case tokenEndpoint = "token_endpoint" case responseTypesSupported = "response_types_supported" @@ -42,8 +161,40 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { case requirePushedAuthorizationRequests = "require_pushed_authorization_requests" case pushedAuthorizationRequestEndpoint = "pushed_authorization_request_endpoint" case dpopSigningAlgValuesSupported = "dpop_signing_alg_values_supported" - case requireRequestUriRegistration = "require_request_uri_registration" case clientIdMetadataDocumentSupported = "client_id_metadata_document_supported" + + case responseModesSupported = "response_modes_supported" + case subjectTypesSupported = "subject_types_supported" + case uiLocalesSupported = "ui_locales_supported" + case idTokenSigningAlgValuesSupported = "id_token_signing_alg_values_supported" + case displayValuesSupported = "display_values_supported" + case promptValuesSupported = "prompt_values_supported" + case requestObjectSigningAlgValuesSupported = + "request_object_signing_alg_values_supported" + case authorizationDetailsTypesSupported = "authorization_details_types_supported" + case requestObjectEncryptionAlgValuesSupported = + "request_object_encryption_alg_values_supported" + case requestObjectEncryptionEncValuesSupported = + "request_object_encryption_enc_values_supported" + case jwksUri = "jwks_uri" + case revocationEndpoint = "revocation_endpoint" + case revocationEndpointAuthMethodsSupported = + "revocation_endpoint_auth_methods_supported" + case revocationEndpointAuthSigningAlgValuesSupported = + "revocation_endpoint_auth_signing_alg_values_supported" + case introspectionEndpoint = "introspection_endpoint" + case introspectionEndpointAuthMethodsSupported = + "introspection_endpoint_auth_methods_supported" + case introspectionEndpointAuthSigningAlgValuesSupported = + "introspection_endpoint_auth_signing_alg_values_supported" + case pushedAuthorizationRequestEndpointAuthMethodsSupported = + "pushed_authorization_request_endpoint_auth_methods_supported" + case pushedAuthorizationRequestEndpointAuthSigningAlgValuesSupported = + "pushed_authorization_request_endpoint_auth_signing_alg_values_supported" + case userinfoEndpoint = "userinfo_endpoint" + case endSessionEndpoint = "end_session_endpoint" + case registrationEndpoint = "registration_endpoint" + case protectedResources = "protected_resources" } //deprecate @@ -71,24 +222,19 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { case authorization case token case par + } - var metadataPath: KeyPath { - switch self { + //for our purposes require secure + func resolve(endpoint: Endpoint) throws -> URL { + let url: URL = + switch endpoint { case .authorization: - \.authorizationEndpoint + authorizationEndpoint case .token: - \.tokenEndpoint + tokenEndpoint case .par: - \.pushedAuthorizationRequestEndpoint + try pushedAuthorizationRequestEndpoint.tryUnwrap } - } - } - - //for our purposes require secure - func resolve(endpoint: Endpoint) throws -> URL { - let url = try URL( - string: self[keyPath: endpoint.metadataPath] - ).tryUnwrap guard url.scheme == "https" else { throw OAuthError.insecureScheme diff --git a/Sources/AtprotoOAuth/AuthorizerImpl.swift b/Sources/AtprotoOAuth/AuthorizerImpl.swift index a2a736e..0ab6730 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl.swift @@ -41,27 +41,13 @@ actor AuthorizerImpl { } } -//extension AuthorizerImpl: DPoPNonceHolding { -// public func getNonce(origin: String) -> IndexedNonce? { -// nonceCache.object(forKey: origin as NSString) -// } -// -// public func store(indexedNonce: IndexedNonce) { -// nonceCache.setObject( -// indexedNonce, -// forKey: indexedNonce.origin as NSString -// ) -// } -// -//} - extension AuthorizerImpl: AuthorizerCapabilities { public static func authorizationURL( - authEndpoint: String, + authEndpoint: URL, parRequestURI: String, clientId: String, ) throws -> URL { - var components = URLComponents(string: authEndpoint) + var components = URLComponents(url: authEndpoint, resolvingAgainstBaseURL: false) components?.queryItems = [ URLQueryItem(name: "request_uri", value: parRequestURI), diff --git a/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift b/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift index 4980e36..ad2b17c 100644 --- a/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift +++ b/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift @@ -32,6 +32,7 @@ struct APITests { ), userAuthenticator: AtprotoClient.failingUserAuthenticator(_:_:), responseProvider: URLSession.defaultProvider, + manualRedirectFetcher: URLSession.manualRefreshFetcher, atprotoClient: MockAtprotoClient(), oauthMetadataFetcher: HTTPOAuthMetadataFetcher( httpRequester: URLSession.defaultProvider @@ -59,6 +60,7 @@ struct ClientAPITests { ), userAuthenticator: AtprotoClient.failingUserAuthenticator(_:_:), responseProvider: URLSession.defaultProvider, + manualRedirectFetcher: URLSession.manualRefreshFetcher, atprotoClient: MockAtprotoClient(), oauthMetadataFetcher: HTTPOAuthMetadataFetcher( httpRequester: URLSession.defaultProvider From fe5062266a1460d80b16d33fc48617775c574c1f Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 08:48:03 +0800 Subject: [PATCH 10/48] bsky.social -> social.example in mock server metadata --- .../Sources/OAuth/Models/Metadata/AuthorizationServer.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift index 1ba846b..f3210d0 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift @@ -245,10 +245,11 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { } extension AuthServerMetadata { + ///from bsky.social static func mock() throws -> Self { let data = """ - {"issuer":"https://bsky.social","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://bsky.social/oauth/jwks","authorization_endpoint":"https://bsky.social/oauth/authorize","token_endpoint":"https://bsky.social/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://bsky.social/oauth/revoke","pushed_authorization_request_endpoint":"https://bsky.social/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true} + {"issuer":"https://social.example","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://social.example/oauth/jwks","authorization_endpoint":"https://social.example/oauth/authorize","token_endpoint":"https://social.example/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://social.example/oauth/revoke","pushed_authorization_request_endpoint":"https://social.example/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"client_id_metadata_document_supported":true} """.utf8Data return try JSONDecoder().decode(AuthServerMetadata.self, from: data) From 0a802419f8ee64385cf33e3d62bf34f35bb7900a Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 08:50:54 +0800 Subject: [PATCH 11/48] don't need ClientMetadata --- ...Metadata.swift => ProtectedResource.swift} | 49 +------------------ .../Sources/OAuth/OAuthMetadataFetcher.swift | 8 --- 2 files changed, 1 insertion(+), 56 deletions(-) rename LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/{Metadata.swift => ProtectedResource.swift} (64%) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/ProtectedResource.swift similarity index 64% rename from LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift rename to LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/ProtectedResource.swift index 855ed47..6700689 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/Metadata.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/ProtectedResource.swift @@ -1,5 +1,5 @@ // -// Metadata.swift +// ProtectedResource.swift // OAuth // // Created by Mark @ Germ on 2/23/26 from OAuthenticator @@ -12,53 +12,6 @@ enum MetadataError: Error { case urlInvalid } -// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ -public struct ClientMetadata: Hashable, Codable, Sendable { - public let clientId: String - public let scope: String - public let redirectURIs: [String] - public let dpopBoundAccessTokens: Bool - - enum CodingKeys: String, CodingKey { - case clientId = "client_id" - case scope - case redirectURIs = "redirect_uris" - case dpopBoundAccessTokens = "dpop_bound_access_tokens" - } - - public static func load( - for clientId: String, - httpRequester: HTTPDataResponse.Requester - ) async throws -> ClientMetadata { - let url = try URL(string: clientId).tryUnwrap(MetadataError.urlInvalid) - - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Accept") - - return try await httpRequester(request) - .expectSuccess() - .decode() - } -} - -extension ClientMetadata { - - //The client metadata is the declaration of all scopes the app may request - //the app does not have to request them all. this initializer sends them - //all - public var credentials: AppCredentials { - get throws { - let url = try redirectURIs.first.map({ URL(string: $0)! }).tryUnwrap - - return AppCredentials( - clientId: clientId, - scopes: scope.components(separatedBy: " "), - callbackURL: url - ) - } - } -} - // See: https://www.rfc-editor.org/rfc/rfc9728.html public struct ProtectedResourceMetadata: Codable, Hashable, Sendable { public let resource: String diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift index 13dd5a2..97d34a2 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift @@ -17,10 +17,6 @@ public protocol OAuthMetadataFetcher: Sendable { func fetchMetadata( authServerHost: String ) async throws -> AuthServerMetadata - - func fetchMetadata( - clientHost: String - ) async throws -> ClientMetadata } public struct HTTPOAuthMetadataFetcher { @@ -32,10 +28,6 @@ public struct HTTPOAuthMetadataFetcher { } extension HTTPOAuthMetadataFetcher: OAuthMetadataFetcher { - public func fetchMetadata(clientHost: String) async throws -> ClientMetadata { - try await .load(for: clientHost, httpRequester: httpRequester) - } - public func fetchMetadata( authServerHost: String ) async throws -> AuthServerMetadata { From 4f0baa87bb55f52ea3e0bebc954d0b160f80b14d Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 09:02:25 +0800 Subject: [PATCH 12/48] support bearer token, comment out OIDC id_token (still will get picked up in additional fields --- .../OAuth/Models/TokenEndpointResponse.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift index f1bab29..e5a83e0 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift @@ -10,23 +10,28 @@ import Foundation public struct TokenEndpointResponse { public let accessToken: String public let expiresIn: Int? - let idToken: String? + // let idToken: String? public let refreshToken: String? public let scope: String? // let authorizationDetails: - let tokenType: TokenType + public let tokenType: TokenType //capture additional fields let additionalFields: [String: Any]? - enum TokenType: String, Decodable { - // case bearer + //TODO: allow extension for unknown types + //example in oauth4web: RecognizedTokenTypes + //https://github.com/panva/oauth4webapi/blob/aa0de3f77edab0f0d2b1e1f8ddf875de4f72e8e6/src/index.ts#L3211 + public enum TokenType: String, Decodable { + case bearer case dpop init(string: String) throws { switch string.lowercased() { case TokenType.dpop.rawValue: self = .dpop + case TokenType.bearer.rawValue: + self = .bearer default: throw OAuthError.unrecognizedTokenType } @@ -37,7 +42,7 @@ extension TokenEndpointResponse: Decodable { enum CodingKeys: String, CodingKey { case accessToken = "access_token" case expiresIn = "expires_in" - case idToken = "id_token" + // case idToken = "id_token" case refreshToken = "refresh_token" case scope case tokenType = "token_type" @@ -48,7 +53,7 @@ extension TokenEndpointResponse: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) accessToken = try container.decode(String.self, forKey: .accessToken) expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn) - idToken = try container.decodeIfPresent(String.self, forKey: .idToken) + // idToken = try container.decodeIfPresent(String.self, forKey: .idToken) refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) scope = try container.decodeIfPresent(String.self, forKey: .scope) let tokenString = try container.decode(String.self, forKey: .tokenType) From 36476d6ac76495296535f0f1c2969fcceac7c8aa Mon Sep 17 00:00:00 2001 From: germ-mark <123763184+germ-mark@users.noreply.github.com> Date: Sat, 7 Mar 2026 09:09:57 +0800 Subject: [PATCH 13/48] Apply suggestion from @ThisIsMissEm Co-authored-by: Emelia Smith --- .../Sources/OAuth/Session/OAuthSession+AuthRequest.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index f347a58..aab5223 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -76,9 +76,9 @@ extension OAuthSessionCapabilities { request: request, token: accessToken ) - request.setValue("DPoP", forHTTPHeaderField: "authorization") + request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "authorization") } else { - request.setValue("Bearer", forHTTPHeaderField: "authorization") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "authorization") } let response = try await manualRedirectFetch(request: request) From 68b29d620a38cd75b144b0d35aea443393ddac36 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 09:17:17 +0800 Subject: [PATCH 14/48] verifier -> pkceVerifier --- .../oauth4swift/Sources/OAuth/OAuthComponents.swift | 6 +++--- .../oauth4swift/Sources/OAuth/TokenEndpointRequest.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 9a82c2c..c8a2ebf 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -104,7 +104,7 @@ extension AuthRequestable { authServerMetadata: AuthServerMetadata, redirectUrl: URL, parsedRedirect: OAuthComponents.ParsedRedirect, - verifier: String?, + pkceVerifier: String?, additionalParameters: [String: String], manualRedirectFetch: HTTPDataResponse.Requester ) async throws -> HTTPDataResponse { @@ -112,8 +112,8 @@ extension AuthRequestable { parameters["redirect_uri"] = redirectUrl.absoluteString parameters["code"] = parsedRedirect.authCode - if let verifier { - parameters["code_verifier"] = verifier + if let pkceVerifier { + parameters["code_verifier"] = pkceVerifier } return try await tokenEndpointRequest( diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index 5ba03af..71fc839 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -56,7 +56,7 @@ extension AuthRequestable { authServerMetadata: authServerMetadata, redirectUrl: appCredentials.callbackURL, parsedRedirect: parsedRedirect, - verifier: pkceVerifier.verifier, + pkceVerifier: pkceVerifier.verifier, additionalParameters: additionalParameters, manualRedirectFetch: manualRedirectFetch ) From 6a3e2cbe1c39eca5e6598550c355107a90c0ee32 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 09:18:09 +0800 Subject: [PATCH 15/48] cache the correct response --- LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index c8a2ebf..5871f28 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -210,7 +210,7 @@ extension AuthRequestable { ) try await dpopSigner.cacheNonce( - response: response, + response: secondResponse, requestUrl: request.url.tryUnwrap ) return secondResponse From 6c9535d60337d5e6d928e78f87cbacd9882f7aab Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 21:17:25 +0800 Subject: [PATCH 16/48] deprecate AuthServerMetadata.load for authServerDiscovery --- .../Models/Metadata/AuthorizationServer.swift | 21 ------------------- .../Sources/OAuth/OAuthComponents.swift | 8 +++++-- .../Sources/OAuth/OAuthMetadataFetcher.swift | 14 ------------- .../Session/OAuthSession+AuthRequest.swift | 3 ++- .../Sources/OAuth/TokenEndpointRequest.swift | 15 ++++++++----- 5 files changed, 18 insertions(+), 43 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift index f3210d0..c75e41a 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/AuthorizationServer.swift @@ -197,27 +197,6 @@ public struct AuthServerMetadata: Codable, Hashable, Sendable { case protectedResources = "protected_resources" } - //deprecate - public static func load( - for host: String, - httpRequester: HTTPDataResponse.Requester - ) async throws -> AuthServerMetadata { - var components = URLComponents() - - components.scheme = URLScheme.https.rawValue - components.host = host - components.path = "/.well-known/oauth-authorization-server" - - let url = try components.url.tryUnwrap(MetadataError.urlInvalid) - - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Accept") - - return try await httpRequester(request) - .expectSuccess() - .decode() - } - enum Endpoint { case authorization case token diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 5871f28..2c08251 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -155,7 +155,7 @@ extension AuthRequestable { var headers = headers headers["accept"] = "application/json" - headers["content-type"] = "application/json" + headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" var request = URLRequest(url: url) for (key, value) in headers { @@ -163,7 +163,11 @@ extension AuthRequestable { } request.httpMethod = HTTPMethod.post.rawValue - request.httpBody = try JSONEncoder().encode(modifiedParams) + let paramsString = + try modifiedParams + .map({ [$0, $1].joined(separator: "=") }) + .joined(separator: "&") + request.httpBody = try modifiedParams.urlEncodedHTTPBody //annoyingly compiler doesn't understand cast isolation is the same if let dpopSigner = self as? DPoPSigning { diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift index 97d34a2..5bf6305 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift @@ -26,17 +26,3 @@ public struct HTTPOAuthMetadataFetcher { self.httpRequester = httpRequester } } - -extension HTTPOAuthMetadataFetcher: OAuthMetadataFetcher { - public func fetchMetadata( - authServerHost: String - ) async throws -> AuthServerMetadata { - try await .load(for: authServerHost, httpRequester: httpRequester) - } - - public func fetchMetadata( - protectedResourceHost: String - ) async throws -> ProtectedResourceMetadata { - try await .load(for: protectedResourceHost, httpRequester: httpRequester) - } -} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index aab5223..eb36f23 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -78,7 +78,8 @@ extension OAuthSessionCapabilities { ) request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "authorization") } else { - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "authorization") + request.setValue( + "Bearer \(accessToken)", forHTTPHeaderField: "authorization") } let response = try await manualRedirectFetch(request: request) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index 71fc839..a588454 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -121,17 +121,14 @@ extension AuthRequestable { var headers = headers headers["accept"] = "application/json" + headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" var request = URLRequest(url: parEndpoint) for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } request.httpMethod = HTTPMethod.post.rawValue - let paramsString = - try bodyParams - .map({ [$0, $1].joined(separator: "=") }) - .joined(separator: "&") - request.httpBody = paramsString.utf8Data + request.httpBody = bodyParams.urlEncodedHTTPBody if let dpopSigner = self as? DPoPSigning { request = try await dpopSigner.addProof( @@ -149,3 +146,11 @@ extension AuthRequestable { return response } } + +extension [String: String] { + var urlEncodedHTTPBody: Data { + map({ [$0, $1].joined(separator: "=") }) + .joined(separator: "&") + .utf8Data + } +} From 55387598b30a3687e80cfcc76d39034120d774f1 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 21:23:59 +0800 Subject: [PATCH 17/48] fix typo --- .../Platforms/Apple/URLSession+HTTPRequester.swift | 2 +- Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift b/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift index 0cfb7ce..7780191 100644 --- a/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift +++ b/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift @@ -30,7 +30,7 @@ extension URLSession { URLSession(configuration: .default).responseProvider } - public static var manualRefreshFetcher: HTTPDataResponse.Requester { + public static var manualRedirectFetcher: HTTPDataResponse.Requester { URLSession( configuration: .default, delegate: ManualRedirect(), diff --git a/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift b/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift index ad2b17c..a102e3a 100644 --- a/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift +++ b/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift @@ -32,7 +32,7 @@ struct APITests { ), userAuthenticator: AtprotoClient.failingUserAuthenticator(_:_:), responseProvider: URLSession.defaultProvider, - manualRedirectFetcher: URLSession.manualRefreshFetcher, + manualRedirectFetcher: URLSession.manualRedirectFetcher, atprotoClient: MockAtprotoClient(), oauthMetadataFetcher: HTTPOAuthMetadataFetcher( httpRequester: URLSession.defaultProvider @@ -60,7 +60,7 @@ struct ClientAPITests { ), userAuthenticator: AtprotoClient.failingUserAuthenticator(_:_:), responseProvider: URLSession.defaultProvider, - manualRedirectFetcher: URLSession.manualRefreshFetcher, + manualRedirectFetcher: URLSession.manualRedirectFetcher, atprotoClient: MockAtprotoClient(), oauthMetadataFetcher: HTTPOAuthMetadataFetcher( httpRequester: URLSession.defaultProvider From 219187dc33557a759e3530e2423a9bb24585ecf7 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 21:38:29 +0800 Subject: [PATCH 18/48] specific error for unsupportedDpopSigningAlgorithm --- .../AtprotoOAuth/Session/SessionError.swift | 2 + .../AtprotoOAuth/Session/SessionImpl.swift | 41 +------------------ 2 files changed, 4 insertions(+), 39 deletions(-) diff --git a/Sources/AtprotoOAuth/Session/SessionError.swift b/Sources/AtprotoOAuth/Session/SessionError.swift index 1746728..5b6a650 100644 --- a/Sources/AtprotoOAuth/Session/SessionError.swift +++ b/Sources/AtprotoOAuth/Session/SessionError.swift @@ -12,6 +12,7 @@ enum OAuthSessionError: Error { case sessionInactive case incorrectResponseType case expectedDpopToken(String) + case unsupportedDpopSigningAlgorithm case unsupported } @@ -21,6 +22,7 @@ extension OAuthSessionError: LocalizedError { case .cantFormURL: "can't form URL" case .sessionInactive: "session is inactive" case .incorrectResponseType: "incorrect response type" + case .unsupportedDpopSigningAlgorithm: "Unsupported dpop signing algorithm" case .unsupported: "unsupported" case .expectedDpopToken(let tokenType): "expected dpop token, got \(tokenType) token" diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index 2785960..8d10765 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -82,7 +82,8 @@ public actor AtprotoOAuthSessionImpl { { guard supportedAlgs.contains("ES256") else { - throw OAuthSessionError.unsupported + throw OAuthSessionError + .unsupportedDpopSigningAlgorithm } } @@ -265,44 +266,6 @@ extension AtprotoOAuthSessionImpl: OAuthSessionCapabilities { } } -//extension AtprotoOAuthSessionImpl: DPoPNonceHolding { -// public var dpopKey: DPoPKey { -// get throws { -// try session.dPopKey.tryUnwrap -// } -// } -// -// //throws if we are unable to construct the origin (missing host of -// public static func decode( -// dataResponse: HTTPDataResponse, -// requestUrl: URL, -// ) throws -> IndexedNonce? { -// guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") -// else { -// return nil -// } -// -// //henceforth should throw instead of return nil as nonce is expected -// return try IndexedNonce( -// responseUrl: dataResponse.response.url, -// requestUrl: requestUrl, -// nonce: nonce -// ) -// } -// -// public func getNonce(origin: String) -> IndexedNonce? { -// nonceCache.object(forKey: origin as NSString) -// } -// -// public func store(indexedNonce: IndexedNonce) { -// nonceCache.setObject( -// indexedNonce, -// forKey: indexedNonce.origin as NSString -// ) -// -// } -//} - extension AtprotoOAuthSessionImpl { func getPDSUrl() async throws -> URL { try await atprotoClient.plcDirectoryQuery(did) From 15192adff4724f5f406168b5bd5cc776ac272510 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 21:43:58 +0800 Subject: [PATCH 19/48] idiomatic resource(for: --- .../Session/OAuthSession+AuthRequest.swift | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index eb36f23..607d4cf 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -40,34 +40,30 @@ extension OAuthSessionCapabilities { func retryNonceRequest( request: URLRequest, ) async throws -> HTTPDataResponse { - let response = try await protectedResourceRequest( - request: request - ) + let response = try await protectedResource(for: request) //retry if nonceError if response.isDPoPNonceError { - return try await protectedResourceRequest( - request: request - ) + return try await protectedResource(for: request) } return response } //needs to have optional access to a dpopSigner, so it is a method //on a OAutSessionCapabilities and not a static method - func protectedResourceRequest( - request: URLRequest, + func protectedResource( + for request: URLRequest, ) async throws -> HTTPDataResponse { let session = try session - return try await resourceRequest( + return try await resource( + for: request, accessToken: session.mutable.accessToken.value, - request: request ) } - func resourceRequest( + func resource( + for request: URLRequest, accessToken: String, - request: URLRequest, ) async throws -> HTTPDataResponse { var request = request From 82ec50053f91793348ba71186e7f43a46d06323c Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sat, 7 Mar 2026 23:53:37 +0800 Subject: [PATCH 20/48] deprecate Metadata.load, standardize on a URLSession abstraction --- .../CachedAuthenticated/LoginVM.swift | 25 +++---- .../Login/LoginDemoVM.swift | 21 +++--- .../Sources/AtprotoClient/AtprotoClient.swift | 6 +- .../AtprotoClient/Get/PLCDirectoryQuery.swift | 2 +- .../Sources/AtprotoClient/Get/Request.swift | 2 +- LocalPackages/GermConvenience/Package.swift | 1 + .../GermConvenience/HTTPDataResponse.swift | 2 - .../Sources/GermConvenience/HTTPFetcher.swift | 57 ++++++++++++++++ .../OAuth/AuthorizerCapabilities.swift | 6 +- .../Models/LoginProviderParameters.swift | 54 +++++++-------- .../Models/Metadata/ProtectedResource.swift | 19 ------ .../Sources/OAuth/OAuthComponents.swift | 55 +++++++++++++-- .../Sources/OAuth/OAuthMetadataFetcher.swift | 42 ++++++------ .../Session/OAuthSession+AuthRequest.swift | 7 +- .../OAuth/Session/SessionCapabilities.swift | 4 ++ .../Sources/OAuth/TokenEndpointRequest.swift | 16 ++--- .../AuthorizerImpl+AuthRequestable.swift | 6 -- Sources/AtprotoOAuth/AuthorizerImpl.swift | 12 ++-- .../AtprotoOAuth/OAuthClient+Interface.swift | 20 ++---- Sources/AtprotoOAuth/OAuthClient.swift | 15 ++--- .../Apple/URLSession+HTTPRequester.swift | 52 -------------- .../AtprotoOAuth/Session/SessionImpl.swift | 67 +++++++------------ .../AtprotoOAuthTests/ATProtoOAuthTests.swift | 20 +++--- 23 files changed, 245 insertions(+), 266 deletions(-) create mode 100644 LocalPackages/GermConvenience/Sources/GermConvenience/HTTPFetcher.swift delete mode 100644 Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift diff --git a/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift b/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift index e5fa4b5..5d38aa8 100644 --- a/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift +++ b/DemoApp/AtprotoOAuthDemoApp/CachedAuthenticated/LoginVM.swift @@ -10,6 +10,7 @@ import AtprotoOAuth import AtprotoTypes import AuthenticationServices import Foundation +import GermConvenience import OAuth import os @@ -27,13 +28,11 @@ import os callbackURL: URL(string: "com.germnetwork.static:/oauth")! ), userAuthenticator: ASWebAuthenticationSession.userAuthenticator(), - responseProvider: URLSession.defaultProvider, - manualRedirectFetcher: URLSession.manualRefreshFetcher, + resourceFetcher: URLSession.shared, + authFetcher: URLSession.manualRedirect(), atprotoClient: AtprotoClient( - responseProvider: URLSession.defaultProvider + resourceFetcher: URLSession.shared ), - oauthMetadataFetcher: HTTPOAuthMetadataFetcher( - httpRequester: URLSession.defaultProvider) ) let handle: String @@ -71,13 +70,11 @@ import os session: sessionArchive, ), appCredentials: oauthClient.appCredentials, - httpRequester: URLSession.defaultProvider, - manualRedirectFetch: URLSession.manualRefreshFetcher, + resourceFetcher: URLSession.shared, + authFetcher: URLSession.manualRedirect(), atprotoClient: AtprotoClient( - responseProvider: URLSession.defaultProvider + resourceFetcher: URLSession.shared ), - oauthMetadataFetcher: HTTPOAuthMetadataFetcher( - httpRequester: URLSession.defaultProvider) ) if !Task.isCancelled { @@ -148,13 +145,11 @@ import os session: archive, ), appCredentials: oauthClient.appCredentials, - httpRequester: URLSession.defaultProvider, - manualRedirectFetch: URLSession.manualRefreshFetcher, + resourceFetcher: URLSession.shared, + authFetcher: URLSession.manualRedirect(), atprotoClient: AtprotoClient( - responseProvider: URLSession.defaultProvider + resourceFetcher: URLSession.shared ), - oauthMetadataFetcher: HTTPOAuthMetadataFetcher( - httpRequester: URLSession.defaultProvider) ) if !Task.isCancelled { self.session = .init( diff --git a/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift b/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift index 3b8f055..9870165 100644 --- a/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift +++ b/DemoApp/AtprotoOAuthDemoApp/Login/LoginDemoVM.swift @@ -10,6 +10,7 @@ import AtprotoOAuth import AtprotoTypes import AuthenticationServices import Foundation +import GermConvenience import Microcosm import OAuth import SwiftUI @@ -22,13 +23,11 @@ import SwiftUI callbackURL: URL(string: "com.germnetwork.static:/oauth")! ), userAuthenticator: ASWebAuthenticationSession.userAuthenticator(), - responseProvider: URLSession.defaultProvider, - manualRedirectFetcher: URLSession.manualRefreshFetcher, + resourceFetcher: URLSession.shared, + authFetcher: URLSession.manualRedirect(), atprotoClient: AtprotoClient( - responseProvider: URLSession.defaultProvider + resourceFetcher: URLSession.shared ), - oauthMetadataFetcher: HTTPOAuthMetadataFetcher( - httpRequester: URLSession.defaultProvider) ) enum State { @@ -52,7 +51,7 @@ import SwiftUI logs.append(.init(body: "Resolved DID: \(resolvedDid.fullId)")) let messageDelegate = try await AtprotoClient( - responseProvider: URLSession.defaultProvider + resourceFetcher: URLSession.shared ) .getGermMessagingDelegate(did: resolvedDid) @@ -74,15 +73,11 @@ import SwiftUI session: sessionArchive, ), appCredentials: oauthClient.appCredentials, - httpRequester: URLSession.defaultProvider, - manualRedirectFetch: URLSession - .manualRefreshFetcher, + resourceFetcher: URLSession.shared, + authFetcher: URLSession.manualRedirect(), atprotoClient: AtprotoClient( - responseProvider: URLSession.defaultProvider + resourceFetcher: URLSession.shared ), - oauthMetadataFetcher: HTTPOAuthMetadataFetcher( - httpRequester: URLSession.defaultProvider - ) ) state = .loggedIn(session) diff --git a/LocalPackages/AtprotoClient/Sources/AtprotoClient/AtprotoClient.swift b/LocalPackages/AtprotoClient/Sources/AtprotoClient/AtprotoClient.swift index ef69b71..91b8b10 100644 --- a/LocalPackages/AtprotoClient/Sources/AtprotoClient/AtprotoClient.swift +++ b/LocalPackages/AtprotoClient/Sources/AtprotoClient/AtprotoClient.swift @@ -32,10 +32,10 @@ public protocol AtprotoSession { } public struct AtprotoClient { - let responseProvider: HTTPDataResponse.Requester + let resourceFetcher: HTTPFetcher - public init(responseProvider: @escaping HTTPDataResponse.Requester) { - self.responseProvider = responseProvider + public init(resourceFetcher: HTTPFetcher) { + self.resourceFetcher = resourceFetcher } } diff --git a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/PLCDirectoryQuery.swift b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/PLCDirectoryQuery.swift index b5e8c80..9499288 100644 --- a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/PLCDirectoryQuery.swift +++ b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/PLCDirectoryQuery.swift @@ -17,7 +17,7 @@ extension AtprotoClient { var request = URLRequest(url: url) request.addValue("application/json", forHTTPHeaderField: "Accept") - return try await responseProvider(request) + return try await resourceFetcher.data(for: request) .expectSuccess() .decode() } diff --git a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Request.swift b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Request.swift index 78f9203..e775635 100644 --- a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Request.swift +++ b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Request.swift @@ -21,7 +21,7 @@ extension AtprotoClient { httpMethod: .get ) - let result = try await responseProvider(request) + let result = try await resourceFetcher.data(for: request) .successErrorDecode( resultType: X.Result.self, errorType: Lexicon.XRPCError.self diff --git a/LocalPackages/GermConvenience/Package.swift b/LocalPackages/GermConvenience/Package.swift index 0b45593..007fee0 100644 --- a/LocalPackages/GermConvenience/Package.swift +++ b/LocalPackages/GermConvenience/Package.swift @@ -5,6 +5,7 @@ import PackageDescription let package = Package( name: "GermConvenience", + platforms: [.iOS(.v15), .macOS(.v12)], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift index df2f73a..7e04456 100644 --- a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift +++ b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift @@ -13,8 +13,6 @@ public struct HTTPDataResponse: Sendable { public let data: Data public let response: HTTPURLResponse - public typealias Requester = @Sendable (URLRequest) async throws -> HTTPDataResponse - public init(data: Data, response: HTTPURLResponse) { self.data = data self.response = response diff --git a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPFetcher.swift b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPFetcher.swift new file mode 100644 index 0000000..a8647d9 --- /dev/null +++ b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPFetcher.swift @@ -0,0 +1,57 @@ +// +// HTTPFetcher.swift +// GermConvenience +// +// Created by Mark @ Germ on 3/7/26. +// + +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +///We are not genericizing over the network stack as our HTTP request construction relies heavily on +///URLRequest which is tied closely to URLSession. +///This genericization serves to allow mock ingestion for testing +///This is a (sendable) object abstraction, not a closure, so that we can reason about the cache sharing +///which is localiized to an instance of a URLSession +public protocol HTTPFetcher: Sendable { + func data(for: URLRequest) async throws -> HTTPDataResponse +} + +extension URLSession: HTTPFetcher { + public func data(for request: URLRequest) async throws -> HTTPDataResponse { + let (data, urlResponse) = try await self.data(for: request) + if let httpResponse = urlResponse as? HTTPURLResponse { + return .init(data: data, response: httpResponse) + } else { + throw URLSessionError.nonHttpResponse + } + } +} + +extension URLSession { + static public func manualRedirect() -> URLSession { + URLSession( + configuration: .default, + delegate: ManualRedirect(), + delegateQueue: nil + ) + } +} + +enum URLSessionError: Error { + case nonHttpResponse +} + +final class ManualRedirect: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest + ) async -> URLRequest? { + nil + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift index 444894c..c32c9ce 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift @@ -42,7 +42,7 @@ public protocol AuthorizerCapabilities: AuthRequestable, DPoPSigning { extension AuthorizerCapabilities { public func performUserAuthentication( parConfig: PARConfiguration, - authServerMetadata: AuthServerMetadata, + // authServerMetadata: AuthServerMetadata, userAuthenticator: UserAuthenticator ) async throws -> SessionState.Archive { let challenge = pkceVerifier.challenge @@ -60,6 +60,10 @@ extension AuthorizerCapabilities { "code_challenge_method": challenge.method, ].merging(parConfig.parameters, uniquingKeysWith: { a, b in a }) + let authServerMetadata = try await authFetcher.authServerDiscovery( + issuer: try await retriableIssuer + ) + let parHTTPResponse = try await pushedAuthorizationRequest( authServerMetadata: authServerMetadata, appCredentials: appCredentials, diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/LoginProviderParameters.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/LoginProviderParameters.swift index ce04df6..cbcd3c0 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/LoginProviderParameters.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/LoginProviderParameters.swift @@ -5,30 +5,30 @@ // Created by Mark @ Germ on 2/23/26 from OAuthenticate // -import Foundation -import GermConvenience - -public struct LoginProviderParameters: Sendable { - public let authorizationURL: URL - public let credentials: AppCredentials - public let redirectURL: URL - public let responseProvider: HTTPDataResponse.Requester - public let stateToken: String - public let pkceVerifier: PKCEVerifier? - - public init( - authorizationURL: URL, - credentials: AppCredentials, - redirectURL: URL, - responseProvider: @escaping HTTPDataResponse.Requester, - stateToken: String, - pkceVerifier: PKCEVerifier? - ) { - self.authorizationURL = authorizationURL - self.credentials = credentials - self.redirectURL = redirectURL - self.responseProvider = responseProvider - self.stateToken = stateToken - self.pkceVerifier = pkceVerifier - } -} +//import Foundation +//import GermConvenience +// +//public struct LoginProviderParameters: Sendable { +// public let authorizationURL: URL +// public let credentials: AppCredentials +// public let redirectURL: URL +// public let responseProvider: HTTPDataResponse.Requester +// public let stateToken: String +// public let pkceVerifier: PKCEVerifier? +// +// public init( +// authorizationURL: URL, +// credentials: AppCredentials, +// redirectURL: URL, +// responseProvider: @escaping HTTPDataResponse.Requester, +// stateToken: String, +// pkceVerifier: PKCEVerifier? +// ) { +// self.authorizationURL = authorizationURL +// self.credentials = credentials +// self.redirectURL = redirectURL +// self.responseProvider = responseProvider +// self.stateToken = stateToken +// self.pkceVerifier = pkceVerifier +// } +//} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/ProtectedResource.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/ProtectedResource.swift index 6700689..0459a37 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/ProtectedResource.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/Metadata/ProtectedResource.swift @@ -48,23 +48,4 @@ public struct ProtectedResourceMetadata: Codable, Hashable, Sendable { case dpopBoundAccessTokensRequired = "dpop_bound_access_tokens_required" case signedMetadata = "signed_metadata" } - - public static func load( - for host: String, - httpRequester: HTTPDataResponse.Requester - ) async throws -> ProtectedResourceMetadata { - var components = URLComponents() - components.scheme = "https" - components.host = host - components.path = "/.well-known/oauth-protected-resource" - - let url = try components.url.tryUnwrap(MetadataError.urlInvalid) - - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Accept") - - return try await httpRequester(request) - .expectSuccess() - .decode() - } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 2c08251..7b3688b 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -8,6 +8,10 @@ import Foundation import GermConvenience +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + ///Direct analog to oauth4web's OAuth module in providing stateless API as building blocks for a full client public enum OAuthComponents { static public func processPushedAuthorizationResponse( @@ -99,6 +103,51 @@ public enum OAuthComponents { } } +extension HTTPFetcher { + //should not redirect + public func resourceDiscoveryRequest( + url: URL, + //Review: what kind of + ) async throws -> ProtectedResourceMetadata { + //TODO: should properly prepend, not append + let url = url.appending( + path: "/.well-known/oauth-protected-resource" + ) + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.get.rawValue + request.setValue("application/json", forHTTPHeaderField: "accept") + + return try await performDiscovery(request: request) + .expectSuccess() + .decode() + + } + + public func authServerDiscovery(issuer: URL) async throws -> AuthServerMetadata { + let url = issuer.appending( + path: "/.well-known/oauth-authorization-server" + ) + + var request = URLRequest(url: url) + request.httpMethod = HTTPMethod.get.rawValue + request.setValue("application/json", forHTTPHeaderField: "accept") + + return try await performDiscovery(request: request) + .expect(successCode: 200) + .decode() + } + + func performDiscovery( + request: URLRequest + ) async throws -> HTTPDataResponse { + guard request.url?.scheme == "https" else { + throw OAuthError.insecureScheme + } + return try await data(for: request) + } +} + extension AuthRequestable { public func authorizationCodeGrantRequest( authServerMetadata: AuthServerMetadata, @@ -106,7 +155,6 @@ extension AuthRequestable { parsedRedirect: OAuthComponents.ParsedRedirect, pkceVerifier: String?, additionalParameters: [String: String], - manualRedirectFetch: HTTPDataResponse.Requester ) async throws -> HTTPDataResponse { var parameters = additionalParameters parameters["redirect_uri"] = redirectUrl.absoluteString @@ -121,7 +169,6 @@ extension AuthRequestable { grantType: .authorizationCode, parameters: parameters, headers: [:], - manualRedirectFetch: manualRedirectFetch ) } @@ -137,7 +184,6 @@ extension AuthRequestable { grantType: .refreshToken, parameters: parameters, headers: [:], - manualRedirectFetch: manualRedirectFetch ) } @@ -146,7 +192,6 @@ extension AuthRequestable { grantType: GrantType, parameters: [String: String], headers: [String: String], - manualRedirectFetch: HTTPDataResponse.Requester ) async throws -> HTTPDataResponse { let url = try authServerMetadata.resolve(endpoint: .token) @@ -229,6 +274,6 @@ extension AuthRequestable { func authenticated( request: URLRequest, ) async throws -> HTTPDataResponse { - try await manualRedirectFetch(request: request) + try await authFetcher.data(for: request) } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift index 5bf6305..a79248a 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthMetadataFetcher.swift @@ -5,24 +5,24 @@ // Created by Mark @ Germ on 2/28/26. // -import Foundation -import GermConvenience - -//allows for test mocking -public protocol OAuthMetadataFetcher: Sendable { - func fetchMetadata( - protectedResourceHost: String - ) async throws -> ProtectedResourceMetadata - - func fetchMetadata( - authServerHost: String - ) async throws -> AuthServerMetadata -} - -public struct HTTPOAuthMetadataFetcher { - let httpRequester: HTTPDataResponse.Requester - - public init(httpRequester: @escaping HTTPDataResponse.Requester) { - self.httpRequester = httpRequester - } -} +//import Foundation +//import GermConvenience +// +////allows for test mocking +//public protocol OAuthMetadataFetcher: Sendable { +// func fetchMetadata( +// protectedResourceHost: String +// ) async throws -> ProtectedResourceMetadata +// +// func fetchMetadata( +// authServerHost: String +// ) async throws -> AuthServerMetadata +//} +// +//public struct HTTPOAuthMetadataFetcher { +// let httpRequester: HTTPDataResponse.Requester +// +// public init(httpRequester: @escaping HTTPDataResponse.Requester) { +// self.httpRequester = httpRequester +// } +//} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index 607d4cf..1dce77a 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -78,7 +78,8 @@ extension OAuthSessionCapabilities { "Bearer \(accessToken)", forHTTPHeaderField: "authorization") } - let response = try await manualRedirectFetch(request: request) + //Review: oauth4web doesn't retry this with a new nonce + let response = try await resourceFetcher.data(for: request) if let dpopSigner = self as? DPoPSigning { try await dpopSigner.cacheNonce( @@ -128,7 +129,9 @@ extension OAuthSessionCapabilities { state: SessionState, appCredentials: AppCredentials, ) async throws -> SessionState.Mutable { - let authServerMetadata = try await getAuthServerMetadata() + let authServerMetadata = try await authFetcher.authServerDiscovery( + issuer: try await retriableIssuer + ) let httpResponse = try await refreshTokenGrantRequest( authServerMetadata: authServerMetadata, refreshToken: state.mutable.refreshToken.tryUnwrap.value diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift index 25bcbb0..afe4cbc 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift @@ -7,6 +7,7 @@ import Crypto import Foundation +import GermConvenience public protocol OAuthSessionCapabilities: Actor, AuthRequestable { var appCredentials: AppCredentials { get } @@ -16,4 +17,7 @@ public protocol OAuthSessionCapabilities: Actor, AuthRequestable { var session: SessionState { get throws } func refreshed(sessionMutable: SessionState.Mutable) throws var refreshTask: Task? { get set } + + //should follow redirects + var resourceFetcher: HTTPFetcher { get } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index a588454..650c32c 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -16,7 +16,9 @@ enum GrantType: String { //make this a protocol so both the Authorizer and Session can use it public protocol AuthRequestable: Actor { var additionalParameters: [String: String] { get } - func manualRedirectFetch(request: URLRequest) async throws -> HTTPDataResponse + + //should not follow redirects + var authFetcher: HTTPFetcher { get } func validate( authMetadata: AuthServerMetadata, tokenResponse: TokenEndpointResponse @@ -28,15 +30,6 @@ public protocol AuthRequestable: Actor { } extension AuthRequestable { - //initially rely on network stack caching - func getAuthServerMetadata() async throws -> AuthServerMetadata { - try await authServerDiscovery( - issuer: try await retriableIssuer - ) - .expect(successCode: 200) - .decode() - } - func finishAuthorization( authorizationUrl: URL, stateToken: String, @@ -58,7 +51,6 @@ extension AuthRequestable { parsedRedirect: parsedRedirect, pkceVerifier: pkceVerifier.verifier, additionalParameters: additionalParameters, - manualRedirectFetch: manualRedirectFetch ) let result = try processAuthorizationCodeOAuth2Response( @@ -81,7 +73,7 @@ extension AuthRequestable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpMethod = HTTPMethod.get.rawValue - return try await manualRedirectFetch(request: request) + return try await authFetcher.data(for: request) } func processRefreshTokenResponse( diff --git a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift index a871604..1b3aa0a 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift @@ -21,12 +21,6 @@ extension AuthorizerImpl: AuthRequestable { ] } - func manualRedirectFetch(request: URLRequest) async throws - -> GermConvenience.HTTPDataResponse - { - try await manualRedirectFetcher(request) - } - func validate( authMetadata: OAuth.AuthServerMetadata, tokenResponse: OAuth.TokenEndpointResponse ) throws -> OAuth.SessionState.Mutable { diff --git a/Sources/AtprotoOAuth/AuthorizerImpl.swift b/Sources/AtprotoOAuth/AuthorizerImpl.swift index 0ab6730..80cf64b 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl.swift @@ -18,8 +18,8 @@ actor AuthorizerImpl { static let logger = Logger(label: "PreSession") let appCredentials: AppCredentials - let httpRequester: HTTPDataResponse.Requester - let manualRedirectFetcher: HTTPDataResponse.Requester + let resourceFetcher: HTTPFetcher + let authFetcher: HTTPFetcher let issuer: URL @@ -31,13 +31,13 @@ actor AuthorizerImpl { init( issuer: URL, appCredentials: AppCredentials, - httpRequester: @escaping HTTPDataResponse.Requester, - manualRedirectFetcher: @escaping HTTPDataResponse.Requester + resourceFetcher: HTTPFetcher, + authFetcher: HTTPFetcher ) { self.issuer = issuer self.appCredentials = appCredentials - self.httpRequester = httpRequester - self.manualRedirectFetcher = manualRedirectFetcher + self.resourceFetcher = resourceFetcher + self.authFetcher = authFetcher } } diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index c8e8b49..ad9370d 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -9,6 +9,7 @@ import AtprotoTypes import AuthenticationServices import Crypto import Foundation +import GermConvenience import OAuth extension AtprotoOAuthClient: AtprotoOAuthInterface { @@ -52,13 +53,6 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { let authorizationServerUrl = try await getAuthorizationUrl(didDoc: didDoc) - let authorizationServerHost = try authorizationServerUrl.host() - .tryUnwrap(OAuthClientError.missingUrlHost) - - let authServerMetadata = try await oauthMetadataFetcher.fetchMetadata( - authServerHost: authorizationServerHost - ) - let parConfig = PARConfiguration( parameters: ["login_hint": identity.serverHint] ) @@ -66,24 +60,20 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { return try await AuthorizerImpl( issuer: authorizationServerUrl, appCredentials: appCredentials, - httpRequester: httpRequester, - manualRedirectFetcher: manualRedirectFetcher + resourceFetcher: resourceFetcher, + authFetcher: authFetcher ) .performUserAuthentication( parConfig: parConfig, - authServerMetadata: authServerMetadata, userAuthenticator: { try await userAuthenticator($0, $1) } ) } private func getAuthorizationUrl(didDoc: DIDDocument) async throws -> URL { - let pdsHost = try didDoc.pdsUrl.host() - .tryUnwrap(OAuthClientError.missingUrlHost) + let pdsUrl = try didDoc.pdsUrl let pdsMetadata = - try await oauthMetadataFetcher.fetchMetadata( - protectedResourceHost: pdsHost - ) + try await authFetcher.resourceDiscoveryRequest(url: pdsUrl) //https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 //PDS doesn't actually fill this field, so we only check it if present diff --git a/Sources/AtprotoOAuth/OAuthClient.swift b/Sources/AtprotoOAuth/OAuthClient.swift index c3393c1..abb71dc 100644 --- a/Sources/AtprotoOAuth/OAuthClient.swift +++ b/Sources/AtprotoOAuth/OAuthClient.swift @@ -23,10 +23,9 @@ public struct AtprotoOAuthClient: Sendable { public nonisolated let appCredentials: AppCredentials public let userAuthenticator: UserAuthenticator - public let httpRequester: HTTPDataResponse.Requester - let manualRedirectFetcher: HTTPDataResponse.Requester + public let resourceFetcher: HTTPFetcher + let authFetcher: HTTPFetcher public let atprotoClient: AtprotoClientInterface - let oauthMetadataFetcher: OAuthMetadataFetcher //didResolver //handleResolver @@ -34,16 +33,14 @@ public struct AtprotoOAuthClient: Sendable { public init( appCredentials: AppCredentials, userAuthenticator: @escaping UserAuthenticator, - responseProvider: @escaping HTTPDataResponse.Requester, - manualRedirectFetcher: @escaping HTTPDataResponse.Requester, + resourceFetcher: HTTPFetcher, + authFetcher: HTTPFetcher, atprotoClient: AtprotoClientInterface, - oauthMetadataFetcher: OAuthMetadataFetcher, ) { self.appCredentials = appCredentials self.userAuthenticator = userAuthenticator - self.httpRequester = responseProvider + self.resourceFetcher = resourceFetcher self.atprotoClient = atprotoClient - self.oauthMetadataFetcher = oauthMetadataFetcher - self.manualRedirectFetcher = manualRedirectFetcher + self.authFetcher = authFetcher } } diff --git a/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift b/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift deleted file mode 100644 index 7780191..0000000 --- a/Sources/AtprotoOAuth/Platforms/Apple/URLSession+HTTPRequester.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// URLSession+HTTPRequester.swift -// AtprotoOAuth -// -// Created by Mark @ Germ on 3/1/26. -// - -import Foundation -import GermConvenience - -enum URLSessionError: Error { - case nonHttpResponse -} - -extension URLSession { - /// Convert a `URLSession` instance into a `URLResponseProvider`. - public var responseProvider: HTTPDataResponse.Requester { - { request in - let (data, urlResponse) = try await self.data(for: request) - if let httpResponse = urlResponse as? HTTPURLResponse { - return .init(data: data, response: httpResponse) - } else { - throw URLSessionError.nonHttpResponse - } - } - } - - /// Convert a `URLSession` with a default configuration into a `URLResponseProvider`. - public static var defaultProvider: HTTPDataResponse.Requester { - URLSession(configuration: .default).responseProvider - } - - public static var manualRedirectFetcher: HTTPDataResponse.Requester { - URLSession( - configuration: .default, - delegate: ManualRedirect(), - delegateQueue: nil - ) - .responseProvider - } -} - -final class ManualRedirect: NSObject, URLSessionTaskDelegate { - func urlSession( - _ session: URLSession, - task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, - newRequest request: URLRequest - ) async -> URLRequest? { - nil - } -} diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index 8d10765..a2f9a42 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -14,10 +14,9 @@ import OAuth public actor AtprotoOAuthSessionImpl { public nonisolated let did: Atproto.DID public let appCredentials: AppCredentials - public let httpRequester: HTTPDataResponse.Requester - public let manualRedirectFetcher: HTTPDataResponse.Requester + public let resourceFetcher: HTTPFetcher + public let authFetcher: HTTPFetcher let atprotoClient: AtprotoClientInterface - let oauthMetadataFetcher: OAuthMetadataFetcher private let nonceCache: NSCache = NSCache() @@ -50,18 +49,16 @@ public actor AtprotoOAuthSessionImpl { did: Atproto.DID, appCredentials: AppCredentials, state: State, - httpRequester: @escaping HTTPDataResponse.Requester, - manualRedirectFetch: @escaping HTTPDataResponse.Requester, + resourceFetcher: HTTPFetcher, + authFetcher: HTTPFetcher, atprotoClient: AtprotoClientInterface, - oauthMetadataFetcher: OAuthMetadataFetcher ) { self.did = did self.appCredentials = appCredentials self.state = state - self.httpRequester = httpRequester - self.manualRedirectFetcher = manualRedirectFetch + self.resourceFetcher = resourceFetcher + self.authFetcher = authFetcher self.atprotoClient = atprotoClient - self.oauthMetadataFetcher = oauthMetadataFetcher self.lazyServerMetadata = .init( fetchTaskGenerator: { @@ -69,11 +66,8 @@ public actor AtprotoOAuthSessionImpl { let pdsHost = try await atprotoClient.plcDirectoryQuery(did) .pdsUrl let pdsMetadata = - try await oauthMetadataFetcher - .fetchMetadata( - protectedResourceHost: pdsHost.host() - .tryUnwrap - ) + try await authFetcher.resourceDiscoveryRequest( + url: pdsHost) //https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 //PDS doesn't actually fill this field, so we only check it if present @@ -88,18 +82,16 @@ public actor AtprotoOAuthSessionImpl { } guard - let authorizationServerUrl = pdsMetadata + let authorizationServerUrlString = pdsMetadata .authorizationServers?.first, - let authorizationServerHost = URL( - string: authorizationServerUrl)?.host() + let authorizationServerUrl = URL( + string: authorizationServerUrlString) else { throw OAuthSessionError.cantFormURL } - return - try await oauthMetadataFetcher - .fetchMetadata( - authServerHost: authorizationServerHost) + return try await authFetcher.authServerDiscovery( + issuer: authorizationServerUrl) } }) @@ -109,11 +101,8 @@ public actor AtprotoOAuthSessionImpl { let pdsHost = try await atprotoClient.plcDirectoryQuery(did) .pdsUrl let pdsMetadata = - try await oauthMetadataFetcher - .fetchMetadata( - protectedResourceHost: pdsHost.host() - .tryUnwrap - ) + try await authFetcher.resourceDiscoveryRequest( + url: pdsHost) //https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 //PDS doesn't actually fill this field, so we only check it if present @@ -205,18 +194,16 @@ extension AtprotoOAuthSessionImpl { public static func restore( archive: Archive, appCredentials: AppCredentials, - httpRequester: @escaping HTTPDataResponse.Requester, - manualRedirectFetch: @escaping HTTPDataResponse.Requester, + resourceFetcher: HTTPFetcher, + authFetcher: HTTPFetcher, atprotoClient: AtprotoClientInterface, - oauthMetadataFetcher: OAuthMetadataFetcher ) throws -> (AtprotoOAuthSession, AsyncStream) { let session = try AtprotoOAuthSessionImpl( archive: archive, appCredentials: appCredentials, - httpRequester: httpRequester, - manualRedirectFetch: manualRedirectFetch, + resourceFetcher: resourceFetcher, + authFetcher: authFetcher, atprotoClient: atprotoClient, - oauthMetadataFetcher: oauthMetadataFetcher ) return (session, session.saveStream) } @@ -224,19 +211,17 @@ extension AtprotoOAuthSessionImpl { private init( archive: Archive, appCredentials: AppCredentials, - httpRequester: @escaping HTTPDataResponse.Requester, - manualRedirectFetch: @escaping HTTPDataResponse.Requester, + resourceFetcher: HTTPFetcher, + authFetcher: HTTPFetcher, atprotoClient: AtprotoClientInterface, - oauthMetadataFetcher: OAuthMetadataFetcher ) throws { try self.init( did: .init(fullId: archive.did), appCredentials: appCredentials, state: .init(archive: archive.session), - httpRequester: httpRequester, - manualRedirectFetch: manualRedirectFetch, + resourceFetcher: resourceFetcher, + authFetcher: authFetcher, atprotoClient: atprotoClient, - oauthMetadataFetcher: oauthMetadataFetcher ) } @@ -287,12 +272,6 @@ extension AtprotoOAuthSessionImpl: AuthRequestable { ] } - public func manualRedirectFetch(request: URLRequest) async throws - -> GermConvenience.HTTPDataResponse - { - try await manualRedirectFetcher(request) - } - public func validate( authMetadata: AuthServerMetadata, tokenResponse: TokenEndpointResponse diff --git a/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift b/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift index a102e3a..19a5e6b 100644 --- a/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift +++ b/Tests/AtprotoOAuthTests/ATProtoOAuthTests.swift @@ -1,5 +1,6 @@ import AtprotoClient import Foundation +import GermConvenience import OAuth import Testing @@ -31,12 +32,9 @@ struct APITests { callbackURL: APITests.redirectUri ), userAuthenticator: AtprotoClient.failingUserAuthenticator(_:_:), - responseProvider: URLSession.defaultProvider, - manualRedirectFetcher: URLSession.manualRedirectFetcher, + resourceFetcher: URLSession.shared, + authFetcher: URLSession.manualRedirect(), atprotoClient: MockAtprotoClient(), - oauthMetadataFetcher: HTTPOAuthMetadataFetcher( - httpRequester: URLSession.defaultProvider - ) ) } } @@ -59,12 +57,9 @@ struct ClientAPITests { callbackURL: APITests.redirectUri ), userAuthenticator: AtprotoClient.failingUserAuthenticator(_:_:), - responseProvider: URLSession.defaultProvider, - manualRedirectFetcher: URLSession.manualRedirectFetcher, + resourceFetcher: URLSession.shared, + authFetcher: URLSession.manualRedirect(), atprotoClient: MockAtprotoClient(), - oauthMetadataFetcher: HTTPOAuthMetadataFetcher( - httpRequester: URLSession.defaultProvider - ) ) } @@ -78,8 +73,9 @@ struct ClientAPITests { //make some unauthed requests. e.g. is this did already using germ? let messageDelegate = try await AtprotoClient( - responseProvider: oauthClient.httpRequester - ).getGermMessagingDelegate(did: resolvedDid) + resourceFetcher: URLSession.shared + ) + .getGermMessagingDelegate(did: resolvedDid) #expect(messageDelegate != nil) } From 44ad392de2ea8f3198b9caeeb6102c0af1e51601 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 09:22:47 +0800 Subject: [PATCH 21/48] add client_credentials grant type --- .../oauth4swift/Sources/OAuth/TokenEndpointRequest.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index 650c32c..f50badc 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -11,6 +11,7 @@ import GermConvenience enum GrantType: String { case authorizationCode = "authorization_code" case refreshToken = "refresh_token" + case clientCredentials = "client_credentials" } //make this a protocol so both the Authorizer and Session can use it From 6983999e94f76a04903f6588eb144cbe124cc28e Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 09:41:40 +0800 Subject: [PATCH 22/48] point implement validator to current task for it --- .../oauth4swift/Sources/OAuth/TokenEndpointRequest.swift | 2 +- Sources/AtprotoOAuth/AuthorizerImpl.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index f50badc..e8c44f8 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -91,7 +91,7 @@ extension AuthRequestable { //check the claims try validate(authMetadata: authServerMetadata, tokenResponse: result) - // TODO: GER-1343 - Implement validator + // TODO: GER-1388 - Implement validator // after a token is issued, it is critical that the returned // identity be resolved and its PDS match the issuing server // diff --git a/Sources/AtprotoOAuth/AuthorizerImpl.swift b/Sources/AtprotoOAuth/AuthorizerImpl.swift index 80cf64b..d8a8a48 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl.swift @@ -154,7 +154,7 @@ extension AuthorizerImpl: AuthorizerCapabilities { // response: Atproto.TokenResponse, // sub: String // ) async throws { - // // TODO: GER-1343 - Implement validator + // // TODO: GER-1388 - Implement validator // // after a token is issued, it is critical that the returned // // identity be resolved and its PDS match the issuing server // // From 3a98d8094171b291ce18a2c41aaaae950b2b8a76 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 09:46:28 +0800 Subject: [PATCH 23/48] document redirect policies --- .../Sources/GermConvenience/HTTPFetcher.swift | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPFetcher.swift b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPFetcher.swift index a8647d9..8ce8875 100644 --- a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPFetcher.swift +++ b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPFetcher.swift @@ -20,6 +20,22 @@ public protocol HTTPFetcher: Sendable { func data(for: URLRequest) async throws -> HTTPDataResponse } +///Authorization fetches should not follow redirects. This includes +///the protected resource metadata +///https://www.rfc-editor.org/rfc/rfc9728.html#section-3.2 +///and auth server metadata +///https://datatracker.ietf.org/doc/html/rfc8414#section-3.2 +extension URLSession { + static public func manualRedirect() -> URLSession { + URLSession( + configuration: .default, + delegate: ManualRedirect(), + delegateQueue: nil + ) + } +} + +///The default (shared) urlsession does follow redirects, which is permitted for resource requests extension URLSession: HTTPFetcher { public func data(for request: URLRequest) async throws -> HTTPDataResponse { let (data, urlResponse) = try await self.data(for: request) @@ -31,16 +47,6 @@ extension URLSession: HTTPFetcher { } } -extension URLSession { - static public func manualRedirect() -> URLSession { - URLSession( - configuration: .default, - delegate: ManualRedirect(), - delegateQueue: nil - ) - } -} - enum URLSessionError: Error { case nonHttpResponse } From b5f4636df53c0575e2fb29661576e71b40913c7e Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 09:55:21 +0800 Subject: [PATCH 24/48] expand and document the OAuthErrorResponse type --- .../OAuth/DPoP/OAuthErrorResponse.swift | 20 ----------- .../OAuth/Models/OAuthErrorResponse.swift | 36 +++++++++++++++++++ .../OAuth/{OAuth.swift => OAuthError.swift} | 0 3 files changed, 36 insertions(+), 20 deletions(-) delete mode 100644 LocalPackages/oauth4swift/Sources/OAuth/DPoP/OAuthErrorResponse.swift create mode 100644 LocalPackages/oauth4swift/Sources/OAuth/Models/OAuthErrorResponse.swift rename LocalPackages/oauth4swift/Sources/OAuth/{OAuth.swift => OAuthError.swift} (100%) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/OAuthErrorResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/OAuthErrorResponse.swift deleted file mode 100644 index 3a9b28b..0000000 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/OAuthErrorResponse.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// OAuthErrorResponse.swift -// OAuth -// -// Created by Mark @ Germ on 2/24/26. -// - -/// Decodes a OAuth Error Response. -public struct OAuthErrorResponse: Codable, Hashable, Sendable { - public let error: String - public let errorDescription: String? - - enum CodingKeys: String, CodingKey { - case error - case errorDescription = "error_description" - } -} - -/// Additional common OAuth responses can be included here later. -/// For example OAuthTokenResponse or similar. diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/OAuthErrorResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/OAuthErrorResponse.swift new file mode 100644 index 0000000..0d8f30b --- /dev/null +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/OAuthErrorResponse.swift @@ -0,0 +1,36 @@ +// +// OAuthErrorResponse.swift +// OAuth +// +// Created by Mark @ Germ on 2/24/26. +// + +import Foundation + +/// Decodes a OAuth Error Response, as specified in +/// https://www.rfc-editor.org/rfc/rfc9126.html#name-error-response +/// https://www.rfc-editor.org/rfc/rfc6749#section-5.2 +/// https://github.com/germ-network/AtprotoOAuth/pull/9 +public struct OAuthErrorResponse: Codable, Hashable, Sendable { + public let error: String + public let errorDescription: String? + public let errorURI: URL? + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + case errorURI = "error_uri" + } + + // public enum ErrorCodes: String, Sendable, Codable { + // case unauthorizedClient = "unauthorized_client" + // case accessDenied = "access_denied" + // case unsupportedResponseType = "unsupported_response_type" + // case invalid_scope = "invalid_scope" + // case serverError = "server_error" + // case temporarilyUnavailable = "temporarily_unavailable" + // } +} + +/// Additional common OAuth responses can be included here later. +/// For example OAuthTokenResponse or similar. diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift similarity index 100% rename from LocalPackages/oauth4swift/Sources/OAuth/OAuth.swift rename to LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift From 6efb45422e720d15bcab15d91537130bb0a11145 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 10:05:57 +0800 Subject: [PATCH 25/48] decode OAuthErrorResponese in processPushedAuthorizationResponse --- .../AtprotoClient/Get/Auth/AuthRequest.swift | 12 +++--- .../Sources/AtprotoClient/Get/Request.swift | 6 +-- .../GermConvenience/HTTPDataResponse.swift | 37 +++++++++++-------- .../Sources/OAuth/OAuthComponents.swift | 17 +++++++-- .../Sources/OAuth/OAuthError.swift | 3 ++ 5 files changed, 47 insertions(+), 28 deletions(-) diff --git a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Auth/AuthRequest.swift b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Auth/AuthRequest.swift index 781d939..7dfa99b 100644 --- a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Auth/AuthRequest.swift +++ b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Auth/AuthRequest.swift @@ -20,9 +20,9 @@ extension AtprotoClient { let request = URLRequest.createRequest(url: requestURL, httpMethod: .get) let result = try await session.authResponse(for: request) - .successErrorDecode( - resultType: X.Result.self, - errorType: Lexicon.XRPCError.self + .success( + decodeResult: X.Result.self, + orError: Lexicon.XRPCError.self ) switch result { @@ -50,9 +50,9 @@ extension AtprotoClient { ) let result = try await session.authResponse(for: request) - .successErrorDecode( - resultType: X.Result.self, - errorType: Lexicon.XRPCError.self + .success( + decodeResult: X.Result.self, + orError: Lexicon.XRPCError.self ) switch result { diff --git a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Request.swift b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Request.swift index e775635..65b427e 100644 --- a/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Request.swift +++ b/LocalPackages/AtprotoClient/Sources/AtprotoClient/Get/Request.swift @@ -22,9 +22,9 @@ extension AtprotoClient { ) let result = try await resourceFetcher.data(for: request) - .successErrorDecode( - resultType: X.Result.self, - errorType: Lexicon.XRPCError.self + .success( + decodeResult: X.Result.self, + orError: Lexicon.XRPCError.self ) switch result { diff --git a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift index 7e04456..1e4f260 100644 --- a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift +++ b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift @@ -40,25 +40,30 @@ public struct HTTPDataResponse: Sendable { case error(E, Int) } - public func successErrorDecode( - resultType: R.Type, - errorType: E.Type, - successRange: RangeExpression = 200..<300 + public func success( + code: Int, + decodeResult resultType: R.Type, + orError error: E.Type, + ) throws -> ErrorResult { + try success( + range: code...code, + decodeResult: R.self, + orError: E.self + ) + } + + public func success( + range: RangeExpression = 200..<300, + decodeResult resultType: R.Type, + orError error: E.Type, ) throws -> ErrorResult { do { - let result: R = try expectSuccess(range: successRange) - .decode() - return .result(result) + return .result( + try expectSuccess(range: range) + .decode() + ) } catch { - if let stringResponse = String(data: data, encoding: .utf8) { - throw - HTTPResponseError - .unsuccessfulString( - response.statusCode, stringResponse) - } else { - throw HTTPResponseError.unsuccessful( - response.statusCode, data) - } + return .error(try data.decode(), response.statusCode) } } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 7b3688b..1a41d1f 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -17,9 +17,20 @@ public enum OAuthComponents { static public func processPushedAuthorizationResponse( response: HTTPDataResponse ) throws -> PARResponse { - try response - .expect(successCode: 201) - .decode() + let parsed = + try response + .success( + code: 201, + decodeResult: PARResponse.self, + orError: OAuthErrorResponse.self + ) + + switch parsed { + case .result(let result): + return result + case .error(let errorResponse, _): + throw OAuthError.oauthError(errorResponse, response.response) + } } static public func validateAuthResponse( diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift index 8f1e6d9..a62c1b0 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift @@ -13,6 +13,7 @@ enum OAuthError: Error { case stateTokenMismatch(String, String) case issuingServerMismatch(String, String) case httpResponse(response: HTTPURLResponse) + case oauthError(OAuthErrorResponse, HTTPURLResponse) case notImplemented } @@ -35,6 +36,8 @@ extension OAuthError: LocalizedError { case .redirectError(let errorString): "Redirect error: \(errorString)" case .httpResponse(let response): "HTTP error with status code: \(response.statusCode), response: \(response)" + case .oauthError(let errorBody, let response): + "OAuth error with status code: \(response.statusCode), response: \(response), body: \(errorBody)" case .notImplemented: "Not implemented" } } From 8b5c3556847a209591e3905f161fce0c04e9f1a2 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 10:14:07 +0800 Subject: [PATCH 26/48] more deliberately order validateAuthResponse steps --- .../Sources/OAuth/OAuthComponents.swift | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 1a41d1f..3338fea 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -44,10 +44,8 @@ public enum OAuthComponents { resolvingAgainstBaseURL: false ).tryUnwrap + //first check for iss and state and bail if not present guard - let authCode = redirectComponents.queryItems?.first(where: { - $0.name == "code" - })?.value, let iss = redirectComponents.queryItems?.first(where: { $0.name == "iss" })?.value, @@ -58,6 +56,38 @@ public enum OAuthComponents { throw OAuthError.redirectMissingComponents } + //check for error_description or error + if let errorItem = redirectComponents.queryItems?.first(where: { + $0.name == "error_description" + }) { + throw OAuthError.redirectError(errorItem.value ?? "") + } + + if let errorItem = redirectComponents.queryItems?.first(where: { + $0.name == "error" + }) { + throw OAuthError.redirectError(errorItem.value ?? "") + } + + //assert we do not support insecure flows + assert( + redirectComponents.queryItems?.first(where: { + $0.name == "id_token" + })?.value == nil) + assert( + redirectComponents.queryItems?.first(where: { + $0.name == "token" + })?.value == nil) + + //finally can check for presence of code + guard + let authCode = redirectComponents.queryItems?.first(where: { + $0.name == "code" + })?.value + else { + throw OAuthError.redirectMissingComponents + } + guard state == expectedState else { throw OAuthError.stateTokenMismatch(state, expectedState) } @@ -68,12 +98,6 @@ public enum OAuthComponents { .issuingServerMismatch(iss, authServerMetadata.issuer) } - if let errorItem = redirectComponents.queryItems?.first(where: { - $0.name == "error" - }) { - throw OAuthError.redirectError(errorItem.value ?? "") - } - return .init( authCode: authCode, issuer: iss, From 1ae4365ef378b1d69e6251720764865a4bd55352 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 10:26:07 +0800 Subject: [PATCH 27/48] resolve merge conflict with main --- .../GermConvenience/HTTPDataResponse.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift index df9ee91..b093be0 100644 --- a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift +++ b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift @@ -65,12 +65,19 @@ public struct HTTPDataResponse: Sendable { } catch { return .error(try data.decode(), response.statusCode) } - if resultType == Data?.self || resultType == Data.self, - let rawData = data as? R + + } +} + +extension Data { + //If the return type is Data we don't try to decode it + public func decode() throws -> R { + if R.self == Data?.self || R.self == Data.self, + let rawData = self as? R { - return .result(rawData) + rawData } else { - return try .result(JSONDecoder().decode(R.self, from: data)) + try JSONDecoder().decode(R.self, from: self) } } } From d1de495ce69027af8ee6e9e80a21a303f7c36de7 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 10:32:06 +0800 Subject: [PATCH 28/48] adjust demo app for merge --- DemoApp/AtprotoOAuthDemoApp/UnauthenticatedView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DemoApp/AtprotoOAuthDemoApp/UnauthenticatedView.swift b/DemoApp/AtprotoOAuthDemoApp/UnauthenticatedView.swift index e4f388c..eb0d902 100644 --- a/DemoApp/AtprotoOAuthDemoApp/UnauthenticatedView.swift +++ b/DemoApp/AtprotoOAuthDemoApp/UnauthenticatedView.swift @@ -29,7 +29,7 @@ struct UnauthenticatedView: View { @State private var processing: Task? = nil let client: AtprotoClientInterface = AtprotoClient.init( - responseProvider: URLSession.defaultProvider + resourceFetcher: URLSession.shared ) var body: some View { From 34936c85f8e543874bbc1b0c246667996ce37a36 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Sun, 8 Mar 2026 10:39:42 +0800 Subject: [PATCH 29/48] don't need the resourceFetcher in authorizer --- .../Sources/OAuth/AuthorizerCapabilities.swift | 17 ----------------- Sources/AtprotoOAuth/AuthorizerImpl.swift | 5 +---- .../AtprotoOAuth/OAuthClient+Interface.swift | 1 - 3 files changed, 1 insertion(+), 22 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift index c32c9ce..2c84a45 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift @@ -20,23 +20,6 @@ public protocol AuthorizerCapabilities: AuthRequestable, DPoPSigning { parRequestURI: String, clientId: String, ) throws -> URL - - // static func finishAuthorization( - // authorizationUrl: URL, - // stateToken: String, - // redirectURI: URL, - // pkceVerifier: PKCEVerifier, - // appCredentials: AppCredentials, - // authServerMetadata: AuthServerMetadata, - // dpopKey: DPoPKey, //only for archiving - // dpopRequester: HTTPDataResponse.Requester - // ) async throws -> SessionState.Archive - - // associatedtype TokenResponse - // static func tokenSubscriberValidator( - // response: TokenResponse, - // sub: String - // ) async throws } extension AuthorizerCapabilities { diff --git a/Sources/AtprotoOAuth/AuthorizerImpl.swift b/Sources/AtprotoOAuth/AuthorizerImpl.swift index d8a8a48..dbb13a0 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl.swift @@ -15,10 +15,9 @@ import OAuth //it should only make requests as necessary to authorize actor AuthorizerImpl { - static let logger = Logger(label: "PreSession") + static let logger = Logger(label: "AuthorizerImpl") let appCredentials: AppCredentials - let resourceFetcher: HTTPFetcher let authFetcher: HTTPFetcher let issuer: URL @@ -31,12 +30,10 @@ actor AuthorizerImpl { init( issuer: URL, appCredentials: AppCredentials, - resourceFetcher: HTTPFetcher, authFetcher: HTTPFetcher ) { self.issuer = issuer self.appCredentials = appCredentials - self.resourceFetcher = resourceFetcher self.authFetcher = authFetcher } } diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index ad9370d..591f63b 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -60,7 +60,6 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { return try await AuthorizerImpl( issuer: authorizationServerUrl, appCredentials: appCredentials, - resourceFetcher: resourceFetcher, authFetcher: authFetcher ) .performUserAuthentication( From f54887f1445a9fdc1998afb57483507aa99737fa Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Mon, 9 Mar 2026 15:43:25 +0800 Subject: [PATCH 30/48] route around AuthorizerImpl --- .../GermConvenience/HTTPDataResponse.swift | 2 +- .../oauth4swift/Sources/OAuth/Authorize.swift | 307 ++++++++++++++++++ .../OAuth/AuthorizerCapabilities.swift | 61 ---- .../Sources/OAuth/DPoP/AuthDPopState.swift | 49 +++ .../Sources/OAuth/DPoP/DPoPResponse.swift | 2 +- .../AuthorizerImpl+AuthRequestable.swift | 2 +- Sources/AtprotoOAuth/AuthorizerImpl.swift | 100 ------ .../AtprotoOAuth/OAuthClient+Interface.swift | 55 +++- 8 files changed, 408 insertions(+), 170 deletions(-) create mode 100644 LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift create mode 100644 LocalPackages/oauth4swift/Sources/OAuth/DPoP/AuthDPopState.swift diff --git a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift index b093be0..d1f2bf2 100644 --- a/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift +++ b/LocalPackages/GermConvenience/Sources/GermConvenience/HTTPDataResponse.swift @@ -65,7 +65,7 @@ public struct HTTPDataResponse: Sendable { } catch { return .error(try data.decode(), response.statusCode) } - + } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift new file mode 100644 index 0000000..0416051 --- /dev/null +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -0,0 +1,307 @@ +// +// Authorize.swift +// OAuth +// +// Created by Mark @ Germ on 3/8/26. +// + +import Foundation +import GermConvenience + +//for authorize +public struct AuthorizeInputs { + let appCredentials: AppCredentials + let stateToken: String + let pkceVerifier: PKCEVerifier + + public init(appCredentials: AppCredentials, stateToken: String, pkceVerifier: PKCEVerifier) + { + self.appCredentials = appCredentials + self.stateToken = stateToken + self.pkceVerifier = pkceVerifier + } +} + +//for authorize and refresh +public struct AuthComponents { + let additionalParameters: [String: String] + let authFetcher: HTTPFetcher + let validator: (AuthServerMetadata, TokenEndpointResponse) throws -> SessionState.Mutable + let issuer: URL + let dpopSigner: DPoPSigning? + + public init( + additionalParameters: [String: String], + authFetcher: HTTPFetcher, + validator: + @escaping ( + AuthServerMetadata, + TokenEndpointResponse + ) throws -> SessionState.Mutable, + issuer: URL, + dpopSigner: DPoPSigning? + ) { + self.additionalParameters = additionalParameters + self.authFetcher = authFetcher + self.validator = validator + self.issuer = issuer + self.dpopSigner = dpopSigner + } + + public func performUserAuthentication( + inputs: AuthorizeInputs, + parConfig: PARConfiguration, + userAuthenticator: UserAuthenticator, + ) async throws -> SessionState.Archive { + let clientId = inputs.appCredentials.clientId + let challenge = inputs.pkceVerifier.challenge + let scopes = inputs.appCredentials.requestedScopes.joined(separator: " ") + let callbackURI = inputs.appCredentials.callbackURL + + let parParams = [ + "client_id": clientId, + "state": inputs.stateToken, + "scope": scopes, + "response_type": "code", + "redirect_uri": inputs.appCredentials.callbackURL.absoluteString, + "code_challenge": challenge.value, + "code_challenge_method": challenge.method, + ].merging(parConfig.parameters, uniquingKeysWith: { a, b in a }) + + let authServerMetadata = try await authFetcher.authServerDiscovery( + issuer: issuer + ) + + let parHTTPResponse = try await pushedAuthorizationRequest( + authServerMetadata: authServerMetadata, + appCredentials: inputs.appCredentials, + params: parParams, + headers: [:], + ) + + let parResponse = try OAuthComponents.processPushedAuthorizationResponse( + response: parHTTPResponse + ) + + let tokenURL = try Self.authorizationURL( + authEndpoint: authServerMetadata.authorizationEndpoint, + parRequestURI: parResponse.requestURI, + clientId: clientId + ) + + let scheme = try inputs.appCredentials.callbackURLScheme + + let callbackURL = try await userAuthenticator(tokenURL, scheme) + + return try await finishAuthorization( + authorizationUrl: tokenURL, + redirectURI: callbackURL, + authInputs: inputs, + authServerMetadata: authServerMetadata, + ) + } + + func pushedAuthorizationRequest( + authServerMetadata: AuthServerMetadata, + appCredentials: AppCredentials, + params: [String: String], + headers: [String: String], + ) async throws -> HTTPDataResponse { + let parEndpoint = try authServerMetadata.resolve( + endpoint: .par) + + var bodyParams = params + bodyParams["client_id"] = appCredentials.clientId + + var headers = headers + headers["accept"] = "application/json" + headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" + + var request = URLRequest(url: parEndpoint) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + request.httpMethod = HTTPMethod.post.rawValue + request.httpBody = bodyParams.urlEncodedHTTPBody + + if let dpopSigner { + return try await dpopSigner.nonceRetryAuthenticated( + request: request, + token: nil, + authFetcher: authFetcher + ) + } else { + return try await authFetcher.data(for: request) + } + } + + static private func authorizationURL( + authEndpoint: URL, + parRequestURI: String, + clientId: String, + ) throws -> URL { + var components = URLComponents(url: authEndpoint, resolvingAgainstBaseURL: false) + + components?.queryItems = [ + URLQueryItem(name: "request_uri", value: parRequestURI), + URLQueryItem(name: "client_id", value: clientId), + ] + + return try (components?.url).tryUnwrap + } + + func finishAuthorization( + authorizationUrl: URL, + redirectURI: URL, + authInputs: AuthorizeInputs, + authServerMetadata: AuthServerMetadata, + ) async throws -> SessionState.Archive { + let parsedRedirect = try OAuthComponents.validateAuthResponse( + authServerMetadata: authServerMetadata, + redirectURL: redirectURI, + expectedState: authInputs.stateToken + ) + + let httpResponse = try await authorizationCodeGrantRequest( + authServerMetadata: authServerMetadata, + redirectUrl: authInputs.appCredentials.callbackURL, + parsedRedirect: parsedRedirect, + pkceVerifier: authInputs.pkceVerifier.verifier, + additionalParameters: additionalParameters, + ) + + let result = try processAuthorizationCodeOAuth2Response( + authServerMetadata: authServerMetadata, + response: httpResponse + ) + + return .init( + dPopKey: try dpopSigner?.dpopKey, + additionalParams: nil, + mutable: result + ) + } + + public func authorizationCodeGrantRequest( + authServerMetadata: AuthServerMetadata, + redirectUrl: URL, + parsedRedirect: OAuthComponents.ParsedRedirect, + pkceVerifier: String?, + additionalParameters: [String: String], + ) async throws -> HTTPDataResponse { + var parameters = additionalParameters + parameters["redirect_uri"] = redirectUrl.absoluteString + parameters["code"] = parsedRedirect.authCode + + if let pkceVerifier { + parameters["code_verifier"] = pkceVerifier + } + + return try await tokenEndpointRequest( + authServerMetadata: authServerMetadata, + grantType: .authorizationCode, + parameters: parameters, + headers: [:], + ) + } + + func processAuthorizationCodeOAuth2Response( + authServerMetadata: AuthServerMetadata, + response: HTTPDataResponse + ) throws -> SessionState.Mutable { + let result = try OAuthComponents.processGenericAccessToken(response: response) + + //check the claims + return try validator(authServerMetadata, result) + // TODO: GER-1388 - Implement validator + // after a token is issued, it is critical that the returned + // identity be resolved and its PDS match the issuing server + // + // check out draft-ietf-oauth-v2-1 section 7.3.1 for details + } + + func tokenEndpointRequest( + authServerMetadata: AuthServerMetadata, + grantType: GrantType, + parameters: [String: String], + headers: [String: String], + ) async throws -> HTTPDataResponse { + let url = try authServerMetadata.resolve(endpoint: .token) + + var modifiedParams = parameters + modifiedParams["grant_type"] = grantType.rawValue + + var headers = headers + headers["accept"] = "application/json" + headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" + + var request = URLRequest(url: url) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + + request.httpMethod = HTTPMethod.post.rawValue + let paramsString = + try modifiedParams + .map({ [$0, $1].joined(separator: "=") }) + .joined(separator: "&") + request.httpBody = try modifiedParams.urlEncodedHTTPBody + + //review: confirm oauth4web doesn't retry "function tokenEndpointRequest" + //for a nonce failure + if let dpopSigner { + return try await dpopSigner.authenticated( + request: request, + token: nil, + authFetcher: authFetcher + ) + } else { + return try await authFetcher.data(for: request) + } + } +} + +extension DPoPSigning { + func nonceRetryAuthenticated( + request: URLRequest, + token: String?, + authFetcher: HTTPFetcher + ) async throws -> HTTPDataResponse { + let firstResponse = try await authenticated( + request: request, + token: token, + authFetcher: authFetcher + ) + + //retry if nonceError + if firstResponse.isDPoPNonceError { + return try await authenticated( + request: request, + token: token, + authFetcher: authFetcher + ) + } else { + return firstResponse + } + } + + func authenticated( + request: URLRequest, + token: String?, + authFetcher: HTTPFetcher + ) async throws -> HTTPDataResponse { + let proofRequest = try addProof( + request: request, + token: nil, + ) + + let response = try await authFetcher.data(for: proofRequest) + + try cacheNonce( + response: response, + requestUrl: proofRequest.url.tryUnwrap + ) + + return response + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift index 2c84a45..50b74b4 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift @@ -25,7 +25,6 @@ public protocol AuthorizerCapabilities: AuthRequestable, DPoPSigning { extension AuthorizerCapabilities { public func performUserAuthentication( parConfig: PARConfiguration, - // authServerMetadata: AuthServerMetadata, userAuthenticator: UserAuthenticator ) async throws -> SessionState.Archive { let challenge = pkceVerifier.challenge @@ -78,64 +77,4 @@ extension AuthorizerCapabilities { dpopKey: dpopKey, ) } - - // private func getPARRequestURI( - // appCredentials: AppCredentials, - // parConfig: PARConfiguration, - // stateToken: String, - // ) async throws -> String { - // let result = try await parRequest( - // appCredentials: appCredentials, - // url: parConfig.url, - // params: parConfig.parameters, - // stateToken: stateToken, - // ) - // - // Logger(label: "PreSessionInterface") - // .debug("Received PAR response that expires in \(result.expiresIn)") - // - // return result.requestURI - // } - - // private func parRequest( - // appCredentials: AppCredentials, - // url: URL, - // params: [String: String], - // stateToken: String, - // ) async throws -> PARResponse { - // let challenge = pkceVerifier.challenge - // let scopes = appCredentials.requestedScopes.joined(separator: " ") - // let callbackURI = appCredentials.callbackURL - // let clientId = appCredentials.clientId - // - // var request = URLRequest(url: url) - // request.httpMethod = HTTPMethod.post.rawValue - // request.setValue("application/json", forHTTPHeaderField: "Accept") - // request.setValue( - // "application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - // - // let base: [String: String] = [ - // "client_id": clientId, - // "state": stateToken, - // "scope": scopes, - // "response_type": "code", - // "redirect_uri": callbackURI.absoluteString, - // "code_challenge": challenge.value, - // "code_challenge_method": challenge.method, - // ] - // - // let body = - // params - // .merging(base, uniquingKeysWith: { a, b in a }) - // .map({ [$0, $1].joined(separator: "=") }) - // .joined(separator: "&") - // - // request.httpBody = body.utf8Data - // - // return try await dpopResponse( - // for: request, - // issuerOrigin: nil, - // token: nil, - // ).successDecode() - // } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/AuthDPopState.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/AuthDPopState.swift new file mode 100644 index 0000000..5016bde --- /dev/null +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/AuthDPopState.swift @@ -0,0 +1,49 @@ +// +// AuthDPopState.swift +// OAuth +// +// Created by Mark @ Germ on 3/9/26. +// + +import Foundation +import GermConvenience + +///A simple actor to manage dpop state for initial auth + +public actor AuthDPopState: DPoPSigning { + nonisolated public let dpopKey: DPoPKey + + let nonceCache: NSCache = NSCache() + + public init(dpopKey: DPoPKey) { + self.dpopKey = dpopKey + } + + public func getNonce(origin: String) -> OAuth.IndexedNonce? { + nonceCache.object(forKey: origin as NSString) + } + + public func cacheNonce(response: HTTPDataResponse, requestUrl: URL) throws { + let indexedNonce = try Self.decode(dataResponse: response, requestUrl: requestUrl) + if let indexedNonce { + nonceCache.setObject(indexedNonce, forKey: indexedNonce.origin as NSString) + } + } + + static func decode( + dataResponse: HTTPDataResponse, + requestUrl: URL, + ) throws -> IndexedNonce? { + guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") + else { + return nil + } + + //henceforth should throw instead of return nil as nonce is expected + return try IndexedNonce( + responseUrl: dataResponse.response.url, + requestUrl: requestUrl, + nonce: nonce + ) + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift index 39df51e..e04fff8 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift @@ -10,7 +10,7 @@ import Foundation import GermConvenience public protocol DPoPSigning: Actor { - var dpopKey: DPoPKey { get throws } + nonisolated var dpopKey: DPoPKey { get throws } func getNonce(origin: String) -> IndexedNonce? func cacheNonce(response: HTTPDataResponse, requestUrl: URL) throws diff --git a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift index 1b3aa0a..b66b867 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift @@ -50,7 +50,7 @@ extension AuthorizerImpl: DPoPSigning { } } - public static func decode( + static func decode( dataResponse: HTTPDataResponse, requestUrl: URL, ) throws -> IndexedNonce? { diff --git a/Sources/AtprotoOAuth/AuthorizerImpl.swift b/Sources/AtprotoOAuth/AuthorizerImpl.swift index dbb13a0..a66151b 100644 --- a/Sources/AtprotoOAuth/AuthorizerImpl.swift +++ b/Sources/AtprotoOAuth/AuthorizerImpl.swift @@ -57,104 +57,4 @@ extension AuthorizerImpl: AuthorizerCapabilities { return url } - - // static func finishAuthorization( - // authorizationUrl: URL, - // stateToken: String, - // redirectURI: URL, - // pkceVerifier: PKCEVerifier, - // appCredentials: AppCredentials, - // authServerMetadata: AuthServerMetadata, - // dpopKey: DPoPKey, - // dpopRequester: (URLRequest) async throws -> HTTPDataResponse - // ) async throws -> SessionState.Archive { - // // decode the params in the redirectURL - // let redirectComponents = try URLComponents( - // url: redirectURI, - // resolvingAgainstBaseURL: false - // ).tryUnwrap(OAuthClientError.missingTokenURL) - // - // guard - // let authCode = redirectComponents.queryItems?.first(where: { - // $0.name == "code" - // })?.value, - // let iss = redirectComponents.queryItems?.first(where: { - // $0.name == "iss" - // })?.value, - // let state = redirectComponents.queryItems?.first(where: { - // $0.name == "state" - // })?.value - // else { - // throw OAuthClientError.missingAuthorizationCode - // } - // - // if state != stateToken { - // throw OAuthClientError.stateTokenMismatch(state, stateToken) - // } - // - // if iss != authServerMetadata.issuer { - // throw - // OAuthClientError - // .issuingServerMismatch(iss, authServerMetadata.issuer) - // } - // - // // and use them (plus just a little more) to construct the token request - // let tokenURL = try URL(string: authServerMetadata.tokenEndpoint) - // .tryUnwrap(OAuthClientError.missingTokenURL) - // - // let tokenRequest = Atproto.TokenRequest( - // code: authCode, - // codeVerifier: pkceVerifier.verifier, - // redirectUri: appCredentials.callbackURL.absoluteString, - // grantType: "authorization_code", - // clientId: appCredentials.clientId - // ) - // - // var request = URLRequest(url: tokenURL) - // - // request.httpMethod = "POST" - // request.setValue("application/json", forHTTPHeaderField: "Content-Type") - // request.setValue("application/json", forHTTPHeaderField: "Accept") - // request.httpBody = try JSONEncoder().encode(tokenRequest) - // - // let result = try await dpopRequester(request) - // .successErrorDecode( - // resultType: Atproto.TokenResponse.self, - // errorType: Atproto.TokenError.self, - // ) - // - // switch result { - // case .result(let tokenResponse): - // guard tokenResponse.tokenType == "DPoP" else { - // throw OAuthClientError.dpopTokenExpected( - // tokenResponse.tokenType) - // } - // - // try await Self.tokenSubscriberValidator( - // response: tokenResponse, - // sub: authServerMetadata.issuer - // ) - // - // return tokenResponse.session(for: iss, dpopKey: dpopKey) - // case .error(let tokenError, let statusCode): - // if tokenError.errorDescription == "Code challenge already used" { - // throw OAuthClientError.codeChallengeAlreadyUsed - // } - // Self.logger.error( - // "Login error: \(tokenError.errorDescription), with status code \(statusCode)" - // ) - // throw OAuthClientError.remoteTokenError(tokenError) - // } - // } - - // static func tokenSubscriberValidator( - // response: Atproto.TokenResponse, - // sub: String - // ) async throws { - // // TODO: GER-1388 - Implement validator - // // after a token is issued, it is critical that the returned - // // identity be resolved and its PDS match the issuing server - // // - // // check out draft-ietf-oauth-v2-1 section 7.3.1 for details - // } } diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index 591f63b..01705a1 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -57,15 +57,58 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { parameters: ["login_hint": identity.serverHint] ) - return try await AuthorizerImpl( + let additionaParameters = [ + "client_id": appCredentials.clientId, + "redirect_url": appCredentials.callbackURL.absoluteString, + ] + + let validator: + ( + AuthServerMetadata, + TokenEndpointResponse + ) -> SessionState.Mutable = { authServerMetadata, tokenResponse in + //TODO: finish validation + + .init( + accessToken: .init( + value: tokenResponse.accessToken, + expiresIn: tokenResponse.expiresIn + ), + refreshToken: .init( + refreshToken: tokenResponse.refreshToken), + scopes: tokenResponse.scope, + //REVIEW: where should this come from? + issuingServer: authServerMetadata.issuer + ) + } + + return try await AuthComponents( + additionalParameters: additionaParameters, + authFetcher: authFetcher, + validator: validator, issuer: authorizationServerUrl, - appCredentials: appCredentials, - authFetcher: authFetcher - ) - .performUserAuthentication( + dpopSigner: AuthDPopState(dpopKey: .generateP256()) + ).performUserAuthentication( + inputs: .init( + appCredentials: appCredentials, + stateToken: UUID().uuidString, + pkceVerifier: .init() + ), parConfig: parConfig, - userAuthenticator: { try await userAuthenticator($0, $1) } + userAuthenticator: { + try await userAuthenticator($0, $1) + } ) + + // return try await AuthorizerImpl( + // issuer: authorizationServerUrl, + // appCredentials: appCredentials, + // authFetcher: authFetcher + // ) + // .performUserAuthentication( + // parConfig: parConfig, + // userAuthenticator: { try await userAuthenticator($0, $1) } + // ) } private func getAuthorizationUrl(didDoc: DIDDocument) async throws -> URL { From f91b9fb3468209f5cf274f31b77c04cbcbaf7995 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Mon, 9 Mar 2026 16:08:00 +0800 Subject: [PATCH 31/48] excise AuthRequestable --- .../oauth4swift/Sources/OAuth/Authorize.swift | 9 ++ .../OAuth/AuthorizerCapabilities.swift | 80 --------- .../Sources/OAuth/DPoP/DPoPResponse.swift | 152 ------------------ .../Sources/OAuth/OAuthComponents.swift | 99 ++---------- .../Session/OAuthSession+AuthRequest.swift | 10 +- .../OAuth/Session/SessionCapabilities.swift | 11 +- .../Sources/OAuth/TokenEndpointRequest.swift | 134 --------------- .../AuthorizerImpl+AuthRequestable.swift | 69 -------- Sources/AtprotoOAuth/AuthorizerImpl.swift | 60 ------- .../AtprotoOAuth/OAuthClient+Interface.swift | 10 -- .../AtprotoOAuth/Session/SessionImpl.swift | 16 +- 11 files changed, 49 insertions(+), 601 deletions(-) delete mode 100644 LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift delete mode 100644 Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift delete mode 100644 Sources/AtprotoOAuth/AuthorizerImpl.swift diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index 0416051..3bf5f5d 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -285,6 +285,7 @@ extension DPoPSigning { } } + //tries just once func authenticated( request: URLRequest, token: String?, @@ -305,3 +306,11 @@ extension DPoPSigning { return response } } + +extension [String: String] { + var urlEncodedHTTPBody: Data { + map({ [$0, $1].joined(separator: "=") }) + .joined(separator: "&") + .utf8Data + } +} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift deleted file mode 100644 index 50b74b4..0000000 --- a/LocalPackages/oauth4swift/Sources/OAuth/AuthorizerCapabilities.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// AuthorizerCapabilities.swift -// OAuth -// -// Created by Mark @ Germ on 2/26/26. -// - -import Foundation -import GermConvenience -import Logging - -public protocol AuthorizerCapabilities: AuthRequestable, DPoPSigning { - var appCredentials: AppCredentials { get } - var stateToken: String { get } - //now mandatory for all - var pkceVerifier: PKCEVerifier { get } - - static func authorizationURL( - authEndpoint: URL, - parRequestURI: String, - clientId: String, - ) throws -> URL -} - -extension AuthorizerCapabilities { - public func performUserAuthentication( - parConfig: PARConfiguration, - userAuthenticator: UserAuthenticator - ) async throws -> SessionState.Archive { - let challenge = pkceVerifier.challenge - let scopes = appCredentials.requestedScopes.joined(separator: " ") - let callbackURI = appCredentials.callbackURL - let clientId = appCredentials.clientId - - let parParams = [ - "client_id": clientId, - "state": stateToken, - "scope": scopes, - "response_type": "code", - "redirect_uri": callbackURI.absoluteString, - "code_challenge": challenge.value, - "code_challenge_method": challenge.method, - ].merging(parConfig.parameters, uniquingKeysWith: { a, b in a }) - - let authServerMetadata = try await authFetcher.authServerDiscovery( - issuer: try await retriableIssuer - ) - - let parHTTPResponse = try await pushedAuthorizationRequest( - authServerMetadata: authServerMetadata, - appCredentials: appCredentials, - params: parParams, - headers: [:], - ) - - let parResponse = try OAuthComponents.processPushedAuthorizationResponse( - response: parHTTPResponse - ) - - let tokenURL = try Self.authorizationURL( - authEndpoint: authServerMetadata.authorizationEndpoint, - parRequestURI: parResponse.requestURI, - clientId: appCredentials.clientId - ) - - let scheme = try appCredentials.callbackURLScheme - - let callbackURL = try await userAuthenticator(tokenURL, scheme) - - return try await finishAuthorization( - authorizationUrl: tokenURL, - stateToken: stateToken, - redirectURI: callbackURL, - pkceVerifier: pkceVerifier, - appCredentials: appCredentials, - authServerMetadata: authServerMetadata, - dpopKey: dpopKey, - ) - } -} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift index e04fff8..520cd37 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift @@ -48,155 +48,3 @@ extension DPoPSigning { return output } } - -//public protocol DPoPNonceHolding: Actor { -// var dpopKey: DPoPKey { get throws } -// -// func getNonce(origin: String) -> IndexedNonce? -// func store(indexedNonce: IndexedNonce) -// var httpRequester: HTTPDataResponse.Requester { get } -// -// //should return nil if nonce is not present and throw if it -// //incorrectly parses -// static func decode( -// dataResponse: HTTPDataResponse, -// requestUrl: URL //if the response object is missing a URL, as fallback -// ) throws -> IndexedNonce? -//} -// -//extension DPoPNonceHolding { -// //needs to be actor constrained so it can safely mutate the nonce cache -// //takes a base request, adds a dpop token, retrying if needed -// -// //this method is shared with the session object and the initial login -// func dpopResponse( -// for request: URLRequest, -// issuerOrigin: String?, -// token: String?, -// ) async throws -> HTTPDataResponse { -// var request = request -// -// //right now the RFC has SHA256 baked into the RFC and a new draft needed -// //to specify alg agility -// let tokenHash = token.map { -// SHA256.hash(data: $0.utf8Data) -// .data.base64URLEncodedString() -// } -// -// // Requests must have a URL with an origin: -// let requestOrigin = try (request.url?.origin) -// .tryUnwrap(DPoPError.requestInvalid(request)) -// -// let initNonce = getNonce(origin: requestOrigin) -// -// let method = try request.httpMethod.tryUnwrap(OAuthError.missingHTTPMethod) -// let requestUrl = try request.url.tryUnwrap(OAuthError.missingUrl) -// -// let jwt = try dpopKey.sign( -// payload: .init( -// endpointUrl: requestUrl, -// httpMethod: method, -// nonce: initNonce?.nonce, -// issuingServer: issuerOrigin, -// accessTokenHash: tokenHash -// ) -// ) -// -// request.setValue(jwt.string, forHTTPHeaderField: "DPoP") -// -// if let token { -// request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") -// } -// -// let dataResponse = try await httpRequester(request) -// -// // Extract the next nonce value if any; if we don't have a new nonce, return the response: -// let nextNonce = try Self.decode( -// dataResponse: dataResponse, -// requestUrl: requestUrl -// ) -// guard let nextNonce else { -// return dataResponse -// } -// -// // If the response doesn't have a new nonce, or the new nonce is the same as -// // the current nonce for the same origin, return the response: -// if nextNonce.origin == initNonce?.origin && nextNonce.nonce == initNonce?.nonce { -// return dataResponse -// } -// store(indexedNonce: nextNonce) -// -// //FIXME: revised logic -// let isAuthServer: Bool? = { -// if let issuerOrigin { -// issuerOrigin == requestOrigin -// } else { -// nil -// } -// }() -// -// //fixme: adopt logic from OAuthenticator pr 50 -// let shouldRetry = Self.isUseDpopError( -// dataResponse: dataResponse, isAuthServer: isAuthServer -// ) -// if !shouldRetry { -// return dataResponse -// } -// -// // repeat once, using newly-established nonce -// let secondJwt = try dpopKey.sign( -// payload: .init( -// endpointUrl: requestUrl, -// httpMethod: method, -// nonce: nextNonce.nonce, -// issuingServer: issuerOrigin, -// accessTokenHash: tokenHash -// ) -// ) -// request.setValue(secondJwt.string, forHTTPHeaderField: "DPoP") -// let retryDataResponse = try await httpRequester(request) -// -// let retryNonce = try Self.decode( -// dataResponse: retryDataResponse, -// requestUrl: requestUrl -// ) -// if let retryNonce { -// store(indexedNonce: retryNonce) -// } -// -// return retryDataResponse -// } -// -// The logic here is taken from: -// https://github.com/bluesky-social/atproto/blob/4e96e2c7/packages/oauth/oauth-client/src/fetch-dpop.ts#L195 -// private static func isUseDpopError( -// dataResponse: HTTPDataResponse, isAuthServer: Bool? -// ) -> Bool { -// // https://datatracker.ietf.org/doc/html/rfc6750#section-3 -// // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no -// -// switch (isAuthServer, dataResponse.response.statusCode) { -// case (let authServer, 401) where authServer != true: -// if let wwwAuthHeader = dataResponse.response.value( -// forHTTPHeaderField: "WWW-Authenticate") -// { -// if wwwAuthHeader.starts(with: "DPoP") { -// return wwwAuthHeader.contains("error=\"use_dpop_nonce\"") -// } -// } -// // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid -// case (let authServer, 400) where authServer != false: -// do { -// let err = try JSONDecoder().decode( -// OAuthErrorResponse.self, from: dataResponse.data) -// return err.error == "use_dpop_nonce" -// } catch { -// return false -// } -// default: -// return false -// } -// -// return false -// } -//} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 3338fea..b436c4b 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -112,6 +112,12 @@ public enum OAuthComponents { public let components: URLComponents } + static func processRefreshTokenResponse( + response: HTTPDataResponse + ) throws -> TokenEndpointResponse { + try processGenericAccessToken(response: response) + } + static func processGenericAccessToken( response: HTTPDataResponse ) throws -> TokenEndpointResponse { @@ -183,33 +189,12 @@ extension HTTPFetcher { } } -extension AuthRequestable { - public func authorizationCodeGrantRequest( - authServerMetadata: AuthServerMetadata, - redirectUrl: URL, - parsedRedirect: OAuthComponents.ParsedRedirect, - pkceVerifier: String?, - additionalParameters: [String: String], - ) async throws -> HTTPDataResponse { - var parameters = additionalParameters - parameters["redirect_uri"] = redirectUrl.absoluteString - parameters["code"] = parsedRedirect.authCode - - if let pkceVerifier { - parameters["code_verifier"] = pkceVerifier - } - - return try await tokenEndpointRequest( - authServerMetadata: authServerMetadata, - grantType: .authorizationCode, - parameters: parameters, - headers: [:], - ) - } - - func refreshTokenGrantRequest( +extension OAuthComponents { + static func refreshTokenGrantRequest( authServerMetadata: AuthServerMetadata, refreshToken: String, + additionalParameters: [String: String], + authFetcher: HTTPFetcher ) async throws -> HTTPDataResponse { var parameters = additionalParameters parameters["refresh_token"] = refreshToken @@ -219,14 +204,16 @@ extension AuthRequestable { grantType: .refreshToken, parameters: parameters, headers: [:], + authFetcher: authFetcher ) } - func tokenEndpointRequest( + static func tokenEndpointRequest( authServerMetadata: AuthServerMetadata, grantType: GrantType, parameters: [String: String], headers: [String: String], + authFetcher: HTTPFetcher ) async throws -> HTTPDataResponse { let url = try authServerMetadata.resolve(endpoint: .token) @@ -249,66 +236,14 @@ extension AuthRequestable { .joined(separator: "&") request.httpBody = try modifiedParams.urlEncodedHTTPBody - //annoyingly compiler doesn't understand cast isolation is the same if let dpopSigner = self as? DPoPSigning { - request = try await dpopSigner.addProof( + return try await dpopSigner.authenticated( request: request, token: nil, + authFetcher: authFetcher ) + } else { + return try await authFetcher.data(for: request) } - - let response = try await authenticated( - request: request, - ) - if let dpopSigner = self as? DPoPSigning { - try await dpopSigner.cacheNonce(response: response, requestUrl: url) - } - - return response - } - - //todo: unify with OAuthSessionCapabilities.retryNonceRequest - func nonceRetryAuthenticated( - request: URLRequest, - token: String? - ) async throws -> HTTPDataResponse { - let response = try await authenticated( - request: request - ) - - if let dpopSigner = self as? DPoPSigning { - try await dpopSigner.cacheNonce( - response: response, - requestUrl: request.url.tryUnwrap - ) - - //retry if nonceError - if response.isDPoPNonceError { - let request = try await dpopSigner.addProof( - request: request, - token: token - ) - - let secondResponse = try await authenticated( - request: request - ) - - try await dpopSigner.cacheNonce( - response: secondResponse, - requestUrl: request.url.tryUnwrap - ) - return secondResponse - } - } - - return response - } - - //here for shadowing of oauth4web.authenticatedRequest - //but most functionality has been lifted out - func authenticated( - request: URLRequest, - ) async throws -> HTTPDataResponse { - try await authFetcher.data(for: request) } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index 1dce77a..5dc056a 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -132,18 +132,20 @@ extension OAuthSessionCapabilities { let authServerMetadata = try await authFetcher.authServerDiscovery( issuer: try await retriableIssuer ) - let httpResponse = try await refreshTokenGrantRequest( + let httpResponse = try await OAuthComponents.refreshTokenGrantRequest( authServerMetadata: authServerMetadata, - refreshToken: state.mutable.refreshToken.tryUnwrap.value + refreshToken: state.mutable.refreshToken.tryUnwrap.value, + additionalParameters: additionalParameters, + authFetcher: authFetcher ) - let response = try await processRefreshTokenResponse(response: httpResponse) + let response = try await OAuthComponents.processRefreshTokenResponse( + response: httpResponse) return try validate( authMetadata: authServerMetadata, tokenResponse: response ) } - } extension HTTPDataResponse { diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift index afe4cbc..c7d8978 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift @@ -9,7 +9,7 @@ import Crypto import Foundation import GermConvenience -public protocol OAuthSessionCapabilities: Actor, AuthRequestable { +public protocol OAuthSessionCapabilities: Actor { var appCredentials: AppCredentials { get } var lazyServerMetadata: LazyResource { get } @@ -20,4 +20,13 @@ public protocol OAuthSessionCapabilities: Actor, AuthRequestable { //should follow redirects var resourceFetcher: HTTPFetcher { get } + + //auth + var authFetcher: HTTPFetcher { get } + var retriableIssuer: URL { get async throws } + func validate( + authMetadata: AuthServerMetadata, + tokenResponse: TokenEndpointResponse + ) throws -> SessionState.Mutable + var additionalParameters: [String: String] { get } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift index e8c44f8..052118e 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/TokenEndpointRequest.swift @@ -13,137 +13,3 @@ enum GrantType: String { case refreshToken = "refresh_token" case clientCredentials = "client_credentials" } - -//make this a protocol so both the Authorizer and Session can use it -public protocol AuthRequestable: Actor { - var additionalParameters: [String: String] { get } - - //should not follow redirects - var authFetcher: HTTPFetcher { get } - func validate( - authMetadata: AuthServerMetadata, - tokenResponse: TokenEndpointResponse - ) throws -> SessionState.Mutable - // var lazyIssuer: LazyResource { get } - //want to be able to create a session offline and eventually resolve - //the issuer for the fixed session id - var retriableIssuer: URL { get async throws } -} - -extension AuthRequestable { - func finishAuthorization( - authorizationUrl: URL, - stateToken: String, - redirectURI: URL, - pkceVerifier: PKCEVerifier, - appCredentials: AppCredentials, - authServerMetadata: AuthServerMetadata, - dpopKey: DPoPKey, - ) async throws -> SessionState.Archive { - let parsedRedirect = try OAuthComponents.validateAuthResponse( - authServerMetadata: authServerMetadata, - redirectURL: redirectURI, - expectedState: stateToken - ) - - let httpResponse = try await authorizationCodeGrantRequest( - authServerMetadata: authServerMetadata, - redirectUrl: appCredentials.callbackURL, - parsedRedirect: parsedRedirect, - pkceVerifier: pkceVerifier.verifier, - additionalParameters: additionalParameters, - ) - - let result = try processAuthorizationCodeOAuth2Response( - authServerMetadata: authServerMetadata, - response: httpResponse - ) - - let mutable = try validate(authMetadata: authServerMetadata, tokenResponse: result) - - return .init(dPopKey: dpopKey, additionalParams: nil, mutable: mutable) - } - - private func authServerDiscovery(issuer: URL) async throws -> HTTPDataResponse { - guard issuer.scheme == "https" else { - throw OAuthError.insecureScheme - } - //NOTE: oauth4web prepends this to the incoming path, - let requestUrl = issuer.appending(path: "/.well-known/oauth-authorization-server") - var request = URLRequest(url: requestUrl) - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpMethod = HTTPMethod.get.rawValue - - return try await authFetcher.data(for: request) - } - - func processRefreshTokenResponse( - response: HTTPDataResponse - ) throws -> TokenEndpointResponse { - try OAuthComponents.processGenericAccessToken(response: response) - } - - func processAuthorizationCodeOAuth2Response( - authServerMetadata: AuthServerMetadata, - response: HTTPDataResponse - ) throws -> TokenEndpointResponse { - let result = try OAuthComponents.processGenericAccessToken(response: response) - - //check the claims - try validate(authMetadata: authServerMetadata, tokenResponse: result) - // TODO: GER-1388 - Implement validator - // after a token is issued, it is critical that the returned - // identity be resolved and its PDS match the issuing server - // - // check out draft-ietf-oauth-v2-1 section 7.3.1 for details - - return result - } - - func pushedAuthorizationRequest( - authServerMetadata: AuthServerMetadata, - appCredentials: AppCredentials, - params: [String: String], - headers: [String: String], - ) async throws -> HTTPDataResponse { - let parEndpoint = try authServerMetadata.resolve( - endpoint: .par) - - var bodyParams = params - bodyParams["client_id"] = appCredentials.clientId - - var headers = headers - headers["accept"] = "application/json" - headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" - - var request = URLRequest(url: parEndpoint) - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - request.httpMethod = HTTPMethod.post.rawValue - request.httpBody = bodyParams.urlEncodedHTTPBody - - if let dpopSigner = self as? DPoPSigning { - request = try await dpopSigner.addProof( - request: request, - //Review: what's correct here - token: nil, - ) - } - - let response = try await nonceRetryAuthenticated( - request: request, - token: nil - ) - - return response - } -} - -extension [String: String] { - var urlEncodedHTTPBody: Data { - map({ [$0, $1].joined(separator: "=") }) - .joined(separator: "&") - .utf8Data - } -} diff --git a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift b/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift deleted file mode 100644 index b66b867..0000000 --- a/Sources/AtprotoOAuth/AuthorizerImpl+AuthRequestable.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// AuthorizerImpl+AuthRequestable.swift -// AtprotoOAuth -// -// Created by Mark @ Germ on 3/5/26. -// - -import Foundation -import GermConvenience -import OAuth - -extension AuthorizerImpl: AuthRequestable { - var retriableIssuer: URL { - issuer - } - - var additionalParameters: [String: String] { - [ - "client_id": appCredentials.clientId, - "redirect_url": appCredentials.callbackURL.absoluteString, - ] - } - - func validate( - authMetadata: OAuth.AuthServerMetadata, tokenResponse: OAuth.TokenEndpointResponse - ) throws -> OAuth.SessionState.Mutable { - //TODO: finish validation - - .init( - accessToken: .init( - value: tokenResponse.accessToken, expiresIn: tokenResponse.expiresIn - ), - refreshToken: .init(refreshToken: tokenResponse.refreshToken), - scopes: tokenResponse.scope, - //REVIEW: where should this come from? - issuingServer: authMetadata.issuer - ) - } -} - -extension AuthorizerImpl: DPoPSigning { - func getNonce(origin: String) -> OAuth.IndexedNonce? { - nonceCache.object(forKey: origin as NSString) - } - - func cacheNonce(response: HTTPDataResponse, requestUrl: URL) throws { - let indexedNonce = try Self.decode(dataResponse: response, requestUrl: requestUrl) - if let indexedNonce { - nonceCache.setObject(indexedNonce, forKey: indexedNonce.origin as NSString) - } - } - - static func decode( - dataResponse: HTTPDataResponse, - requestUrl: URL, - ) throws -> IndexedNonce? { - guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") - else { - return nil - } - - //henceforth should throw instead of return nil as nonce is expected - return try IndexedNonce( - responseUrl: dataResponse.response.url, - requestUrl: requestUrl, - nonce: nonce - ) - } -} diff --git a/Sources/AtprotoOAuth/AuthorizerImpl.swift b/Sources/AtprotoOAuth/AuthorizerImpl.swift deleted file mode 100644 index a66151b..0000000 --- a/Sources/AtprotoOAuth/AuthorizerImpl.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// AuthorizerImpl.swift -// AtprotoOAuth -// -// Created by Mark @ Germ on 2/26/26. -// - -import AtprotoTypes -import Foundation -import GermConvenience -import Logging -import OAuth - -//a container for a nonce cache for getting authorization -//it should only make requests as necessary to authorize - -actor AuthorizerImpl { - static let logger = Logger(label: "AuthorizerImpl") - - let appCredentials: AppCredentials - let authFetcher: HTTPFetcher - - let issuer: URL - - let stateToken = UUID().uuidString - let dpopKey = DPoPKey.generateP256() - let nonceCache: NSCache = NSCache() - let pkceVerifier = PKCEVerifier() - - init( - issuer: URL, - appCredentials: AppCredentials, - authFetcher: HTTPFetcher - ) { - self.issuer = issuer - self.appCredentials = appCredentials - self.authFetcher = authFetcher - } -} - -extension AuthorizerImpl: AuthorizerCapabilities { - public static func authorizationURL( - authEndpoint: URL, - parRequestURI: String, - clientId: String, - ) throws -> URL { - var components = URLComponents(url: authEndpoint, resolvingAgainstBaseURL: false) - - components?.queryItems = [ - URLQueryItem(name: "request_uri", value: parRequestURI), - URLQueryItem(name: "client_id", value: clientId), - ] - - guard let url = components?.url else { - throw OAuthSessionError.cantFormURL - } - - return url - } -} diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index 01705a1..241a2f5 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -99,16 +99,6 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { try await userAuthenticator($0, $1) } ) - - // return try await AuthorizerImpl( - // issuer: authorizationServerUrl, - // appCredentials: appCredentials, - // authFetcher: authFetcher - // ) - // .performUserAuthentication( - // parConfig: parConfig, - // userAuthenticator: { try await userAuthenticator($0, $1) } - // ) } private func getAuthorizationUrl(didDoc: DIDDocument) async throws -> URL { diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index a2f9a42..12b179e 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -249,16 +249,7 @@ extension AtprotoOAuthSessionImpl: OAuthSessionCapabilities { public func refreshed(sessionMutable: SessionState.Mutable) throws { try save(sessionMutable: sessionMutable) } -} - -extension AtprotoOAuthSessionImpl { - func getPDSUrl() async throws -> URL { - try await atprotoClient.plcDirectoryQuery(did) - .pdsUrl - } -} -extension AtprotoOAuthSessionImpl: AuthRequestable { public var retriableIssuer: URL { get async throws { try await lazyIssuer.lazyValue(isolation: self) @@ -290,3 +281,10 @@ extension AtprotoOAuthSessionImpl: AuthRequestable { } } + +extension AtprotoOAuthSessionImpl { + func getPDSUrl() async throws -> URL { + try await atprotoClient.plcDirectoryQuery(did) + .pdsUrl + } +} From 799943639a45a2af4d26874d7de8e35d971e5d3b Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Mon, 9 Mar 2026 16:59:30 +0800 Subject: [PATCH 32/48] consolidate initial parameters for authorization and auth requests (refresh) --- .../oauth4swift/Sources/OAuth/Authorize.swift | 98 ++++++++++--------- .../Session/OAuthSession+AuthRequest.swift | 2 +- Sources/AtprotoOAuth/AtprotoOauth.swift | 42 ++++++++ .../AtprotoOAuth/OAuthClient+Interface.swift | 48 ++------- 4 files changed, 105 insertions(+), 85 deletions(-) create mode 100644 Sources/AtprotoOAuth/AtprotoOauth.swift diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index 3bf5f5d..a486081 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -13,12 +13,21 @@ public struct AuthorizeInputs { let appCredentials: AppCredentials let stateToken: String let pkceVerifier: PKCEVerifier + let parConfig: PARConfiguration? + let issuer: URL - public init(appCredentials: AppCredentials, stateToken: String, pkceVerifier: PKCEVerifier) - { + public init( + appCredentials: AppCredentials, + stateToken: String = UUID().uuidString, + pkceVerifier: PKCEVerifier = .init(), + parConfig: PARConfiguration?, + issuer: URL + ) { self.appCredentials = appCredentials self.stateToken = stateToken self.pkceVerifier = pkceVerifier + self.parConfig = parConfig + self.issuer = issuer } } @@ -27,7 +36,6 @@ public struct AuthComponents { let additionalParameters: [String: String] let authFetcher: HTTPFetcher let validator: (AuthServerMetadata, TokenEndpointResponse) throws -> SessionState.Mutable - let issuer: URL let dpopSigner: DPoPSigning? public init( @@ -38,67 +46,69 @@ public struct AuthComponents { AuthServerMetadata, TokenEndpointResponse ) throws -> SessionState.Mutable, - issuer: URL, dpopSigner: DPoPSigning? ) { self.additionalParameters = additionalParameters self.authFetcher = authFetcher self.validator = validator - self.issuer = issuer self.dpopSigner = dpopSigner } public func performUserAuthentication( - inputs: AuthorizeInputs, - parConfig: PARConfiguration, + authorizeInputs: AuthorizeInputs, userAuthenticator: UserAuthenticator, ) async throws -> SessionState.Archive { - let clientId = inputs.appCredentials.clientId - let challenge = inputs.pkceVerifier.challenge - let scopes = inputs.appCredentials.requestedScopes.joined(separator: " ") - let callbackURI = inputs.appCredentials.callbackURL - - let parParams = [ - "client_id": clientId, - "state": inputs.stateToken, - "scope": scopes, - "response_type": "code", - "redirect_uri": inputs.appCredentials.callbackURL.absoluteString, - "code_challenge": challenge.value, - "code_challenge_method": challenge.method, - ].merging(parConfig.parameters, uniquingKeysWith: { a, b in a }) + let clientId = authorizeInputs.appCredentials.clientId + let challenge = authorizeInputs.pkceVerifier.challenge + let scopes = authorizeInputs.appCredentials.requestedScopes.joined(separator: " ") + let callbackURI = authorizeInputs.appCredentials.callbackURL let authServerMetadata = try await authFetcher.authServerDiscovery( - issuer: issuer + issuer: authorizeInputs.issuer ) - let parHTTPResponse = try await pushedAuthorizationRequest( - authServerMetadata: authServerMetadata, - appCredentials: inputs.appCredentials, - params: parParams, - headers: [:], - ) + if let parConfig = authorizeInputs.parConfig { + let parParams = [ + "client_id": clientId, + "state": authorizeInputs.stateToken, + "scope": scopes, + "response_type": "code", + "redirect_uri": authorizeInputs.appCredentials.callbackURL + .absoluteString, + "code_challenge": challenge.value, + "code_challenge_method": challenge.method, + ].merging(parConfig.parameters, uniquingKeysWith: { a, b in a }) + + let parHTTPResponse = try await pushedAuthorizationRequest( + authServerMetadata: authServerMetadata, + appCredentials: authorizeInputs.appCredentials, + params: parParams, + headers: [:], + ) - let parResponse = try OAuthComponents.processPushedAuthorizationResponse( - response: parHTTPResponse - ) + let parResponse = try OAuthComponents.processPushedAuthorizationResponse( + response: parHTTPResponse + ) - let tokenURL = try Self.authorizationURL( - authEndpoint: authServerMetadata.authorizationEndpoint, - parRequestURI: parResponse.requestURI, - clientId: clientId - ) + let tokenURL = try Self.authorizationURL( + authEndpoint: authServerMetadata.authorizationEndpoint, + parRequestURI: parResponse.requestURI, + clientId: clientId + ) - let scheme = try inputs.appCredentials.callbackURLScheme + let scheme = try authorizeInputs.appCredentials.callbackURLScheme - let callbackURL = try await userAuthenticator(tokenURL, scheme) + let callbackURL = try await userAuthenticator(tokenURL, scheme) - return try await finishAuthorization( - authorizationUrl: tokenURL, - redirectURI: callbackURL, - authInputs: inputs, - authServerMetadata: authServerMetadata, - ) + return try await finishAuthorization( + authorizationUrl: tokenURL, + redirectURI: callbackURL, + authInputs: authorizeInputs, + authServerMetadata: authServerMetadata, + ) + } else { + throw OAuthError.notImplemented + } } func pushedAuthorizationRequest( diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index 5dc056a..c1c8c27 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -124,7 +124,7 @@ extension OAuthSessionCapabilities { } //compare to refreshTokenGrantRequest - //and processRefreshTokenResponse in + //and processRefreshTokenResponse in oauth4web private func refresh( state: SessionState, appCredentials: AppCredentials, diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift new file mode 100644 index 0000000..c1a0a78 --- /dev/null +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -0,0 +1,42 @@ +// +// AtprotoOauth.swift +// AtprotoOAuth +// +// Created by Mark @ Germ on 3/9/26. +// + +import Foundation +import GermConvenience +import OAuth + +extension AuthComponents { + static func atproto( + appCredentials: AppCredentials, + authFetcher: HTTPFetcher, + dpopSigner: DPoPSigning + ) -> AuthComponents { + .init( + additionalParameters: [ + "client_id": appCredentials.clientId, + "redirect_url": appCredentials.callbackURL.absoluteString, + ], + authFetcher: authFetcher, + validator: { authServerMetadata, tokenResponse in + //TODO: finish validation + + .init( + accessToken: .init( + value: tokenResponse.accessToken, + expiresIn: tokenResponse.expiresIn + ), + refreshToken: .init( + refreshToken: tokenResponse.refreshToken), + scopes: tokenResponse.scope, + //REVIEW: where should this come from? + issuingServer: authServerMetadata.issuer + ) + }, + dpopSigner: dpopSigner + ) + } +} diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index 241a2f5..551b7bd 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -53,51 +53,19 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { let authorizationServerUrl = try await getAuthorizationUrl(didDoc: didDoc) - let parConfig = PARConfiguration( - parameters: ["login_hint": identity.serverHint] - ) - - let additionaParameters = [ - "client_id": appCredentials.clientId, - "redirect_url": appCredentials.callbackURL.absoluteString, - ] - - let validator: - ( - AuthServerMetadata, - TokenEndpointResponse - ) -> SessionState.Mutable = { authServerMetadata, tokenResponse in - //TODO: finish validation - - .init( - accessToken: .init( - value: tokenResponse.accessToken, - expiresIn: tokenResponse.expiresIn - ), - refreshToken: .init( - refreshToken: tokenResponse.refreshToken), - scopes: tokenResponse.scope, - //REVIEW: where should this come from? - issuingServer: authServerMetadata.issuer - ) - } - - return try await AuthComponents( - additionalParameters: additionaParameters, + return try await AuthComponents.atproto( + appCredentials: appCredentials, authFetcher: authFetcher, - validator: validator, - issuer: authorizationServerUrl, dpopSigner: AuthDPopState(dpopKey: .generateP256()) ).performUserAuthentication( - inputs: .init( + authorizeInputs: .init( appCredentials: appCredentials, - stateToken: UUID().uuidString, - pkceVerifier: .init() + parConfig: .init( + parameters: ["login_hint": identity.serverHint] + ), + issuer: authorizationServerUrl ), - parConfig: parConfig, - userAuthenticator: { - try await userAuthenticator($0, $1) - } + userAuthenticator: { try await userAuthenticator($0, $1) }, ) } From 3c0a96e3d95f3e1a38c6122001ff5683fd63f4e2 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Tue, 10 Mar 2026 06:21:25 +0800 Subject: [PATCH 33/48] check sub field in atproto token response --- .../oauth4swift/Sources/OAuth/Authorize.swift | 12 ++--- .../Sources/OAuth/DPoP/AuthDPopState.swift | 25 +++-------- .../Sources/OAuth/DPoP/DPoPResponse.swift | 2 +- .../OAuth/Models/TokenEndpointResponse.swift | 2 +- .../Sources/OAuth/OAuthComponents.swift | 7 ++- .../Session/OAuthSession+AuthRequest.swift | 13 +++--- .../OAuth/Session/SessionCapabilities.swift | 8 +--- Sources/AtprotoOAuth/AtprotoOauth.swift | 35 ++++++++++++--- .../AtprotoOAuth/OAuthClient+Interface.swift | 7 ++- Sources/AtprotoOAuth/OAuthClientError.swift | 2 + .../AtprotoOAuth/Session/SessionImpl.swift | 44 ++++++++++--------- 11 files changed, 87 insertions(+), 70 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index a486081..140b47c 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -31,18 +31,20 @@ public struct AuthorizeInputs { } } -//for authorize and refresh -public struct AuthComponents { +///Client defined paramenters for requests to the Auth server, for refresh and user auth requests. +///does not include the issuer so that it can be lazily fetched +public struct AuthServerRequestOptions: Sendable { let additionalParameters: [String: String] let authFetcher: HTTPFetcher - let validator: (AuthServerMetadata, TokenEndpointResponse) throws -> SessionState.Mutable + let validator: + @Sendable (AuthServerMetadata, TokenEndpointResponse) throws -> SessionState.Mutable let dpopSigner: DPoPSigning? public init( additionalParameters: [String: String], authFetcher: HTTPFetcher, validator: - @escaping ( + @escaping @Sendable ( AuthServerMetadata, TokenEndpointResponse ) throws -> SessionState.Mutable, @@ -186,7 +188,7 @@ public struct AuthComponents { ) return .init( - dPopKey: try dpopSigner?.dpopKey, + dPopKey: try await dpopSigner?.dpopKey, additionalParams: nil, mutable: result ) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/AuthDPopState.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/AuthDPopState.swift index 5016bde..a7a3aa3 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/AuthDPopState.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/AuthDPopState.swift @@ -14,9 +14,14 @@ public actor AuthDPopState: DPoPSigning { nonisolated public let dpopKey: DPoPKey let nonceCache: NSCache = NSCache() + private let decoder: (HTTPDataResponse, URL) throws -> IndexedNonce? - public init(dpopKey: DPoPKey) { + public init( + dpopKey: DPoPKey, + decoder: @escaping (HTTPDataResponse, URL) throws -> IndexedNonce? + ) { self.dpopKey = dpopKey + self.decoder = decoder } public func getNonce(origin: String) -> OAuth.IndexedNonce? { @@ -24,26 +29,10 @@ public actor AuthDPopState: DPoPSigning { } public func cacheNonce(response: HTTPDataResponse, requestUrl: URL) throws { - let indexedNonce = try Self.decode(dataResponse: response, requestUrl: requestUrl) + let indexedNonce = try decoder(response, requestUrl) if let indexedNonce { nonceCache.setObject(indexedNonce, forKey: indexedNonce.origin as NSString) } } - static func decode( - dataResponse: HTTPDataResponse, - requestUrl: URL, - ) throws -> IndexedNonce? { - guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") - else { - return nil - } - - //henceforth should throw instead of return nil as nonce is expected - return try IndexedNonce( - responseUrl: dataResponse.response.url, - requestUrl: requestUrl, - nonce: nonce - ) - } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift index 520cd37..b1acfab 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift @@ -10,7 +10,7 @@ import Foundation import GermConvenience public protocol DPoPSigning: Actor { - nonisolated var dpopKey: DPoPKey { get throws } + var dpopKey: DPoPKey { get throws } func getNonce(origin: String) -> IndexedNonce? func cacheNonce(response: HTTPDataResponse, requestUrl: URL) throws diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift index e5a83e0..753caaa 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Models/TokenEndpointResponse.swift @@ -17,7 +17,7 @@ public struct TokenEndpointResponse { public let tokenType: TokenType //capture additional fields - let additionalFields: [String: Any]? + public let additionalFields: [String: Any]? //TODO: allow extension for unknown types //example in oauth4web: RecognizedTokenTypes diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index b436c4b..33e8ec2 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -193,10 +193,9 @@ extension OAuthComponents { static func refreshTokenGrantRequest( authServerMetadata: AuthServerMetadata, refreshToken: String, - additionalParameters: [String: String], - authFetcher: HTTPFetcher + authServerRequestOptions: AuthServerRequestOptions, ) async throws -> HTTPDataResponse { - var parameters = additionalParameters + var parameters = authServerRequestOptions.additionalParameters parameters["refresh_token"] = refreshToken return try await tokenEndpointRequest( @@ -204,7 +203,7 @@ extension OAuthComponents { grantType: .refreshToken, parameters: parameters, headers: [:], - authFetcher: authFetcher + authFetcher: authServerRequestOptions.authFetcher ) } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index c1c8c27..267d093 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -129,21 +129,18 @@ extension OAuthSessionCapabilities { state: SessionState, appCredentials: AppCredentials, ) async throws -> SessionState.Mutable { - let authServerMetadata = try await authFetcher.authServerDiscovery( - issuer: try await retriableIssuer - ) + let authServerMetadata = try await authServerRequestOptions.authFetcher + .authServerDiscovery(issuer: try await retriableIssuer) let httpResponse = try await OAuthComponents.refreshTokenGrantRequest( authServerMetadata: authServerMetadata, refreshToken: state.mutable.refreshToken.tryUnwrap.value, - additionalParameters: additionalParameters, - authFetcher: authFetcher + authServerRequestOptions: authServerRequestOptions ) let response = try await OAuthComponents.processRefreshTokenResponse( response: httpResponse) - return try validate( - authMetadata: authServerMetadata, - tokenResponse: response + return try authServerRequestOptions.validator( + authServerMetadata, response ) } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift index c7d8978..5894c1e 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionCapabilities.swift @@ -22,11 +22,7 @@ public protocol OAuthSessionCapabilities: Actor { var resourceFetcher: HTTPFetcher { get } //auth - var authFetcher: HTTPFetcher { get } var retriableIssuer: URL { get async throws } - func validate( - authMetadata: AuthServerMetadata, - tokenResponse: TokenEndpointResponse - ) throws -> SessionState.Mutable - var additionalParameters: [String: String] { get } + + var authServerRequestOptions: AuthServerRequestOptions { get } } diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift index c1a0a78..389d6ca 100644 --- a/Sources/AtprotoOAuth/AtprotoOauth.swift +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -5,16 +5,18 @@ // Created by Mark @ Germ on 3/9/26. // +import AtprotoTypes import Foundation import GermConvenience import OAuth -extension AuthComponents { +extension AuthServerRequestOptions { static func atproto( appCredentials: AppCredentials, + did: Atproto.DID, authFetcher: HTTPFetcher, dpopSigner: DPoPSigning - ) -> AuthComponents { + ) -> AuthServerRequestOptions { .init( additionalParameters: [ "client_id": appCredentials.clientId, @@ -22,9 +24,13 @@ extension AuthComponents { ], authFetcher: authFetcher, validator: { authServerMetadata, tokenResponse in - //TODO: finish validation + let sub = try tokenResponse.additionalFields?["sub"].tryUnwrap + let subString = try (sub as? String).tryUnwrap + guard subString == did.fullId else { + throw OAuthClientError.subDidMismatch + } - .init( + return .init( accessToken: .init( value: tokenResponse.accessToken, expiresIn: tokenResponse.expiresIn @@ -32,7 +38,7 @@ extension AuthComponents { refreshToken: .init( refreshToken: tokenResponse.refreshToken), scopes: tokenResponse.scope, - //REVIEW: where should this come from? + //REVIEW: do we need to compare the authmetadata issuer against some response from the auth server? issuingServer: authServerMetadata.issuer ) }, @@ -40,3 +46,22 @@ extension AuthComponents { ) } } + +extension AuthDPopState { + static func decode( + dataResponse: HTTPDataResponse, + requestUrl: URL, + ) throws -> IndexedNonce? { + guard let nonce = dataResponse.response.value(forHTTPHeaderField: "DPoP-Nonce") + else { + return nil + } + + //henceforth should throw instead of return nil as nonce is expected + return try IndexedNonce( + responseUrl: dataResponse.response.url, + requestUrl: requestUrl, + nonce: nonce + ) + } +} diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index 551b7bd..a9c0087 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -53,10 +53,13 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { let authorizationServerUrl = try await getAuthorizationUrl(didDoc: didDoc) - return try await AuthComponents.atproto( + return try await AuthServerRequestOptions.atproto( appCredentials: appCredentials, authFetcher: authFetcher, - dpopSigner: AuthDPopState(dpopKey: .generateP256()) + dpopSigner: AuthDPopState( + dpopKey: .generateP256(), + decoder: AuthDPopState.decode + ) ).performUserAuthentication( authorizeInputs: .init( appCredentials: appCredentials, diff --git a/Sources/AtprotoOAuth/OAuthClientError.swift b/Sources/AtprotoOAuth/OAuthClientError.swift index 1d7aa09..da8cb1a 100644 --- a/Sources/AtprotoOAuth/OAuthClientError.swift +++ b/Sources/AtprotoOAuth/OAuthClientError.swift @@ -17,6 +17,7 @@ enum OAuthClientError: Error, Equatable { case pkceRequired case codeChallengeAlreadyUsed case tokenInvalid + case subDidMismatch case stateTokenMismatch(String, String) case issuingServerMismatch(String, String) case remoteTokenError(Atproto.TokenError) @@ -36,6 +37,7 @@ extension OAuthClientError: LocalizedError { case .pkceRequired: "PKCE was required but not provided." case .codeChallengeAlreadyUsed: "Code challenge has already been used." case .tokenInvalid: "Token was invalid." + case .subDidMismatch: "Mismatched did in the subject field of token response" case .stateTokenMismatch( let expected, let got diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index 12b179e..0a58505 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -256,28 +256,32 @@ extension AtprotoOAuthSessionImpl: OAuthSessionCapabilities { } } - public var additionalParameters: [String: String] { - [ - "client_id": appCredentials.clientId, - "redirect_url": appCredentials.callbackURL.absoluteString, - ] + public var authServerRequestOptions: AuthServerRequestOptions { + .atproto( + appCredentials: appCredentials, + authFetcher: authFetcher, + dpopSigner: self + ) } +} - public func validate( - authMetadata: AuthServerMetadata, - tokenResponse: TokenEndpointResponse - ) throws -> SessionState.Mutable { - //TODO: finish validation - - .init( - accessToken: .init( - value: tokenResponse.accessToken, expiresIn: tokenResponse.expiresIn - ), - refreshToken: .init(refreshToken: tokenResponse.refreshToken), - scopes: tokenResponse.scope, - //REVIEW: where should this come from? - issuingServer: authMetadata.issuer - ) +extension AtprotoOAuthSessionImpl: DPoPSigning { + public var dpopKey: OAuth.DPoPKey { + get throws { + try session.dPopKey.tryUnwrap + } + } + + public func getNonce(origin: String) -> OAuth.IndexedNonce? { + nonceCache.object(forKey: origin as NSString) + } + + public func cacheNonce(response: GermConvenience.HTTPDataResponse, requestUrl: URL) throws { + let indexedNonce = try AuthDPopState.decode( + dataResponse: response, requestUrl: requestUrl) + if let indexedNonce { + nonceCache.setObject(indexedNonce, forKey: indexedNonce.origin as NSString) + } } } From 0acde8f6ea812930656af561ab31c9fdd7804686 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Tue, 10 Mar 2026 06:32:23 +0800 Subject: [PATCH 34/48] use common authenticated for the try once flow --- .../oauth4swift/Sources/OAuth/Authorize.swift | 10 +++--- .../Sources/OAuth/OAuthComponents.swift | 2 +- .../Session/OAuthSession+AuthRequest.swift | 31 +++---------------- .../AtprotoOAuth/OAuthClient+Interface.swift | 1 + .../AtprotoOAuth/Session/SessionImpl.swift | 1 + 5 files changed, 13 insertions(+), 32 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index 140b47c..f6e741d 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -265,7 +265,7 @@ public struct AuthServerRequestOptions: Sendable { return try await dpopSigner.authenticated( request: request, token: nil, - authFetcher: authFetcher + fetcher: authFetcher ) } else { return try await authFetcher.data(for: request) @@ -282,7 +282,7 @@ extension DPoPSigning { let firstResponse = try await authenticated( request: request, token: token, - authFetcher: authFetcher + fetcher: authFetcher ) //retry if nonceError @@ -290,7 +290,7 @@ extension DPoPSigning { return try await authenticated( request: request, token: token, - authFetcher: authFetcher + fetcher: authFetcher ) } else { return firstResponse @@ -301,14 +301,14 @@ extension DPoPSigning { func authenticated( request: URLRequest, token: String?, - authFetcher: HTTPFetcher + fetcher: HTTPFetcher ) async throws -> HTTPDataResponse { let proofRequest = try addProof( request: request, token: nil, ) - let response = try await authFetcher.data(for: proofRequest) + let response = try await fetcher.data(for: proofRequest) try cacheNonce( response: response, diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 33e8ec2..a15c0ca 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -239,7 +239,7 @@ extension OAuthComponents { return try await dpopSigner.authenticated( request: request, token: nil, - authFetcher: authFetcher + fetcher: authFetcher ) } else { return try await authFetcher.data(for: request) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index 267d093..95ca71f 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -65,41 +65,20 @@ extension OAuthSessionCapabilities { for request: URLRequest, accessToken: String, ) async throws -> HTTPDataResponse { - var request = request - if let dpopSigner = self as? DPoPSigning { - request = try await dpopSigner.addProof( + return try await dpopSigner.authenticated( request: request, - token: accessToken + token: accessToken, + fetcher: resourceFetcher ) - request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "authorization") } else { + var request = request request.setValue( "Bearer \(accessToken)", forHTTPHeaderField: "authorization") + return try await resourceFetcher.data(for: request) } - - //Review: oauth4web doesn't retry this with a new nonce - let response = try await resourceFetcher.data(for: request) - - if let dpopSigner = self as? DPoPSigning { - try await dpopSigner.cacheNonce( - response: response, - requestUrl: request.url.tryUnwrap - ) - } - - return response } - // async function resourceRequest( - // accessToken: string, - // method: string, - // url: URL, - // headers?: Headers, - // body?: ProtectedResourceRequestBody, - // options?: ProtectedResourceRequestOptions, - // ): Promise { - //conserving in that it reuses result if a refresh is alread in flght private func conservingRefresh(state: SessionState) async throws -> SessionState.Mutable { if let refreshTask { diff --git a/Sources/AtprotoOAuth/OAuthClient+Interface.swift b/Sources/AtprotoOAuth/OAuthClient+Interface.swift index a9c0087..2f68054 100644 --- a/Sources/AtprotoOAuth/OAuthClient+Interface.swift +++ b/Sources/AtprotoOAuth/OAuthClient+Interface.swift @@ -55,6 +55,7 @@ extension AtprotoOAuthClient: AtprotoOAuthInterface { return try await AuthServerRequestOptions.atproto( appCredentials: appCredentials, + did: did, authFetcher: authFetcher, dpopSigner: AuthDPopState( dpopKey: .generateP256(), diff --git a/Sources/AtprotoOAuth/Session/SessionImpl.swift b/Sources/AtprotoOAuth/Session/SessionImpl.swift index 0a58505..af562dd 100644 --- a/Sources/AtprotoOAuth/Session/SessionImpl.swift +++ b/Sources/AtprotoOAuth/Session/SessionImpl.swift @@ -259,6 +259,7 @@ extension AtprotoOAuthSessionImpl: OAuthSessionCapabilities { public var authServerRequestOptions: AuthServerRequestOptions { .atproto( appCredentials: appCredentials, + did: did, authFetcher: authFetcher, dpopSigner: self ) From 0bce27a639e2cfd1aa68c684b91262173964f2df Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Tue, 10 Mar 2026 06:34:34 +0800 Subject: [PATCH 35/48] remove duplicated method --- .../oauth4swift/Sources/OAuth/Authorize.swift | 43 +------------------ 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index f6e741d..b7e5ed1 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -209,11 +209,12 @@ public struct AuthServerRequestOptions: Sendable { parameters["code_verifier"] = pkceVerifier } - return try await tokenEndpointRequest( + return try await OAuthComponents.tokenEndpointRequest( authServerMetadata: authServerMetadata, grantType: .authorizationCode, parameters: parameters, headers: [:], + authFetcher: authFetcher ) } @@ -231,46 +232,6 @@ public struct AuthServerRequestOptions: Sendable { // // check out draft-ietf-oauth-v2-1 section 7.3.1 for details } - - func tokenEndpointRequest( - authServerMetadata: AuthServerMetadata, - grantType: GrantType, - parameters: [String: String], - headers: [String: String], - ) async throws -> HTTPDataResponse { - let url = try authServerMetadata.resolve(endpoint: .token) - - var modifiedParams = parameters - modifiedParams["grant_type"] = grantType.rawValue - - var headers = headers - headers["accept"] = "application/json" - headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" - - var request = URLRequest(url: url) - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - - request.httpMethod = HTTPMethod.post.rawValue - let paramsString = - try modifiedParams - .map({ [$0, $1].joined(separator: "=") }) - .joined(separator: "&") - request.httpBody = try modifiedParams.urlEncodedHTTPBody - - //review: confirm oauth4web doesn't retry "function tokenEndpointRequest" - //for a nonce failure - if let dpopSigner { - return try await dpopSigner.authenticated( - request: request, - token: nil, - fetcher: authFetcher - ) - } else { - return try await authFetcher.data(for: request) - } - } } extension DPoPSigning { From 32db775330d676e581275599c6af29b54359f27d Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Tue, 10 Mar 2026 06:39:55 +0800 Subject: [PATCH 36/48] remove comment --- LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index a15c0ca..8c220bb 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -148,7 +148,6 @@ extension HTTPFetcher { //should not redirect public func resourceDiscoveryRequest( url: URL, - //Review: what kind of ) async throws -> ProtectedResourceMetadata { //TODO: should properly prepend, not append let url = url.appending( From 0728178a2baf28e0e16042fc2fd5a164dbd1580a Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Tue, 10 Mar 2026 08:33:03 +0800 Subject: [PATCH 37/48] rename intermediate var --- LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift | 4 ++-- Sources/AtprotoOAuth/AtprotoOauth.swift | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index b7e5ed1..4279465 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -182,7 +182,7 @@ public struct AuthServerRequestOptions: Sendable { additionalParameters: additionalParameters, ) - let result = try processAuthorizationCodeOAuth2Response( + let tokenResponse = try processAuthorizationCodeOAuth2Response( authServerMetadata: authServerMetadata, response: httpResponse ) @@ -190,7 +190,7 @@ public struct AuthServerRequestOptions: Sendable { return .init( dPopKey: try await dpopSigner?.dpopKey, additionalParams: nil, - mutable: result + mutable: tokenResponse ) } diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift index 389d6ca..fe26c47 100644 --- a/Sources/AtprotoOAuth/AtprotoOauth.swift +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -26,6 +26,12 @@ extension AuthServerRequestOptions { validator: { authServerMetadata, tokenResponse in let sub = try tokenResponse.additionalFields?["sub"].tryUnwrap let subString = try (sub as? String).tryUnwrap + + //for now, enforcing the did is the same as what we started with + //(and checked) + + //more full implementation is to check the new did if different + //and its issuer guard subString == did.fullId else { throw OAuthClientError.subDidMismatch } From fee9f089c6ccbeb967d012297b5db9fd768892d0 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Tue, 10 Mar 2026 10:52:05 +0800 Subject: [PATCH 38/48] remove unused --- .../Sources/OAuth/OAuthComponents.swift | 16 +--------------- Sources/AtprotoOAuth/AtprotoOauth.swift | 4 ++-- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 8c220bb..8d88a06 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -106,7 +106,7 @@ public enum OAuthComponents { } public struct ParsedRedirect { - public let authCode: String + public let authCode: String? public let issuer: String public let components: URLComponents @@ -128,20 +128,6 @@ public enum OAuthComponents { return decoded } - - struct TokenResponse: Decodable { - let accessToken: String - let tokenType: String - let scope: String? - let idToken: String? - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case tokenType = "token_type" - case scope - case idToken = "id_token" - } - } } extension HTTPFetcher { diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift index fe26c47..ff5548a 100644 --- a/Sources/AtprotoOAuth/AtprotoOauth.swift +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -26,10 +26,10 @@ extension AuthServerRequestOptions { validator: { authServerMetadata, tokenResponse in let sub = try tokenResponse.additionalFields?["sub"].tryUnwrap let subString = try (sub as? String).tryUnwrap - + //for now, enforcing the did is the same as what we started with //(and checked) - + //more full implementation is to check the new did if different //and its issuer guard subString == did.fullId else { From d19a6f03228fcf8abae41d394288084187fcb354 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Tue, 10 Mar 2026 11:11:51 +0800 Subject: [PATCH 39/48] check that received scopes is a subset of requested scopes, falling back to requested if nil --- .../Sources/OAuth/OAuthComponents.swift | 3 +-- .../Sources/OAuth/Session/SessionState.swift | 4 ++-- Sources/AtprotoOAuth/AtprotoOauth.swift | 20 +++++++++++++++++-- .../AtprotoOAuth/Session/SessionError.swift | 2 ++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 8d88a06..406bc2c 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -216,8 +216,7 @@ extension OAuthComponents { request.httpMethod = HTTPMethod.post.rawValue let paramsString = try modifiedParams - .map({ [$0, $1].joined(separator: "=") }) - .joined(separator: "&") + .urlEncodedHTTPBody request.httpBody = try modifiedParams.urlEncodedHTTPBody if let dpopSigner = self as? DPoPSigning { diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionState.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionState.swift index 873d844..18f05c3 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionState.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/SessionState.swift @@ -83,13 +83,13 @@ public class SessionState { public let refreshToken: Token? // User authorized scopes - let scopes: String? + let scopes: [String] let issuingServer: String? public init( accessToken: Token, refreshToken: Token? = nil, - scopes: String? = nil, + scopes: [String] = [], issuingServer: String? = nil ) { self.accessToken = accessToken diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift index ff5548a..a985f09 100644 --- a/Sources/AtprotoOAuth/AtprotoOauth.swift +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -15,7 +15,7 @@ extension AuthServerRequestOptions { appCredentials: AppCredentials, did: Atproto.DID, authFetcher: HTTPFetcher, - dpopSigner: DPoPSigning + dpopSigner: DPoPSigning, ) -> AuthServerRequestOptions { .init( additionalParameters: [ @@ -36,6 +36,22 @@ extension AuthServerRequestOptions { throw OAuthClientError.subDidMismatch } + let returnedScopes: [String]? = try { + guard let scopes = tokenResponse.scope else { + return nil + } + let components = scopes.components(separatedBy: " ") + + guard + Set(appCredentials.requestedScopes).contains( + components) + else { + throw OAuthSessionError.receivedScopeNotRequested + } + + return components + }() + return .init( accessToken: .init( value: tokenResponse.accessToken, @@ -43,7 +59,7 @@ extension AuthServerRequestOptions { ), refreshToken: .init( refreshToken: tokenResponse.refreshToken), - scopes: tokenResponse.scope, + scopes: returnedScopes ?? appCredentials.requestedScopes, //REVIEW: do we need to compare the authmetadata issuer against some response from the auth server? issuingServer: authServerMetadata.issuer ) diff --git a/Sources/AtprotoOAuth/Session/SessionError.swift b/Sources/AtprotoOAuth/Session/SessionError.swift index 5b6a650..f8ac5b4 100644 --- a/Sources/AtprotoOAuth/Session/SessionError.swift +++ b/Sources/AtprotoOAuth/Session/SessionError.swift @@ -12,6 +12,7 @@ enum OAuthSessionError: Error { case sessionInactive case incorrectResponseType case expectedDpopToken(String) + case receivedScopeNotRequested case unsupportedDpopSigningAlgorithm case unsupported } @@ -22,6 +23,7 @@ extension OAuthSessionError: LocalizedError { case .cantFormURL: "can't form URL" case .sessionInactive: "session is inactive" case .incorrectResponseType: "incorrect response type" + case .receivedScopeNotRequested: "received scope not requested" case .unsupportedDpopSigningAlgorithm: "Unsupported dpop signing algorithm" case .unsupported: "unsupported" case .expectedDpopToken(let tokenType): From 91c560200b1975176b54917d657a92460d4926b4 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Tue, 10 Mar 2026 13:22:06 +0800 Subject: [PATCH 40/48] renaming --- Sources/AtprotoOAuth/AtprotoOauth.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift index a985f09..cf4f713 100644 --- a/Sources/AtprotoOAuth/AtprotoOauth.swift +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -37,19 +37,19 @@ extension AuthServerRequestOptions { } let returnedScopes: [String]? = try { - guard let scopes = tokenResponse.scope else { + guard let scope = tokenResponse.scope else { return nil } - let components = scopes.components(separatedBy: " ") + let scopes = scope.components(separatedBy: " ") guard Set(appCredentials.requestedScopes).contains( - components) + scopes) else { throw OAuthSessionError.receivedScopeNotRequested } - return components + return scopes }() return .init( From e6264ed6edb6ae8734bdbea7ff2a1e8be95eb617 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Wed, 11 Mar 2026 07:43:21 +0800 Subject: [PATCH 41/48] resolved review comment --- Sources/AtprotoOAuth/AtprotoOauth.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift index cf4f713..7fe959e 100644 --- a/Sources/AtprotoOAuth/AtprotoOauth.swift +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -60,7 +60,6 @@ extension AuthServerRequestOptions { refreshToken: .init( refreshToken: tokenResponse.refreshToken), scopes: returnedScopes ?? appCredentials.requestedScopes, - //REVIEW: do we need to compare the authmetadata issuer against some response from the auth server? issuingServer: authServerMetadata.issuer ) }, From 1b22ceab55e147000e60eb51064163192da19774 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Wed, 11 Mar 2026 11:06:51 +0800 Subject: [PATCH 42/48] pass back additional params to the session state object --- .../oauth4swift/Sources/OAuth/Authorize.swift | 30 ++++++++++++------- Sources/AtprotoOAuth/AtprotoOauth.swift | 6 ++++ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index 4279465..ead82b9 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -7,6 +7,7 @@ import Foundation import GermConvenience +import Logging //for authorize public struct AuthorizeInputs { @@ -182,14 +183,14 @@ public struct AuthServerRequestOptions: Sendable { additionalParameters: additionalParameters, ) - let tokenResponse = try processAuthorizationCodeOAuth2Response( + let (tokenResponse, additionalParams) = try processAuthorizationCodeOAuth2Response( authServerMetadata: authServerMetadata, response: httpResponse ) return .init( dPopKey: try await dpopSigner?.dpopKey, - additionalParams: nil, + additionalParams: additionalParams, mutable: tokenResponse ) } @@ -221,16 +222,25 @@ public struct AuthServerRequestOptions: Sendable { func processAuthorizationCodeOAuth2Response( authServerMetadata: AuthServerMetadata, response: HTTPDataResponse - ) throws -> SessionState.Mutable { - let result = try OAuthComponents.processGenericAccessToken(response: response) + ) throws -> (SessionState.Mutable, [String: String]?) { + let tokenResponse = try OAuthComponents.processGenericAccessToken( + response: response) //check the claims - return try validator(authServerMetadata, result) - // TODO: GER-1388 - Implement validator - // after a token is issued, it is critical that the returned - // identity be resolved and its PDS match the issuing server - // - // check out draft-ietf-oauth-v2-1 section 7.3.1 for details + let sessionState = try validator(authServerMetadata, tokenResponse) + + let additionalParams = tokenResponse.additionalFields? + .compactMapValues { + if let string = $0 as? String { + return string + } else { + Logger(label: "processAuthorizationCodeOAuth2Response") + .error("received param value \($0)") + return nil + } + } + + return (sessionState, additionalParams) } } diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift index 7fe959e..89d5e10 100644 --- a/Sources/AtprotoOAuth/AtprotoOauth.swift +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -30,6 +30,12 @@ extension AuthServerRequestOptions { //for now, enforcing the did is the same as what we started with //(and checked) + // TODO: GER-1388 - Implement validator + // after a token is issued, it is critical that the returned + // identity be resolved and its PDS match the issuing server + // + // check out draft-ietf-oauth-v2-1 section 7.3.1 for details + //more full implementation is to check the new did if different //and its issuer guard subString == did.fullId else { From e9a4f4988fe977e937093683798bfc8a209ce8ec Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Wed, 11 Mar 2026 13:09:21 +0800 Subject: [PATCH 43/48] parse error in processGenericAccessToken --- .../oauth4swift/Sources/OAuth/Authorize.swift | 6 +++- .../Sources/OAuth/OAuthComponents.swift | 32 ++++++++++++------- .../Sources/OAuth/OAuthError.swift | 9 ++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index ead82b9..0abee8f 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -204,7 +204,11 @@ public struct AuthServerRequestOptions: Sendable { ) async throws -> HTTPDataResponse { var parameters = additionalParameters parameters["redirect_uri"] = redirectUrl.absoluteString - parameters["code"] = parsedRedirect.authCode + + //Review: how does e.g., the atproto implementation signal that this is required? + if let code = parsedRedirect.authCode { + parameters["code"] = parsedRedirect.authCode + } if let pkceVerifier { parameters["code_verifier"] = pkceVerifier diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 406bc2c..0d79da3 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -80,13 +80,9 @@ public enum OAuthComponents { })?.value == nil) //finally can check for presence of code - guard - let authCode = redirectComponents.queryItems?.first(where: { - $0.name == "code" - })?.value - else { - throw OAuthError.redirectMissingComponents - } + let authCode = redirectComponents.queryItems?.first(where: { + $0.name == "code" + })?.value guard state == expectedState else { throw OAuthError.stateTokenMismatch(state, expectedState) @@ -121,12 +117,26 @@ public enum OAuthComponents { static func processGenericAccessToken( response: HTTPDataResponse ) throws -> TokenEndpointResponse { - let decoded: TokenEndpointResponse = + let decodedResponse = try response - .expect(successCode: 200) - .decode() + .success( + decodeResult: TokenEndpointResponse.self, + orError: OAuthErrorResponse.self + ) - return decoded + switch decodedResponse { + case .result(let r): + return r + case .error(let e, _): + switch e.error { + case "invalid_request": + throw OAuthError.invalidRequest + case "invalid_response": + throw OAuthError.invalidResponse + default: + throw OAuthError.unrecognizedAuthError(e) + } + } } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift index a62c1b0..e019b19 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift @@ -9,6 +9,10 @@ enum OAuthError: Error { case insecureScheme case unrecognizedTokenType case redirectMissingComponents + case missingAuthCode + case invalidRequest + case invalidResponse + case unrecognizedAuthError(OAuthErrorResponse) case redirectError(String) case stateTokenMismatch(String, String) case issuingServerMismatch(String, String) @@ -27,6 +31,11 @@ extension OAuthError: LocalizedError { case .insecureScheme: "Insecure scheme" case .unrecognizedTokenType: "Unrecognized Token Type" case .redirectMissingComponents: "Redirect missing components" + case .missingAuthCode: "Missing authorization code" + case .invalidRequest: "Invalid request" + case .invalidResponse: "Invalid response" + case .unrecognizedAuthError(let oauthError): + "Unrecognized auth error: \(oauthError)" case .stateTokenMismatch( let expected, let got From a2ba59355e589cb2f8823a7b87b8f1a79f62de8e Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Wed, 11 Mar 2026 13:13:27 +0800 Subject: [PATCH 44/48] for atproto, expect dpop --- LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift | 8 ++++---- .../Sources/OAuth/Session/OAuthSession+AuthRequest.swift | 2 +- Sources/AtprotoOAuth/AtprotoOauth.swift | 7 ++++++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index 0abee8f..7e1cf10 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -37,14 +37,14 @@ public struct AuthorizeInputs { public struct AuthServerRequestOptions: Sendable { let additionalParameters: [String: String] let authFetcher: HTTPFetcher - let validator: + let tokenValidator: @Sendable (AuthServerMetadata, TokenEndpointResponse) throws -> SessionState.Mutable let dpopSigner: DPoPSigning? public init( additionalParameters: [String: String], authFetcher: HTTPFetcher, - validator: + tokenValidator: @escaping @Sendable ( AuthServerMetadata, TokenEndpointResponse @@ -53,7 +53,7 @@ public struct AuthServerRequestOptions: Sendable { ) { self.additionalParameters = additionalParameters self.authFetcher = authFetcher - self.validator = validator + self.tokenValidator = tokenValidator self.dpopSigner = dpopSigner } @@ -231,7 +231,7 @@ public struct AuthServerRequestOptions: Sendable { response: response) //check the claims - let sessionState = try validator(authServerMetadata, tokenResponse) + let sessionState = try tokenValidator(authServerMetadata, tokenResponse) let additionalParams = tokenResponse.additionalFields? .compactMapValues { diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index 95ca71f..db04c8c 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -118,7 +118,7 @@ extension OAuthSessionCapabilities { let response = try await OAuthComponents.processRefreshTokenResponse( response: httpResponse) - return try authServerRequestOptions.validator( + return try authServerRequestOptions.tokenValidator( authServerMetadata, response ) } diff --git a/Sources/AtprotoOAuth/AtprotoOauth.swift b/Sources/AtprotoOAuth/AtprotoOauth.swift index 89d5e10..a6bdbdf 100644 --- a/Sources/AtprotoOAuth/AtprotoOauth.swift +++ b/Sources/AtprotoOAuth/AtprotoOauth.swift @@ -23,7 +23,12 @@ extension AuthServerRequestOptions { "redirect_url": appCredentials.callbackURL.absoluteString, ], authFetcher: authFetcher, - validator: { authServerMetadata, tokenResponse in + tokenValidator: { authServerMetadata, tokenResponse in + guard tokenResponse.tokenType == .dpop else { + throw OAuthSessionError.expectedDpopToken( + tokenResponse.tokenType.rawValue) + } + let sub = try tokenResponse.additionalFields?["sub"].tryUnwrap let subString = try (sub as? String).tryUnwrap From 746704403f29c4dbb916db5497bc2e14847bd104 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Wed, 11 Mar 2026 13:23:53 +0800 Subject: [PATCH 45/48] error handling in processGenericAccessToken --- .../oauth4swift/Sources/OAuth/OAuthComponents.swift | 8 ++++---- .../oauth4swift/Sources/OAuth/OAuthError.swift | 10 ++++------ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 0d79da3..81dbd02 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -28,8 +28,8 @@ public enum OAuthComponents { switch parsed { case .result(let result): return result - case .error(let errorResponse, _): - throw OAuthError.oauthError(errorResponse, response.response) + case .error(let errorResponse, let errorCode): + throw OAuthError.oauthError(errorResponse, errorCode) } } @@ -127,14 +127,14 @@ public enum OAuthComponents { switch decodedResponse { case .result(let r): return r - case .error(let e, _): + case .error(let e, let statusCode): switch e.error { case "invalid_request": throw OAuthError.invalidRequest case "invalid_response": throw OAuthError.invalidResponse default: - throw OAuthError.unrecognizedAuthError(e) + throw OAuthError.oauthError(e, statusCode) } } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift index e019b19..d367d0f 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthError.swift @@ -1,6 +1,7 @@ import Foundation import GermConvenience +//this is growing to the point where it should get broken down into subdomains enum OAuthError: Error { case missingScheme case missingHTTPMethod @@ -12,12 +13,11 @@ enum OAuthError: Error { case missingAuthCode case invalidRequest case invalidResponse - case unrecognizedAuthError(OAuthErrorResponse) case redirectError(String) case stateTokenMismatch(String, String) case issuingServerMismatch(String, String) case httpResponse(response: HTTPURLResponse) - case oauthError(OAuthErrorResponse, HTTPURLResponse) + case oauthError(OAuthErrorResponse, Int) case notImplemented } @@ -34,8 +34,6 @@ extension OAuthError: LocalizedError { case .missingAuthCode: "Missing authorization code" case .invalidRequest: "Invalid request" case .invalidResponse: "Invalid response" - case .unrecognizedAuthError(let oauthError): - "Unrecognized auth error: \(oauthError)" case .stateTokenMismatch( let expected, let got @@ -45,8 +43,8 @@ extension OAuthError: LocalizedError { case .redirectError(let errorString): "Redirect error: \(errorString)" case .httpResponse(let response): "HTTP error with status code: \(response.statusCode), response: \(response)" - case .oauthError(let errorBody, let response): - "OAuth error with status code: \(response.statusCode), response: \(response), body: \(errorBody)" + case .oauthError(let errorBody, let statusCode): + "OAuth error with status code: \(statusCode), body: \(errorBody)" case .notImplemented: "Not implemented" } } From 251124f28a5f33b8f54936fc8cc43725a486dea1 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Wed, 11 Mar 2026 13:57:53 +0800 Subject: [PATCH 46/48] fold tokenEndpointRequest into AuthServerRequestOptions --- .../oauth4swift/Sources/OAuth/Authorize.swift | 53 ++++++++++++++++- .../Sources/OAuth/OAuthComponents.swift | 57 ------------------- .../Session/OAuthSession+AuthRequest.swift | 3 +- 3 files changed, 51 insertions(+), 62 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index 7e1cf10..26c1a86 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -64,7 +64,6 @@ public struct AuthServerRequestOptions: Sendable { let clientId = authorizeInputs.appCredentials.clientId let challenge = authorizeInputs.pkceVerifier.challenge let scopes = authorizeInputs.appCredentials.requestedScopes.joined(separator: " ") - let callbackURI = authorizeInputs.appCredentials.callbackURL let authServerMetadata = try await authFetcher.authServerDiscovery( issuer: authorizeInputs.issuer @@ -214,12 +213,11 @@ public struct AuthServerRequestOptions: Sendable { parameters["code_verifier"] = pkceVerifier } - return try await OAuthComponents.tokenEndpointRequest( + return try await tokenEndpointRequest( authServerMetadata: authServerMetadata, grantType: .authorizationCode, parameters: parameters, headers: [:], - authFetcher: authFetcher ) } @@ -246,6 +244,55 @@ public struct AuthServerRequestOptions: Sendable { return (sessionState, additionalParams) } + + func refreshTokenGrantRequest( + authServerMetadata: AuthServerMetadata, + refreshToken: String, + ) async throws -> HTTPDataResponse { + var parameters = additionalParameters + parameters["refresh_token"] = refreshToken + + return try await tokenEndpointRequest( + authServerMetadata: authServerMetadata, + grantType: .refreshToken, + parameters: parameters, + headers: [:], + ) + } + + func tokenEndpointRequest( + authServerMetadata: AuthServerMetadata, + grantType: GrantType, + parameters: [String: String], + headers: [String: String], + ) async throws -> HTTPDataResponse { + let url = try authServerMetadata.resolve(endpoint: .token) + + var modifiedParams = parameters + modifiedParams["grant_type"] = grantType.rawValue + + var headers = headers + headers["accept"] = "application/json" + headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" + + var request = URLRequest(url: url) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + + request.httpMethod = HTTPMethod.post.rawValue + request.httpBody = modifiedParams.urlEncodedHTTPBody + + if let dpopSigner = self as? DPoPSigning { + return try await dpopSigner.authenticated( + request: request, + token: nil, + fetcher: authFetcher + ) + } else { + return try await authFetcher.data(for: request) + } + } } extension DPoPSigning { diff --git a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift index 81dbd02..36c062e 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/OAuthComponents.swift @@ -183,60 +183,3 @@ extension HTTPFetcher { return try await data(for: request) } } - -extension OAuthComponents { - static func refreshTokenGrantRequest( - authServerMetadata: AuthServerMetadata, - refreshToken: String, - authServerRequestOptions: AuthServerRequestOptions, - ) async throws -> HTTPDataResponse { - var parameters = authServerRequestOptions.additionalParameters - parameters["refresh_token"] = refreshToken - - return try await tokenEndpointRequest( - authServerMetadata: authServerMetadata, - grantType: .refreshToken, - parameters: parameters, - headers: [:], - authFetcher: authServerRequestOptions.authFetcher - ) - } - - static func tokenEndpointRequest( - authServerMetadata: AuthServerMetadata, - grantType: GrantType, - parameters: [String: String], - headers: [String: String], - authFetcher: HTTPFetcher - ) async throws -> HTTPDataResponse { - let url = try authServerMetadata.resolve(endpoint: .token) - - var modifiedParams = parameters - modifiedParams["grant_type"] = grantType.rawValue - - var headers = headers - headers["accept"] = "application/json" - headers["content-type"] = "application/x-www-form-urlencoded;charset=UTF-8" - - var request = URLRequest(url: url) - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - - request.httpMethod = HTTPMethod.post.rawValue - let paramsString = - try modifiedParams - .urlEncodedHTTPBody - request.httpBody = try modifiedParams.urlEncodedHTTPBody - - if let dpopSigner = self as? DPoPSigning { - return try await dpopSigner.authenticated( - request: request, - token: nil, - fetcher: authFetcher - ) - } else { - return try await authFetcher.data(for: request) - } - } -} diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift index db04c8c..96ebc7d 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Session/OAuthSession+AuthRequest.swift @@ -110,10 +110,9 @@ extension OAuthSessionCapabilities { ) async throws -> SessionState.Mutable { let authServerMetadata = try await authServerRequestOptions.authFetcher .authServerDiscovery(issuer: try await retriableIssuer) - let httpResponse = try await OAuthComponents.refreshTokenGrantRequest( + let httpResponse = try await authServerRequestOptions.refreshTokenGrantRequest( authServerMetadata: authServerMetadata, refreshToken: state.mutable.refreshToken.tryUnwrap.value, - authServerRequestOptions: authServerRequestOptions ) let response = try await OAuthComponents.processRefreshTokenResponse( response: httpResponse) From d0fb3be9a12a18a7e6b735da67b650218e04c8e9 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Wed, 11 Mar 2026 14:07:06 +0800 Subject: [PATCH 47/48] fix errors --- LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index 26c1a86..5cd0687 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -206,7 +206,7 @@ public struct AuthServerRequestOptions: Sendable { //Review: how does e.g., the atproto implementation signal that this is required? if let code = parsedRedirect.authCode { - parameters["code"] = parsedRedirect.authCode + parameters["code"] = code } if let pkceVerifier { @@ -283,7 +283,7 @@ public struct AuthServerRequestOptions: Sendable { request.httpMethod = HTTPMethod.post.rawValue request.httpBody = modifiedParams.urlEncodedHTTPBody - if let dpopSigner = self as? DPoPSigning { + if let dpopSigner { return try await dpopSigner.authenticated( request: request, token: nil, From acff9dfa17d459d2a00a662fdc1263b232ac4434 Mon Sep 17 00:00:00 2001 From: "Mark @ Germ" Date: Wed, 11 Mar 2026 21:52:59 +0800 Subject: [PATCH 48/48] add unit test for addProof --- .../oauth4swift/Sources/OAuth/Authorize.swift | 46 ------ ...PSigner.swift => DPoPRequestPayload.swift} | 0 .../{DPoPResponse.swift => DPoPSigning.swift} | 46 +++++- .../Sources/OAuth/DPoP/JWT/ECDSASigner.swift | 2 +- .../Sources/OAuth/DPoP/JWT/JWT.swift | 26 +-- .../Tests/OAuthTests/DPoPSignerTests.swift | 149 ++++++++++++++++++ 6 files changed, 212 insertions(+), 57 deletions(-) rename LocalPackages/oauth4swift/Sources/OAuth/DPoP/{DPoPSigner.swift => DPoPRequestPayload.swift} (100%) rename LocalPackages/oauth4swift/Sources/OAuth/DPoP/{DPoPResponse.swift => DPoPSigning.swift} (56%) create mode 100644 LocalPackages/oauth4swift/Tests/OAuthTests/DPoPSignerTests.swift diff --git a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift index 5cd0687..53ed68d 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/Authorize.swift @@ -295,52 +295,6 @@ public struct AuthServerRequestOptions: Sendable { } } -extension DPoPSigning { - func nonceRetryAuthenticated( - request: URLRequest, - token: String?, - authFetcher: HTTPFetcher - ) async throws -> HTTPDataResponse { - let firstResponse = try await authenticated( - request: request, - token: token, - fetcher: authFetcher - ) - - //retry if nonceError - if firstResponse.isDPoPNonceError { - return try await authenticated( - request: request, - token: token, - fetcher: authFetcher - ) - } else { - return firstResponse - } - } - - //tries just once - func authenticated( - request: URLRequest, - token: String?, - fetcher: HTTPFetcher - ) async throws -> HTTPDataResponse { - let proofRequest = try addProof( - request: request, - token: nil, - ) - - let response = try await fetcher.data(for: proofRequest) - - try cacheNonce( - response: response, - requestUrl: proofRequest.url.tryUnwrap - ) - - return response - } -} - extension [String: String] { var urlEncodedHTTPBody: Data { map({ [$0, $1].joined(separator: "=") }) diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigner.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPRequestPayload.swift similarity index 100% rename from LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigner.swift rename to LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPRequestPayload.swift diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigning.swift similarity index 56% rename from LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift rename to LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigning.swift index b1acfab..ac4aa90 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPResponse.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/DPoPSigning.swift @@ -1,5 +1,5 @@ // -// DPoPResponse.swift +// DPoPSigning.swift // OAuth // // Created by Mark @ Germ on 2/26/26. @@ -47,4 +47,48 @@ extension DPoPSigning { return output } + + func nonceRetryAuthenticated( + request: URLRequest, + token: String?, + authFetcher: HTTPFetcher + ) async throws -> HTTPDataResponse { + let firstResponse = try await authenticated( + request: request, + token: token, + fetcher: authFetcher + ) + + //retry if nonceError + if firstResponse.isDPoPNonceError { + return try await authenticated( + request: request, + token: token, + fetcher: authFetcher + ) + } else { + return firstResponse + } + } + + //tries just once + func authenticated( + request: URLRequest, + token: String?, + fetcher: HTTPFetcher + ) async throws -> HTTPDataResponse { + let proofRequest = try addProof( + request: request, + token: nil, + ) + + let response = try await fetcher.data(for: proofRequest) + + try cacheNonce( + response: response, + requestUrl: proofRequest.url.tryUnwrap + ) + + return response + } } diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/JWT/ECDSASigner.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/JWT/ECDSASigner.swift index f024496..325fa36 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/JWT/ECDSASigner.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/JWT/ECDSASigner.swift @@ -21,7 +21,7 @@ struct ECDSASigner { keyType: String, payload: some Encodable, ) throws -> JWT { - let headerEncoded = try JWT.JWTHeader( + let headerEncoded = try JWT.Header( typ: keyType, jwk: JWT.JWK(key: publicKey) ).jwtEncoded diff --git a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/JWT/JWT.swift b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/JWT/JWT.swift index 0906a8c..f4ecd02 100644 --- a/LocalPackages/oauth4swift/Sources/OAuth/DPoP/JWT/JWT.swift +++ b/LocalPackages/oauth4swift/Sources/OAuth/DPoP/JWT/JWT.swift @@ -31,9 +31,9 @@ extension JWT { //periphery: ignore //ignore codable properties - struct JWK: Sendable, Encodable { - let kty: String = "EC" - let crv: String = "P-256" + struct JWK: Sendable, Codable { + let kty: String + let crv: String let x: String let y: String @@ -41,14 +41,17 @@ extension JWT { // Public key consists of 04 | X | Y where X and Y are the same length // (Which, for P256, is 256 / 8 = 32 bytes each.) // https://developer.apple.com/forums/thread/680554 - let componentSize = JWT.JWTConstants.keySize / 8 + let componentSize = JWT.Constants.keySize / 8 let keyBytes = key.x963Representation guard keyBytes.count == (componentSize * 2 + 1) else { throw JWTError.badKey } - guard keyBytes[0] == JWT.JWTConstants.keyMarker else { + guard keyBytes[0] == JWT.Constants.keyMarker else { throw JWTError.badKey } + + self.kty = "EC" + self.crv = "P-256" self.x = keyBytes.subdata(in: 1..<(componentSize + 1)) .base64URLEncodedString() self.y = keyBytes.subdata(in: (componentSize + 1)..<(componentSize * 2 + 1)) @@ -58,18 +61,23 @@ extension JWT { //periphery:ignore //ignore codable properties - struct JWTHeader: Encodable { + struct Header: Codable { let typ: String - let alg: String = JWTConstants.ecdsaSignerAlg + let alg: String let jwk: JWK - init(typ: String?, jwk: JWK) { + init( + typ: String?, + alg: String = Constants.ecdsaSignerAlg, + jwk: JWK + ) { self.typ = typ ?? "JWT" + self.alg = alg self.jwk = jwk } } - struct JWTConstants { + struct Constants { static let keySize = 256 static let keyMarker = 0x04 static let ecdsaSignerAlg = "ES256" diff --git a/LocalPackages/oauth4swift/Tests/OAuthTests/DPoPSignerTests.swift b/LocalPackages/oauth4swift/Tests/OAuthTests/DPoPSignerTests.swift new file mode 100644 index 0000000..84b1734 --- /dev/null +++ b/LocalPackages/oauth4swift/Tests/OAuthTests/DPoPSignerTests.swift @@ -0,0 +1,149 @@ +// +// DPoPSignerTests.swift +// OAuth +// +// Created by Mark @ Germ on 3/11/26. +// + +import Crypto +import Foundation +import GermConvenience +import Testing + +@testable import OAuth + +struct Test { + let dpopSigner = AuthDPopState( + dpopKey: .generateP256(), + decoder: { (dataResponse, requestUrl) in + guard + let nonce = dataResponse.response.value( + forHTTPHeaderField: "DPoP-Nonce") + else { + return nil + } + + //henceforth should throw instead of return nil as nonce is expected + return try IndexedNonce( + responseUrl: dataResponse.response.url, + requestUrl: requestUrl, + nonce: nonce + ) + } + ) + + @Test( + "add DpopProof", + arguments: [UUID().uuidString, nil], + [UUID().uuidString, nil] + ) + func testAddProof(token: String?, nonce: String?) async throws { + let url = try #require(URL(string: "https://example.com/endpoint")) + + if let nonce { + await dpopSigner.cache( + nonce: .init( + origin: try url.origin, + nonce: nonce + ) + ) + } + + let signedRequest = try await dpopSigner.addProof( + request: .init(url: url), + token: token + ) + + let dpopHeader = try #require(signedRequest.value(forHTTPHeaderField: "DPoP")) + let jwt = try JWT(string: dpopHeader) + + let header = try JSONDecoder().decode( + JWT.Header.self, + from: try #require(Data(base64URLEncoded: jwt.header)) + ) + + #expect(header.typ == "dpop+jwt") + #expect(header.alg == "ES256") + + #expect(try header.jwk.verifyP256(jwt: jwt) == true) + } + + // @Test func singleDpopRequest() async throws { + // let request = URLRequest( + // url: try #require(URL(string: "example.com/endpoint")) + // ) + // + // let mockFetcher = MockFetcher { request in + // + // } + // + // let response = try await dpopSigner.authenticated( + // request: request, + // token: nil, + // fetcher: mockFetcher + // ) + // } +} + +extension AuthDPopState { + func cache(nonce: IndexedNonce) { + nonceCache.setObject(nonce, forKey: nonce.origin as NSString) + } +} + +extension JWT { + init(string: String) throws { + let components = string.split(separator: JWT.period) + #expect(components.count == 3) + self.init( + header: .init(components[0]), + payload: .init(components[1]), + signature: .init(components[2]) + ) + } +} + +extension JWT.JWK { + + func verifyP256(jwt: JWT) throws -> Bool { + let signOver = (jwt.header + [JWT.period] + jwt.payload).utf8Data + let signatureData = try #require(Data(base64URLEncoded: jwt.signature)) + + return try p256Key.isValidSignature( + .init(rawRepresentation: signatureData), + for: SHA256.hash(data: signOver) + ) + } + + var p256Key: P256.Signing.PublicKey { + get throws { + #expect(kty == "EC") + #expect(crv == "P-256") + let xComponent = try #require(Data(base64URLEncoded: x)) + let yComponent = try #require(Data(base64URLEncoded: y)) + + // Public key consists of 04 | X | Y where X and Y are the same length + // (Which, for P256, is 256 / 8 = 32 bytes each.) + // https://developer.apple.com/forums/thread/680554 + let x963 = [UInt8(4)] + xComponent + yComponent + + return try P256.Signing.PublicKey(x963Representation: x963) + } + } +} + +struct MockFetcher { + let host: String = "example.com" + + let resolver: @Sendable (URLRequest) throws -> HTTPDataResponse +} + +extension MockFetcher: HTTPFetcher { + func data(for request: URLRequest) async throws -> HTTPDataResponse { + let url = try #require(request.url) + assert(url.scheme == "https") + assert(url.host() == host) + + return try resolver(request) + } +}