From 96ae7fa7377979f10d9ccb9e9e58e6a6d717e43d Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 6 Mar 2026 17:19:07 +0100 Subject: [PATCH 1/2] Fix Bluesky token error handling This is actually generic OAuth token response logic, but the package is currently structured to make this bluesky specific. --- Sources/OAuthenticator/Authenticator.swift | 3 + Sources/OAuthenticator/Services/Bluesky.swift | 126 ++++++++++-------- 2 files changed, 73 insertions(+), 56 deletions(-) diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 72b5a11..05aa4bb 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -14,6 +14,9 @@ public enum AuthenticatorError: Error, Hashable { case refreshUnsupported case refreshNotPossible case tokenInvalid + case invalidRequest(String, String) + case invalidGrant(String, String) + case unrecognizedError(String, String) case manualAuthenticationRequired case httpResponseExpected case unauthorizedRefreshFailed diff --git a/Sources/OAuthenticator/Services/Bluesky.swift b/Sources/OAuthenticator/Services/Bluesky.swift index 3321870..4a52971 100644 --- a/Sources/OAuthenticator/Services/Bluesky.swift +++ b/Sources/OAuthenticator/Services/Bluesky.swift @@ -1,6 +1,7 @@ import Foundation + #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Find the spec here: https://atproto.com/specs/oauth @@ -118,10 +119,15 @@ public enum Bluesky { } } - private static func loginProvider(server: ServerMetadata, validator: @escaping TokenSubscriberValidator) -> TokenHandling.LoginProvider { + private static func loginProvider( + server: ServerMetadata, validator: @escaping TokenSubscriberValidator + ) -> TokenHandling.LoginProvider { return { params in // decode the params in the redirectURL - guard let redirectComponents = URLComponents(url: params.redirectURL, resolvingAgainstBaseURL: false) else { + guard + let redirectComponents = URLComponents( + url: params.redirectURL, resolvingAgainstBaseURL: false) + else { throw AuthenticatorError.missingTokenURL } @@ -141,11 +147,6 @@ public enum Bluesky { throw AuthenticatorError.issuingServerMismatch(iss, server.issuer) } - // and use them (plus just a little more) to construct the token request - guard let tokenURL = URL(string: server.tokenEndpoint) else { - throw AuthenticatorError.missingTokenURL - } - guard let verifier = params.pcke?.verifier else { throw AuthenticatorError.pkceRequired } @@ -158,39 +159,23 @@ public enum Bluesky { client_id: params.credentials.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 (data, _) = try await params.responseProvider(request) - - let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) - - guard tokenResponse.token_type == "DPoP" else { - throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type) - } - - if try await validator(tokenResponse, server.issuer) == false { - throw AuthenticatorError.tokenInvalid - } - - return tokenResponse.login(for: iss) + return try await Bluesky.requestToken( + tokenRequest, + authorizationServer: server, + validator: validator, + responseProvider: params.responseProvider + ) } } - private static func refreshProvider(server: ServerMetadata, validator: @escaping TokenSubscriberValidator) -> TokenHandling.RefreshProvider { + private static func refreshProvider( + server: ServerMetadata, validator: @escaping TokenSubscriberValidator + ) -> TokenHandling.RefreshProvider { { login, credentials, responseProvider -> Login in guard let refreshToken = login.refreshToken?.value else { throw AuthenticatorError.refreshNotPossible } - guard let tokenURL = URL(string: server.tokenEndpoint) else { - throw AuthenticatorError.missingTokenURL - } - let tokenRequest = RefreshTokenRequest( refresh_token: refreshToken, redirect_uri: credentials.callbackURL.absoluteString, @@ -198,36 +183,65 @@ public enum Bluesky { client_id: credentials.clientId ) - var request = URLRequest(url: tokenURL) - - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(tokenRequest) - - let (data, response) = try await responseProvider(request) + return try await Bluesky.requestToken( + tokenRequest, + authorizationServer: server, + validator: validator, + responseProvider: responseProvider + ) + } + } - // make sure that we got a successful HTTP response - guard - let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 - else { - print("data:", String(decoding: data, as: UTF8.self)) - print("response:", response) + private static func requestToken( + _ tokenRequest: Encodable, + authorizationServer: ServerMetadata, + validator: @escaping TokenSubscriberValidator, + responseProvider: URLResponseProvider + ) async throws -> Login { + guard let tokenURL = URL(string: authorizationServer.tokenEndpoint) else { + throw AuthenticatorError.missingTokenURL + } - throw AuthenticatorError.refreshNotPossible + 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 (data, response) = try await responseProvider(request) + + guard + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 + else { + if let error = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { + switch error.error { + case "invalid_request": + throw AuthenticatorError.invalidRequest(error.error, error.errorDescription ?? "") + case "invalid_grant": + throw AuthenticatorError.invalidGrant(error.error, error.errorDescription ?? "") + default: + throw AuthenticatorError.unrecognizedError(error.error, error.errorDescription ?? "") + } } - let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) + throw AuthenticatorError.unrecognizedError( + "unknown_response", "Received an unexpected response from the authorization server") + } - guard tokenResponse.token_type == "DPoP" else { - throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type) - } + guard let tokenResponse = try? JSONDecoder().decode(TokenResponse.self, from: data) else { + throw AuthenticatorError.unrecognizedError("invalid_json", "Decoding response JSON") + } - if try await validator(tokenResponse, server.issuer) == false { - throw AuthenticatorError.tokenInvalid - } + guard tokenResponse.token_type == "DPoP" else { + throw AuthenticatorError.dpopTokenExpected(tokenResponse.token_type) + } - return tokenResponse.login(for: server.issuer) + if try await validator(tokenResponse, authorizationServer.issuer) == false { + throw AuthenticatorError.tokenInvalid } + + return tokenResponse.login(for: authorizationServer.issuer) } } From ef66626b32c0f7ad2bed25ba5dfcb7722061b33c Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 6 Mar 2026 17:32:21 +0100 Subject: [PATCH 2/2] Fix PAR error handling --- Sources/OAuthenticator/Authenticator.swift | 30 ++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 05aa4bb..044e545 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -29,6 +29,7 @@ public enum AuthenticatorError: Error, Hashable { case stateTokenMismatch(String, String) case issuingServerMismatch(String, String) case pkceRequired + case rateLimited(HTTPURLResponse) } /// Manage state required to executed authenticated URLRequests. @@ -398,9 +399,34 @@ extension Authenticator { request.httpBody = Data(body.utf8) - let (parData, _) = try await self.dpopResponse(for: request, login: nil, isAuthServer: true) + let (data, response) = try await self.dpopResponse(for: request, login: nil, isAuthServer: true) - return try JSONDecoder().decode(PARResponse.self, from: parData) + guard let httpResponse = response as? HTTPURLResponse else { + throw AuthenticatorError.httpResponseExpected + } + + switch httpResponse.statusCode { + case 201: + return try JSONDecoder().decode(PARResponse.self, from: data) + // Expected response error status codes 405, 413, 429: + // See: https://www.rfc-editor.org/rfc/rfc9126.html#section-2.3 + case 413: + throw AuthenticatorError.invalidRequest("invalid_request", "PAR Request body too large") + case 429: + throw AuthenticatorError.rateLimited(httpResponse) + default: + if let error = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { + switch error.error { + case "invalid_request": + throw AuthenticatorError.invalidRequest(error.error, error.errorDescription ?? "") + default: + throw AuthenticatorError.unrecognizedError(error.error, error.errorDescription ?? "") + } + } else { + throw AuthenticatorError.unrecognizedError( + "unknown", "An unknown error occurred when making pushed authorization request") + } + } } private func getPARRequestURI() async throws -> String? {