diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 4f9ad5c..9ba32c4 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -26,6 +26,57 @@ public enum AuthenticatorError: Error, Hashable { case stateTokenMismatch(String, String) case issuingServerMismatch(String, String) case pkceRequired + case tokenRequestFailed(String) + case parRequestFailed(String) +} + +extension AuthenticatorError: LocalizedError { + public var errorDescription: String? { + switch self { + case .missingScheme: + return "Missing URL scheme in callback" + case .missingAuthorizationCode: + return "Missing authorization code in callback URL" + case .missingTokenURL: + return "Missing token endpoint URL" + case .missingAuthorizationURL: + return "Missing authorization endpoint URL" + case .refreshUnsupported: + return "Token refresh is not supported" + case .refreshNotPossible: + return "Token refresh is not possible (no valid refresh token)" + case .tokenInvalid: + return "Token is invalid" + case .manualAuthenticationRequired: + return "Manual authentication required" + case .httpResponseExpected: + return "Expected HTTP response but received a different type" + case .unauthorizedRefreshFailed: + return "Unauthorized refresh attempt failed" + case .missingRedirectURI: + return "Missing redirect URI" + case .missingRefreshToken: + return "Missing refresh token" + case .missingScope: + return "Missing scope" + case .failingAuthenticatorUsed: + return "Failing authenticator placeholder was used" + case .dpopTokenExpected(let value): + return "Expected DPoP token but received: \(value)" + case .parRequestURIMissing: + return "PAR request URI missing from response" + case .stateTokenMismatch(let received, let expected): + return "State token mismatch. Received \(received), expected \(expected)." + case .issuingServerMismatch(let received, let expected): + return "Issuer mismatch. Received \(received), expected \(expected)." + case .pkceRequired: + return "PKCE is required but not configured" + case .tokenRequestFailed(let message): + return "Token request failed: \(message)" + case .parRequestFailed(let message): + return "PAR request failed: \(message)" + } + } } /// Manage state required to executed authenticated URLRequests. @@ -384,14 +435,26 @@ extension Authenticator { "code_challenge_method": challenge.method, ] - let body = params + var components = URLComponents() + components.queryItems = params .merging(base, uniquingKeysWith: { a, b in a }) - .map({ [$0, $1].joined(separator: "=") }) - .joined(separator: "&") + .map { URLQueryItem(name: $0, value: $1) } + + // URLComponents.percentEncodedQuery handles form encoding (spaces → %20, etc.) + let body = components.percentEncodedQuery ?? "" request.httpBody = Data(body.utf8) - let (parData, _) = try await dpopResponse(for: request, login: nil) + let (parData, parResponse) = try await dpopResponse(for: request, login: nil) + + guard let httpResponse = parResponse as? HTTPURLResponse else { + throw AuthenticatorError.httpResponseExpected + } + + guard (200..<300).contains(httpResponse.statusCode) else { + let body = String(decoding: parData, as: UTF8.self) + throw AuthenticatorError.parRequestFailed(body) + } return try JSONDecoder().decode(PARResponse.self, from: parData) } diff --git a/Sources/OAuthenticator/DPoPSigner.swift b/Sources/OAuthenticator/DPoPSigner.swift index fc87408..9dc4b74 100644 --- a/Sources/OAuthenticator/DPoPSigner.swift +++ b/Sources/OAuthenticator/DPoPSigner.swift @@ -152,6 +152,14 @@ extension DPoPSigner { return (data, response) } + // On a successful response, store the updated nonce for future requests + // but return immediately. Retrying a successful request would break + // single-use operations like authorization code exchange (RFC 9449 §4.1). + if let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) { + return (data, response) + } + print("DPoP nonce updated", existingNonce ?? "", nonce ?? "") // repeat once, using newly-established nonce diff --git a/Sources/OAuthenticator/Services/Bluesky.swift b/Sources/OAuthenticator/Services/Bluesky.swift index 3321870..f558271 100644 --- a/Sources/OAuthenticator/Services/Bluesky.swift +++ b/Sources/OAuthenticator/Services/Bluesky.swift @@ -165,9 +165,24 @@ public enum Bluesky { request.setValue("application/json", forHTTPHeaderField: "Accept") request.httpBody = try JSONEncoder().encode(tokenRequest) - let (data, _) = try await params.responseProvider(request) + let (data, response) = try await params.responseProvider(request) - let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) + guard let httpResponse = response as? HTTPURLResponse else { + throw AuthenticatorError.httpResponseExpected + } + + guard httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { + let payload = String(decoding: data, as: UTF8.self) + throw AuthenticatorError.tokenRequestFailed(payload) + } + + let tokenResponse: TokenResponse + do { + tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) + } catch { + let payload = String(decoding: data, as: UTF8.self) + throw AuthenticatorError.tokenRequestFailed(payload) + } guard tokenResponse.token_type == "DPoP" else { throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type) diff --git a/Sources/OAuthenticator/WebAuthenticationSession+Utility.swift b/Sources/OAuthenticator/WebAuthenticationSession+Utility.swift index fb4f6ae..050fe47 100644 --- a/Sources/OAuthenticator/WebAuthenticationSession+Utility.swift +++ b/Sources/OAuthenticator/WebAuthenticationSession+Utility.swift @@ -1,8 +1,7 @@ -#if canImport(AuthenticationServices) +#if canImport(AuthenticationServices) && canImport(SwiftUI) && !os(macOS) && !targetEnvironment(macCatalyst) import AuthenticationServices import SwiftUI - @available(macOS 13.3, iOS 16.4, watchOS 9.4, tvOS 16.4, *) extension WebAuthenticationSession { public func userAuthenticator(preferredBrowserSession: BrowserSession? = nil) -> Authenticator.UserAuthenticator { diff --git a/Sources/OAuthenticator/WellknownEndpoints.swift b/Sources/OAuthenticator/WellknownEndpoints.swift index bf99ba3..4d2783b 100644 --- a/Sources/OAuthenticator/WellknownEndpoints.swift +++ b/Sources/OAuthenticator/WellknownEndpoints.swift @@ -66,7 +66,7 @@ public struct ServerMetadata: Codable, Hashable, Sendable { // 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 scope: String? public let redirectURIs: [String] public let dpopBoundAccessTokens: Bool @@ -100,7 +100,7 @@ extension ClientMetadata { return AppCredentials( clientId: clientId, clientPassword: "", - scopes: scope.components(separatedBy: " "), + scopes: scope?.components(separatedBy: " ") ?? [], callbackURL: url ) }