From 780e6ac3ebd0853072cb6467b1fa8f96f3c062f0 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Sat, 21 Feb 2026 05:57:01 +0100 Subject: [PATCH 1/6] Refactor DPoP Signing to correctly track nonces per origin This also ensures we clearly signal to the DPoP request method whether we are talking to an authorization server or a resource server (since these give different OAuth Error responses). The test coverage has increased dramatically here. --- Sources/OAuthenticator/Authenticator.swift | 46 +- Sources/OAuthenticator/DPoPSigner.swift | 239 +++++-- Sources/OAuthenticator/Models.swift | 1 + Sources/OAuthenticator/OAuthTypes.swift | 13 + Sources/OAuthenticator/URL+Origin.swift | 24 + Sources/OAuthenticator/URL+TargetURI.swift | 25 + Sources/OAuthenticator/URLHelpers.swift | 22 + .../AuthenticatorTests.swift | 4 +- .../OAuthenticatorTests/DPoPSignerTests.swift | 660 +++++++++++++++++- 9 files changed, 961 insertions(+), 73 deletions(-) create mode 100644 Sources/OAuthenticator/OAuthTypes.swift create mode 100644 Sources/OAuthenticator/URL+Origin.swift create mode 100644 Sources/OAuthenticator/URL+TargetURI.swift create mode 100644 Sources/OAuthenticator/URLHelpers.swift diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 4f9ad5c..72b5a11 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -308,20 +308,20 @@ extension Authenticator { pcke: config.tokenHandling.pkce, parRequestURI: parRequestURI, stateToken: stateToken, - responseProvider: { try await self.dpopResponse(for: $0, login: nil) } + responseProvider: { try await self.dpopResponse(for: $0, login: nil, isAuthServer: true) } ) let tokenURL = try await config.tokenHandling.authorizationURLProvider(authConfig) let scheme = try config.appCredentials.callbackURLScheme - let callbackURL = try await userAuthenticator(tokenURL, scheme) + let callbackURL = try await userAuthenticator(tokenURL, scheme) let params = TokenHandling.LoginProviderParameters( authorizationURL: tokenURL, credentials: config.appCredentials, redirectURL: callbackURL, - responseProvider: { try await self.dpopResponse(for: $0, login: nil) }, + responseProvider: { try await self.dpopResponse(for: $0, login: nil, isAuthServer: true) }, stateToken: stateToken, pcke: config.tokenHandling.pkce ) @@ -347,7 +347,11 @@ extension Authenticator { } do { - let login = try await refreshProvider(login, config.appCredentials, { try await self.dpopResponse(for: $0, login: nil) }) + let login = try await refreshProvider( + login, config.appCredentials, + { + try await self.dpopResponse(for: $0, login: nil, isAuthServer: true) + }) try await storeLogin(login) @@ -365,7 +369,7 @@ extension Authenticator { } let challenge = pkce.challenge - let scopes = config.appCredentials.scopes.joined(separator: " ") + let scopes = config.appCredentials.scopeString let callbackURI = config.appCredentials.callbackURL let clientId = config.appCredentials.clientId @@ -391,7 +395,7 @@ extension Authenticator { request.httpBody = Data(body.utf8) - let (parData, _) = try await dpopResponse(for: request, login: nil) + let (parData, _) = try await self.dpopResponse(for: request, login: nil, isAuthServer: true) return try JSONDecoder().decode(PARResponse.self, from: parData) } @@ -412,7 +416,31 @@ extension Authenticator { { try await self.response(for: $0) } } - private func dpopResponse(for request: URLRequest, login: Login?) async throws -> (Data, URLResponse) { + private func dpopResponse(for request: URLRequest, login: Login?) async throws -> ( + Data, URLResponse + ) { + var issuer: String? = nil + if let iss = login?.issuingServer { + issuer = URL(string: iss)?.origin + } + + guard let requestOrigin = request.url?.origin else { + throw DPoPError.requestInvalid(request) + } + + let isAuthServer = issuer == nil || issuer == requestOrigin + + return try await dpopResponse( + for: request, + login: login, + isAuthServer: isAuthServer + ) + } + + private func dpopResponse(for request: URLRequest, login: Login?, isAuthServer: Bool?) + async throws -> (Data, URLResponse) + { + print("Request: \(request.httpMethod!) - \(request.url?.absoluteString ?? "missing url")") guard let generator = config.tokenHandling.dpopJWTGenerator else { return try await urlLoader(request) } @@ -430,8 +458,8 @@ extension Authenticator { using: generator, token: token, tokenHash: tokenHash, - issuingServer: login?.issuingServer, - provider: urlLoader + isAuthServer: isAuthServer, + responseProvider: urlLoader ) } } diff --git a/Sources/OAuthenticator/DPoPSigner.swift b/Sources/OAuthenticator/DPoPSigner.swift index fc87408..3dfc5e7 100644 --- a/Sources/OAuthenticator/DPoPSigner.swift +++ b/Sources/OAuthenticator/DPoPSigner.swift @@ -1,8 +1,43 @@ import Foundation + #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif +public final class NonceValue { + public let origin: String + public let nonce: String + + init(origin: String, nonce: String) { + self.origin = origin + self.nonce = nonce + } +} + +extension NSCache where KeyType == NSString, ObjectType == NonceValue { + subscript(_ url: URL) -> String? { + get { + guard let key = url.origin else { + return nil + } + let value = object(forKey: key as NSString) + return value?.nonce + } + set { + guard let key = url.origin else { + return + } + + if let entry = newValue { + let value = NonceValue(origin: key, nonce: entry) + setObject(value, forKey: key as NSString) + } else { + removeObject(forKey: key as NSString) + } + } + } +} + public struct DPoPRequestPayload: Codable, Hashable, Sendable { public let uniqueCode: String public let httpMethod: String @@ -12,9 +47,8 @@ public struct DPoPRequestPayload: Codable, Hashable, Sendable { /// UNIX type, seconds since epoch public let expiresAt: Int public let nonce: String? - public let authorizationServerIssuer: String - public let accessTokenHash: String - + public let accessTokenHash: String? + public enum CodingKeys: String, CodingKey { case uniqueCode = "jti" case httpMethod = "htm" @@ -22,17 +56,15 @@ public struct DPoPRequestPayload: Codable, Hashable, Sendable { case createdAt = "iat" case expiresAt = "exp" case nonce - case authorizationServerIssuer = "iss" case accessTokenHash = "ath" } - + public init( httpMethod: String, httpRequestURL: String, createdAt: Int, expiresAt: Int, nonce: String, - authorizationServerIssuer: String, accessTokenHash: String ) { self.uniqueCode = UUID().uuidString @@ -41,13 +73,12 @@ public struct DPoPRequestPayload: Codable, Hashable, Sendable { self.createdAt = createdAt self.expiresAt = expiresAt self.nonce = nonce - self.authorizationServerIssuer = authorizationServerIssuer self.accessTokenHash = accessTokenHash } } -public enum DPoPError: Error { - case nonceExpected(URLResponse) +public enum DPoPError: Error, Equatable { + case urlResponseToHttpUrlResponseConversionFailed case requestInvalid(URLRequest) } @@ -64,51 +95,90 @@ public final class DPoPSigner { public let requestEndpoint: String public let nonce: String? public let tokenHash: String? - public let issuingServer: String? } - - public typealias NonceDecoder = (Data, URLResponse) throws -> String + public typealias JWTGenerator = @Sendable (JWTParameters) async throws -> String + + // Return value is (origin, nonce) + public typealias NonceDecoder = (Data, HTTPURLResponse) throws -> NonceValue? + private let nonceCache: NSCache = NSCache() private let nonceDecoder: NonceDecoder - public var nonce: String? - public static func nonceHeaderDecoder(data: Data, response: URLResponse) throws -> String { - guard let value = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "DPoP-Nonce") else { - print("data:", String(decoding: data, as: UTF8.self)) - throw DPoPError.nonceExpected(response) + public static func nonceHeaderDecoder(data: Data, response: HTTPURLResponse) throws -> NonceValue? + { + guard let value = response.value(forHTTPHeaderField: "DPoP-Nonce") else { + return nil + } + + // I'm not sure why response.url is optional, but maybe we need the request + // passed into the decoder here, to fallback to request.url.origin + guard let responseOrigin = response.url?.origin else { + return nil } - return value + return NonceValue(origin: responseOrigin, nonce: value) } public init(nonceDecoder: @escaping NonceDecoder = nonceHeaderDecoder) { self.nonceDecoder = nonceDecoder + self.nonceCache.countLimit = 20 + } + + // Test helper: + public func testRetrieveNonceForOrigin(url: URL) -> NonceValue? { + guard let origin = url.origin else { + return nil + } + + return nonceCache.object(forKey: origin as NSString) } } extension DPoPSigner { - public func authenticateRequest( + private func makeRequest( + _ request: inout URLRequest, + isolation: isolated (any Actor), + responseProvider: URLResponseProvider + ) + async throws -> (Data, HTTPURLResponse) + { + let (data, urlResponse) = try await responseProvider(request) + if let httpResponse = urlResponse as? HTTPURLResponse { + return (data, httpResponse) + } else { + throw DPoPError.urlResponseToHttpUrlResponseConversionFailed + } + } + + public func buildProof( _ request: inout URLRequest, isolation: isolated (any Actor), using jwtGenerator: JWTGenerator, + nonce: String?, token: String?, - tokenHash: String?, - issuer: String? + tokenHash: String? ) async throws { guard let method = request.httpMethod, - let url = request.url + let targetURI = request.url?.targetURI else { throw DPoPError.requestInvalid(request) } + // Protect against the `tokenHash`` not being supplied but we have a `token` + // This is why we really need to calculate the tokenHash internally. + if token != nil && tokenHash == nil { + throw DPoPError.requestInvalid(request) + } + let params = JWTParameters( keyType: "dpop+jwt", httpMethod: method, - requestEndpoint: url.absoluteString, + // `requestEndpoint` is the `htu` in the DPoP JWT, it should be the URL without the + // query or hash fragment: https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-4.6 + requestEndpoint: targetURI, nonce: nonce, - tokenHash: tokenHash, - issuingServer: issuer + tokenHash: tokenHash ) let jwt = try await jwtGenerator(params) @@ -120,43 +190,120 @@ extension DPoPSigner { } } - @discardableResult - public func setNonce(from response: URLResponse) -> Bool { - let newValue = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "dpop-nonce") - - nonce = newValue - - return newValue != nil - } - public func response( isolation: isolated (any Actor), for request: URLRequest, using jwtGenerator: JWTGenerator, token: String?, + // FIXME: Remove and use swift crypto internally to provide sha256, instead + // of using pkce.hashFunction in the caller to calculate the tokenHash tokenHash: String?, - issuingServer: String?, - provider: URLResponseProvider - ) async throws -> (Data, URLResponse) { + isAuthServer: Bool?, + responseProvider: URLResponseProvider + ) async throws -> (Data, HTTPURLResponse) { var request = request + // FIXME: calculate tokenHash using the value from the request Authorization + // header: + // + // `Authorization: DPoP access-token` + // + // which is `access-token`. This requires swift crypto or for DPoP Signer to + // have a sha256 hash function supplied. - try await authenticateRequest(&request, isolation: isolation, using: jwtGenerator, token: token, tokenHash: tokenHash, issuer: issuingServer) + // Requests must have a URL with an origin: + guard let requestOrigin = request.url?.origin else { + throw DPoPError.requestInvalid(request) + } - let (data, response) = try await provider(request) + let initNonce = nonceCache.object(forKey: requestOrigin as NSString) - let existingNonce = nonce + // build proof + try await buildProof( + &request, + isolation: isolation, + using: jwtGenerator, + nonce: initNonce?.nonce, + token: token, + tokenHash: tokenHash + ) - self.nonce = try nonceDecoder(data, response) + let (data, response) = try await makeRequest( + &request, + isolation: isolation, + responseProvider: responseProvider + ) + + // Extract the next nonce value if any; if we don't have a new nonce, return the response: + guard let nextNonce = try nonceDecoder(data, response) else { + return (data, response) + } - if nonce == existingNonce { + // 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 (data, response) } - print("DPoP nonce updated", existingNonce ?? "", nonce ?? "") + // Store the fresh nonce for future requests + nonceCache.setObject(nextNonce, forKey: nextNonce.origin as NSString) + + let shouldRetry = isUseDpopError(data: data, response: response, isAuthServer: isAuthServer) + if !shouldRetry { + return (data, response) + } // repeat once, using newly-established nonce - try await authenticateRequest(&request, isolation: isolation, using: jwtGenerator, token: token, tokenHash: tokenHash, issuer: issuingServer) + try await buildProof( + &request, + isolation: isolation, + using: jwtGenerator, + nonce: nextNonce.nonce, + token: token, + tokenHash: tokenHash + ) + + let (retryData, retryResponse) = try await makeRequest( + &request, + isolation: isolation, + responseProvider: responseProvider + ) + + if let retryNonce = try nonceDecoder(retryData, retryResponse) { + nonceCache.setObject(retryNonce, forKey: retryNonce.origin as NSString) + } + + return (retryData, retryResponse) + } + + // The logic here is taken from: + // https://github.com/bluesky-social/atproto/blob/4e96e2c7/packages/oauth/oauth-client/src/fetch-dpop.ts#L195 + private func isUseDpopError(data: Data, response: HTTPURLResponse, isAuthServer: Bool?) -> Bool { + print( + "isAuthServer: " + (isAuthServer == nil ? "nil" : (isAuthServer == true ? "true" : "false"))) + // https://datatracker.ietf.org/doc/html/rfc6750#section-3 + // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no + if isAuthServer == nil || isAuthServer == false { + if response.statusCode == 401 { + 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 + if isAuthServer == nil || isAuthServer == true { + if response.statusCode == 400 { + do { + let err = try JSONDecoder().decode(OAuthErrorResponse.self, from: data) + return err.error == "use_dpop_nonce" + } catch { + return false + } + } + } - return try await provider(request) + return false } } diff --git a/Sources/OAuthenticator/Models.swift b/Sources/OAuthenticator/Models.swift index 2e8329e..8039615 100644 --- a/Sources/OAuthenticator/Models.swift +++ b/Sources/OAuthenticator/Models.swift @@ -207,6 +207,7 @@ public struct TokenHandling: Sendable { throw AuthenticatorError.httpResponseExpected } + // FIXME: This isn't really to spec: 401 doesn't mean "refresh", it just means unauthorized. if response.statusCode == 401 { return .refresh } diff --git a/Sources/OAuthenticator/OAuthTypes.swift b/Sources/OAuthenticator/OAuthTypes.swift new file mode 100644 index 0000000..7358488 --- /dev/null +++ b/Sources/OAuthenticator/OAuthTypes.swift @@ -0,0 +1,13 @@ +/// 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/Sources/OAuthenticator/URL+Origin.swift b/Sources/OAuthenticator/URL+Origin.swift new file mode 100644 index 0000000..207321f --- /dev/null +++ b/Sources/OAuthenticator/URL+Origin.swift @@ -0,0 +1,24 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension URL { + var origin: String? { + guard + let host = self.host, + let scheme = self.scheme + else { + return nil + } + + var originComponents = URLComponents() + originComponents.scheme = scheme + originComponents.host = host + + omitWebDefaultPort(components: &originComponents, port: self.port, scheme: scheme) + + return originComponents.string + } +} diff --git a/Sources/OAuthenticator/URL+TargetURI.swift b/Sources/OAuthenticator/URL+TargetURI.swift new file mode 100644 index 0000000..2d75c6f --- /dev/null +++ b/Sources/OAuthenticator/URL+TargetURI.swift @@ -0,0 +1,25 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension URL { + var targetURI: String? { + 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 + + omitWebDefaultPort(components: &originComponents, port: self.port, scheme: scheme) + + return originComponents.string + } +} diff --git a/Sources/OAuthenticator/URLHelpers.swift b/Sources/OAuthenticator/URLHelpers.swift new file mode 100644 index 0000000..4e9eebf --- /dev/null +++ b/Sources/OAuthenticator/URLHelpers.swift @@ -0,0 +1,22 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +// Only sets the port if the port is not the default port for http or https requests: +internal func omitWebDefaultPort(components: inout URLComponents, port: Int?, scheme: String) { + guard let port = port else { + return + } + + if scheme == "http" || scheme == "https" { + if scheme == "http" && port != 80 { + components.port = port + } else if scheme == "https" && port != 443 { + components.port = port + } + } else { + components.port = port + } +} diff --git a/Tests/OAuthenticatorTests/AuthenticatorTests.swift b/Tests/OAuthenticatorTests/AuthenticatorTests.swift index c15cd5f..8a3ca15 100644 --- a/Tests/OAuthenticatorTests/AuthenticatorTests.swift +++ b/Tests/OAuthenticatorTests/AuthenticatorTests.swift @@ -513,7 +513,7 @@ struct AuthenticatorTests { ) let storedLogin = Login( - accessToken: Token(value: "EXPIRE SOON", expiry: Date().addingTimeInterval(5)), + accessToken: Token(value: "EXPIRE SOON", expiry: Date().addingTimeInterval(15)), refreshToken: Token(value: "REFRESH") ) @@ -550,7 +550,7 @@ struct AuthenticatorTests { #expect(events1 == expected1) // Let the token expire - try await Task.sleep(for: .seconds(5)) + try await Task.sleep(for: .seconds(20)) let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) continuation.checkpoint() diff --git a/Tests/OAuthenticatorTests/DPoPSignerTests.swift b/Tests/OAuthenticatorTests/DPoPSignerTests.swift index ea0f03e..176a763 100644 --- a/Tests/OAuthenticatorTests/DPoPSignerTests.swift +++ b/Tests/OAuthenticatorTests/DPoPSignerTests.swift @@ -1,13 +1,86 @@ import Foundation +import OAuthenticator +import Testing + #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif -import Testing -import OAuthenticator +enum RequestError: Error, Equatable { + case tooManyRequests +} + +final class MockResponseProvider: @unchecked Sendable { + + var responses: [Result<(Data, HTTPURLResponse), Error>] = [] + private(set) var requests: [URLRequest] = [] + private let lock = NSLock() + + init() {} + + func response(for request: URLRequest) throws -> (Data, HTTPURLResponse) { + try lock.withLock { + requests.append(request) + + if requests.count > responses.count { + throw RequestError.tooManyRequests + } + + return try responses[requests.count - 1].get() + } + } + + var allRequested: Bool { + return requests.count == responses.count + } + + var notAllRequested: Bool { + return requests.count < responses.count + } + + var responseProvider: URLResponseProvider { + return { try self.response(for: $0) } + } +} + +typealias Assertions = + @Sendable ( + _ request: Int, + _ parameters: DPoPSigner.JWTParameters, + _ loader: MockResponseProvider? + ) throws -> Void + +func genericJWTGenerator() -> DPoPSigner.JWTGenerator { + return { _ in "my_fake_jwt" } +} + +func assertingJWTGenerator(loader: MockResponseProvider?, assertions: Assertions?) + -> DPoPSigner.JWTGenerator +{ + return { parameters in + var req = 0 + if let requests = loader?.requests { + req = requests.count + } + debugPrint("Request:", req, "Params:", parameters) + + if let assertions = assertions { + try assertions(req, parameters, loader) + } + + return "my_fake_jwt" + } +} -struct ExamplePayload: Codable, Hashable, Sendable { - let value: String +func RequestFor(url: String, method: String = "GET") -> URLRequest { + var request = URLRequest(url: URL(string: url)!) + request.httpMethod = method + return request +} + +struct JWTAssertion { + let htu: String + let htm: String } struct DPoPSignerTests { @@ -15,24 +88,579 @@ struct DPoPSignerTests { @Test func basicSignature() async throws { let signer = DPoPSigner() + var request = RequestFor(url: "https://resource.example/test") + let assertTokenParams = assertingJWTGenerator( + loader: nil, + assertions: { + (request: Int, parameters: DPoPSigner.JWTParameters, loader: MockResponseProvider?) in + + #expect(parameters.httpMethod == "GET") + #expect(parameters.requestEndpoint == "https://resource.example/test") + #expect(parameters.nonce == "test_nonce") + #expect(parameters.tokenHash == "token_hash") + }) + + try await signer.buildProof( + &request, + isolation: MainActor.shared, + using: assertTokenParams, + nonce: "test_nonce", + token: "token", + tokenHash: "token_hash" + ) + + #expect(request.value(forHTTPHeaderField: "Authorization") == "DPoP token") + #expect(request.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") + } + + @MainActor + @Test func missingTokenHashThrows() async throws { + let signer = DPoPSigner() + var request = RequestFor(url: "https://resource.example/test") + + await #expect(throws: DPoPError.requestInvalid(request)) { + try await signer.buildProof( + &request, + isolation: MainActor.shared, + using: genericJWTGenerator(), + nonce: "test_nonce", + token: "token", + tokenHash: nil + ) + } + } + + @MainActor + @Test func withoutParameters() async throws { + let signer = DPoPSigner() + + var request = RequestFor(url: "https://resource.example/test") + let assertTokenParams = assertingJWTGenerator( + loader: nil, + assertions: { + (request: Int, parameters: DPoPSigner.JWTParameters, loader: MockResponseProvider?) in + + #expect(parameters.httpMethod == "GET") + #expect(parameters.requestEndpoint == "https://resource.example/test") + #expect(parameters.nonce == nil) + #expect(parameters.tokenHash == nil) + }) + + try await signer.buildProof( + &request, + isolation: MainActor.shared, + using: assertTokenParams, + nonce: nil, + token: nil, + tokenHash: nil + ) + + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + #expect(request.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") + } + + @MainActor + @Test( + "Correctly constructs the JWTParameters", + arguments: zip( + [ + RequestFor(url: "https://example.com/foo/bar/baz.json"), + RequestFor(url: "https://example.com/foo.json?query=param"), + RequestFor(url: "https://example.com/foo.json#fragment"), + RequestFor(url: "https://example.com/foo.json?foo=bar#fragment"), + RequestFor(url: "https://example.com/foo?query=param", method: "POST"), + ], + [ + JWTAssertion(htu: "https://example.com/foo/bar/baz.json", htm: "GET"), + JWTAssertion(htu: "https://example.com/foo.json", htm: "GET"), + JWTAssertion(htu: "https://example.com/foo.json", htm: "GET"), + JWTAssertion(htu: "https://example.com/foo.json", htm: "GET"), + JWTAssertion(htu: "https://example.com/foo", htm: "POST"), + ])) + func handlesParameters(inputRequest: URLRequest, expectedParams: JWTAssertion) async throws { + var request = inputRequest + let signer = DPoPSigner() + + let assertTokenParams = assertingJWTGenerator( + loader: nil, + assertions: { + (request: Int, parameters: DPoPSigner.JWTParameters, loader: MockResponseProvider?) in + + debugPrint(parameters, expectedParams) + + #expect(parameters.httpMethod == expectedParams.htm) + #expect(parameters.requestEndpoint == expectedParams.htu) + }) + + try await signer.buildProof( + &request, + isolation: MainActor.shared, + using: assertTokenParams, + nonce: "test_nonce", + token: "token", + tokenHash: "token_hash" + ) + + #expect(request.value(forHTTPHeaderField: "Authorization") != nil) + #expect(request.value(forHTTPHeaderField: "DPoP") != nil) + } + + @MainActor + @Test func overwritesAuthorization() async throws { + // We expect the original request to not be modified: + let signer = DPoPSigner() + let authorization = "Bearer foo" + var request = URLRequest(url: URL(string: "https://example.com")!) + request.setValue(authorization, forHTTPHeaderField: "Authorization") - try await signer.authenticateRequest( + try await signer.buildProof( &request, isolation: MainActor.shared, - using: { _ in "my_fake_jwt" }, + using: genericJWTGenerator(), + nonce: "test_nonce", token: "token", - tokenHash: "token_hash", - issuer: "issuer" + tokenHash: "token_hash" ) - let headers = try #require(request.allHTTPHeaderFields) + #expect(request.value(forHTTPHeaderField: "Authorization") == "DPoP token") + #expect(request.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") + } - #expect(headers["Authorization"] == "DPoP token") -#if !os(Linux) - // I'm unsure why exactly this test is failing on Linux only, but I suspect it is due to - // platform differences in FoundationNetworking. - #expect(headers["DPoP"] == "my_fake_jwt") -#endif + @MainActor + @Test func invalidRequest() async throws { + // We expect the original request to not be modified: + let signer = DPoPSigner() + let authorization = "Bearer foo" + + var request = URLRequest(url: URL(string: "https://example.com")!) + request.setValue(authorization, forHTTPHeaderField: "Authorization") + + // Ensure the guard for url / method will throw: + request.url = nil + #expect(request.url == nil) + + await #expect(throws: DPoPError.requestInvalid(request)) { + try await signer.buildProof( + &request, + isolation: MainActor.shared, + using: genericJWTGenerator(), + nonce: "test_nonce", + token: "token", + tokenHash: "token_hash" + ) + } + + #expect(request.value(forHTTPHeaderField: "Authorization") == authorization) + #expect(request.value(forHTTPHeaderField: "DPoP") == nil) + } +} + +struct DPoPSignerRequestTests { + @MainActor + @Test func authorizationResponseSuccess() async throws { + let signer = DPoPSigner() + + let requestedURL = URL(string: "https://as.example/oauth/token")! + let request = URLRequest(url: requestedURL) + + let mockLoader = MockResponseProvider() + let payload = """ + {"access_token":"1", "sub":"2", "scope":"3", "token_type":"DPoP","expires_in":120} + """ + + mockLoader.responses = [ + .success( + ( + Data(payload.utf8), + HTTPURLResponse( + url: requestedURL, statusCode: 200, httpVersion: nil, + headerFields: ["DPoP-Nonce": "test-nonce"])! + )) + ] + + let (resultData, resultResponse) = try await signer.response( + isolation: MainActor.shared, + for: request, + using: assertingJWTGenerator(loader: mockLoader, assertions: nil), + token: "test-token", + tokenHash: "test-abc123", + isAuthServer: true, + responseProvider: mockLoader.responseProvider + ) + + #expect(mockLoader.allRequested) + + let nonce = try #require( + signer.testRetrieveNonceForOrigin(url: URL(string: "https://as.example")!) + ) + #expect(nonce.nonce == "test-nonce") + + #expect(resultResponse.statusCode == 200) + #expect(resultData == Data(payload.utf8)) + } + + @MainActor + @Test func resourceResponseWWWAuthInvalidRequest() async throws { + let signer = DPoPSigner() + + // We are testing that we can make a request against a Resource Server, + // which returns a WWW-Authenticate error due to invalid it being an invalid + // request (i.e., not DPoP), upon that error, we don't retry the request. + let requestedURL = URL(string: "https://resource.example.com/")! + let request = URLRequest(url: requestedURL) + + let mockLoader = MockResponseProvider() + let failurePayload = "failed" + + mockLoader.responses = [ + .success( + ( + Data(failurePayload.utf8), + HTTPURLResponse( + url: requestedURL, statusCode: 401, httpVersion: nil, + headerFields: [ + "WWW-Authenticate": "DPoP error=\"invalid_request\"", "DPoP-Nonce": "test-nonce-1", + ])! + )), + .success( + ( + Data(), + HTTPURLResponse( + url: requestedURL, statusCode: 200, httpVersion: nil, + headerFields: [ + "DPoP-Nonce": "test-nonce-2" + ])! + )), + ] + + let (resultData, resultResponse) = try await signer.response( + isolation: MainActor.shared, + for: request, + using: assertingJWTGenerator( + loader: mockLoader, + assertions: { + (request: Int, parameters: DPoPSigner.JWTParameters, loader: MockResponseProvider?) in + #expect(request == 0) + #expect(parameters.nonce == nil) + }), + token: "test-token", + tokenHash: "test-abc123", + isAuthServer: false, + responseProvider: mockLoader.responseProvider + ) + + // We don't expect the request to be + #expect(mockLoader.notAllRequested) + #expect(mockLoader.requests.count == 1) + + let nonce = try #require( + signer.testRetrieveNonceForOrigin(url: URL(string: "https://resource.example.com")!) + ) + #expect(nonce.nonce == "test-nonce-1") + + #expect(resultResponse.statusCode == 401) + #expect(resultData.elementsEqual(failurePayload.utf8)) + } + + @MainActor + @Test func resourceResponseWWWAuthRetry() async throws { + let signer = DPoPSigner() + + // We are testing that we can make a request against a Resource Server, + // which returns a WWW-Authenticate error due to invalid DPoP Nonce, + // upon that error, we retry the request with the given DPoP-Nonce header value. + let requestedURL = URL(string: "https://resource.example.com/")! + let request = URLRequest(url: requestedURL) + + let mockLoader = MockResponseProvider() + let payload = """ + {"access_token":"1", "sub":"2", "scope":"3", "token_type":"DPoP","expires_in":120} + """ + + mockLoader.responses = [ + .success( + ( + Data("".utf8), + HTTPURLResponse( + url: requestedURL, statusCode: 401, httpVersion: nil, + headerFields: [ + "WWW-Authenticate": "DPoP error=\"use_dpop_nonce\"", "DPoP-Nonce": "test-nonce-1", + ])! + )), + .success( + ( + Data(payload.utf8), + HTTPURLResponse( + url: requestedURL, statusCode: 200, httpVersion: nil, + headerFields: [ + "DPoP-Nonce": "test-nonce-2" + ])! + )), + ] + + let (resultData, resultResponse) = try await signer.response( + isolation: MainActor.shared, + for: request, + using: assertingJWTGenerator( + loader: mockLoader, + assertions: { + (request: Int, parameters: DPoPSigner.JWTParameters, loader: MockResponseProvider?) in + if request == 0 { + #expect(parameters.nonce == nil) + } else if request == 1 { + #expect(parameters.nonce == "test-nonce-1") + } + }), + token: "test-token", + tokenHash: "test-abc123", + isAuthServer: false, + responseProvider: mockLoader.responseProvider + ) + + #expect(mockLoader.allRequested) + + let nonce = try #require( + signer.testRetrieveNonceForOrigin(url: URL(string: "https://resource.example.com")!) + ) + #expect(nonce.nonce == "test-nonce-2") + + #expect(resultResponse.statusCode == 200) + #expect(resultData.elementsEqual(payload.utf8)) + } + + @MainActor + @Test func authorizationResponseAfterDPoPError() async throws { + let signer = DPoPSigner() + + // We are making a request against an Authorization Server (the issuer), + // which returns a DPoP Error Response body, with a DPoP-Nonce header. The + // request is then retried with the supplied DPoP-Nonce header value, and + // succeeds. + let requestedURL = URL(string: "https://as.example/oauth/token")! + let request = URLRequest(url: requestedURL) + + let mockLoader = MockResponseProvider() + let nonceError = """ + { "error": "use_dpop_nonce", "error_description": "Authorization server requires nonce in DPoP proof" } + """ + let payload = """ + {"access_token":"1", "sub":"2", "scope":"3", "token_type":"DPoP","expires_in":120} + """ + + mockLoader.responses = [ + .success( + ( + Data(nonceError.utf8), + HTTPURLResponse( + url: requestedURL, statusCode: 400, httpVersion: nil, + headerFields: [ + "DPoP-Nonce": "test-nonce-1" + ])! + )), + .success( + ( + Data(payload.utf8), + HTTPURLResponse( + url: requestedURL, statusCode: 200, httpVersion: nil, + headerFields: ["DPoP-Nonce": "test-nonce-2"])! + )), + ] + + let (resultData, resultResponse) = try await signer.response( + isolation: MainActor.shared, + for: request, + using: assertingJWTGenerator( + loader: mockLoader, + assertions: { + (request: Int, parameters: DPoPSigner.JWTParameters, loader: MockResponseProvider?) in + if request == 0 { + #expect(parameters.nonce == nil) + } else if request == 1 { + #expect(parameters.nonce == "test-nonce-1") + } + }), + token: "test-token", + tokenHash: "test-abc123", + isAuthServer: nil, // this allows either AS or RS logic to apply + responseProvider: mockLoader.responseProvider + ) + + #expect(mockLoader.allRequested) + + let nonce = try #require( + signer.testRetrieveNonceForOrigin(url: URL(string: "https://as.example")!) + ) + #expect(nonce.nonce == "test-nonce-2") + + #expect(resultResponse.statusCode == 200) + #expect(resultData == Data(payload.utf8)) + } + + @MainActor + @Test func authorizationResponseAfterInvalidRequestError() async throws { + let signer = DPoPSigner() + + // We are making a request against an Authorization Server (the issuer), + // which returns a DPoP Error Response body, with a DPoP-Nonce header. The + // request is then retried with the supplied DPoP-Nonce header value, and + // succeeds. + let requestedURL = URL(string: "https://as.example/oauth/token")! + let request = URLRequest(url: requestedURL) + + let mockLoader = MockResponseProvider() + let oauthError = """ + { "error": "invalid_request", "error_description": "This request was not valid" } + """ + + mockLoader.responses = [ + .success( + ( + Data(oauthError.utf8), + HTTPURLResponse( + url: requestedURL, statusCode: 400, httpVersion: nil, + headerFields: [ + "DPoP-Nonce": "test-nonce-1" + ])! + )), + // We never actually get to this response: + .success( + ( + Data("never".utf8), + HTTPURLResponse( + url: requestedURL, statusCode: 200, httpVersion: nil, + headerFields: ["DPoP-Nonce": "test-nonce-2"])! + )), + ] + + let (resultData, resultResponse) = try await signer.response( + isolation: MainActor.shared, + for: request, + using: genericJWTGenerator(), + token: "test-token", + tokenHash: "test-abc123", + isAuthServer: true, + responseProvider: mockLoader.responseProvider + ) + + #expect(mockLoader.notAllRequested) + #expect(mockLoader.requests.count == 1) + + let nonce = try #require( + signer.testRetrieveNonceForOrigin(url: URL(string: "https://as.example")!) + ) + #expect(nonce.nonce == "test-nonce-1") + + #expect(resultResponse.statusCode == 400) + #expect(resultData == Data(oauthError.utf8)) + } + + @MainActor + @Test func requestsAgainstDifferentOrigins() async throws { + let signer = DPoPSigner() + + // We are making a request against an Authorization Server (the issuer), + // which succeed with a DPoP-Nonce header. Then we request against a + // resource server which succeeds. + let asRequestUrl = URL(string: "https://as.example/oauth/token")! + let asRequest = URLRequest(url: asRequestUrl) + + let rsRequestUrl = URL(string: "https://resource.example/")! + let rsRequest = URLRequest(url: rsRequestUrl) + + let mockLoader = MockResponseProvider() + let nonceError = """ + { "error": "use_dpop_nonce", "error_description": "Authorization server requires nonce in DPoP proof" } + """ + + mockLoader.responses = [ + .success( + ( + Data(nonceError.utf8), + HTTPURLResponse( + url: asRequestUrl, statusCode: 400, httpVersion: nil, + headerFields: [ + "DPoP-Nonce": "test-as-nonce-1" + ])! + )), + .success( + ( + Data("authorization server".utf8), + HTTPURLResponse( + url: asRequestUrl, statusCode: 200, httpVersion: nil, + headerFields: [ + "DPoP-Nonce": "test-as-nonce-2" + ])! + )), + // We never actually get to this response: + .success( + ( + Data("resource server".utf8), + HTTPURLResponse( + url: rsRequestUrl, statusCode: 200, httpVersion: nil, + headerFields: ["DPoP-Nonce": "test-rs-nonce-1"])! + )), + ] + + let tokenGenerator = assertingJWTGenerator( + loader: mockLoader, + assertions: { + (request: Int, parameters: DPoPSigner.JWTParameters, loader: MockResponseProvider?) in + if request == 0 { + #expect(parameters.nonce == nil) + #expect(parameters.requestEndpoint == "https://as.example/oauth/token") + } else if request == 1 { + #expect(parameters.nonce == "test-as-nonce-1") + #expect(parameters.requestEndpoint == "https://as.example/oauth/token") + } else if request == 2 { + // We don't have a DPoP Nonce for the resource server, because it's a new origin: + #expect(parameters.nonce == nil) + #expect(parameters.requestEndpoint == "https://resource.example/") + } + }) + + let asResult = try await signer.response( + isolation: MainActor.shared, + for: asRequest, + using: tokenGenerator, + token: "test-token", + tokenHash: "test-abc123", + isAuthServer: true, + responseProvider: mockLoader.responseProvider + ) + + // We retry due to nonce failure: + #expect(mockLoader.requests.count == 2) + + #expect(asResult.1.statusCode == 200) + #expect(asResult.0 == Data("authorization server".utf8)) + + let rsResult = try await signer.response( + isolation: MainActor.shared, + for: rsRequest, + using: tokenGenerator, + token: "test-token", + tokenHash: "test-abc123", + isAuthServer: false, + responseProvider: mockLoader.responseProvider + ) + + #expect(rsResult.1.statusCode == 200) + #expect(rsResult.0.elementsEqual("resource server".utf8)) + + // We now have the resource server request completed, so all requests are completed: + #expect(mockLoader.allRequested) + + // Check the Authorization Server DPoP-Nonce didn't clobber the Resource + // Server DPoP-Nonce: + let asNonce = try #require( + signer.testRetrieveNonceForOrigin(url: URL(string: "https://as.example")!) + ) + #expect(asNonce.nonce == "test-as-nonce-2") + + let rsNonce = try #require( + signer.testRetrieveNonceForOrigin(url: URL(string: "https://resource.example")!) + ) + #expect(rsNonce.nonce == "test-rs-nonce-1") } } From e72c5c2337572945eb4edf08dd752eb012c2f93b Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 6 Mar 2026 16:37:00 +0100 Subject: [PATCH 2/6] Remove mutability in buildProof --- Sources/OAuthenticator/DPoPSigner.swift | 26 ++++++----- .../OAuthenticatorTests/DPoPSignerTests.swift | 45 ++++++++++--------- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/Sources/OAuthenticator/DPoPSigner.swift b/Sources/OAuthenticator/DPoPSigner.swift index 3dfc5e7..a60f3b9 100644 --- a/Sources/OAuthenticator/DPoPSigner.swift +++ b/Sources/OAuthenticator/DPoPSigner.swift @@ -136,7 +136,7 @@ public final class DPoPSigner { extension DPoPSigner { private func makeRequest( - _ request: inout URLRequest, + _ request: URLRequest, isolation: isolated (any Actor), responseProvider: URLResponseProvider ) @@ -151,13 +151,13 @@ extension DPoPSigner { } public func buildProof( - _ request: inout URLRequest, + _ request: URLRequest, isolation: isolated (any Actor), using jwtGenerator: JWTGenerator, nonce: String?, token: String?, tokenHash: String? - ) async throws { + ) async throws -> URLRequest { guard let method = request.httpMethod, let targetURI = request.url?.targetURI @@ -183,11 +183,14 @@ extension DPoPSigner { let jwt = try await jwtGenerator(params) - request.setValue(jwt, forHTTPHeaderField: "DPoP") + var signedRequest = request + signedRequest.setValue(jwt, forHTTPHeaderField: "DPoP") if let token { - request.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") + signedRequest.setValue("DPoP \(token)", forHTTPHeaderField: "Authorization") } + + return signedRequest } public func response( @@ -201,7 +204,6 @@ extension DPoPSigner { isAuthServer: Bool?, responseProvider: URLResponseProvider ) async throws -> (Data, HTTPURLResponse) { - var request = request // FIXME: calculate tokenHash using the value from the request Authorization // header: // @@ -218,8 +220,8 @@ extension DPoPSigner { let initNonce = nonceCache.object(forKey: requestOrigin as NSString) // build proof - try await buildProof( - &request, + let request = try await buildProof( + request, isolation: isolation, using: jwtGenerator, nonce: initNonce?.nonce, @@ -228,7 +230,7 @@ extension DPoPSigner { ) let (data, response) = try await makeRequest( - &request, + request, isolation: isolation, responseProvider: responseProvider ) @@ -253,8 +255,8 @@ extension DPoPSigner { } // repeat once, using newly-established nonce - try await buildProof( - &request, + let retryRequest = try await buildProof( + request, isolation: isolation, using: jwtGenerator, nonce: nextNonce.nonce, @@ -263,7 +265,7 @@ extension DPoPSigner { ) let (retryData, retryResponse) = try await makeRequest( - &request, + retryRequest, isolation: isolation, responseProvider: responseProvider ) diff --git a/Tests/OAuthenticatorTests/DPoPSignerTests.swift b/Tests/OAuthenticatorTests/DPoPSignerTests.swift index 176a763..5fb701e 100644 --- a/Tests/OAuthenticatorTests/DPoPSignerTests.swift +++ b/Tests/OAuthenticatorTests/DPoPSignerTests.swift @@ -88,7 +88,7 @@ struct DPoPSignerTests { @Test func basicSignature() async throws { let signer = DPoPSigner() - var request = RequestFor(url: "https://resource.example/test") + let request = RequestFor(url: "https://resource.example/test") let assertTokenParams = assertingJWTGenerator( loader: nil, assertions: { @@ -100,8 +100,8 @@ struct DPoPSignerTests { #expect(parameters.tokenHash == "token_hash") }) - try await signer.buildProof( - &request, + let signedRequest = try await signer.buildProof( + request, isolation: MainActor.shared, using: assertTokenParams, nonce: "test_nonce", @@ -109,18 +109,18 @@ struct DPoPSignerTests { tokenHash: "token_hash" ) - #expect(request.value(forHTTPHeaderField: "Authorization") == "DPoP token") - #expect(request.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") + #expect(signedRequest.value(forHTTPHeaderField: "Authorization") == "DPoP token") + #expect(signedRequest.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") } @MainActor @Test func missingTokenHashThrows() async throws { let signer = DPoPSigner() - var request = RequestFor(url: "https://resource.example/test") + let request = RequestFor(url: "https://resource.example/test") await #expect(throws: DPoPError.requestInvalid(request)) { try await signer.buildProof( - &request, + request, isolation: MainActor.shared, using: genericJWTGenerator(), nonce: "test_nonce", @@ -134,7 +134,7 @@ struct DPoPSignerTests { @Test func withoutParameters() async throws { let signer = DPoPSigner() - var request = RequestFor(url: "https://resource.example/test") + let request = RequestFor(url: "https://resource.example/test") let assertTokenParams = assertingJWTGenerator( loader: nil, assertions: { @@ -146,8 +146,8 @@ struct DPoPSignerTests { #expect(parameters.tokenHash == nil) }) - try await signer.buildProof( - &request, + let signedRequest = try await signer.buildProof( + request, isolation: MainActor.shared, using: assertTokenParams, nonce: nil, @@ -155,8 +155,8 @@ struct DPoPSignerTests { tokenHash: nil ) - #expect(request.value(forHTTPHeaderField: "Authorization") == nil) - #expect(request.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") + #expect(signedRequest.value(forHTTPHeaderField: "Authorization") == nil) + #expect(signedRequest.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") } @MainActor @@ -178,7 +178,7 @@ struct DPoPSignerTests { JWTAssertion(htu: "https://example.com/foo", htm: "POST"), ])) func handlesParameters(inputRequest: URLRequest, expectedParams: JWTAssertion) async throws { - var request = inputRequest + let request = inputRequest let signer = DPoPSigner() let assertTokenParams = assertingJWTGenerator( @@ -192,8 +192,8 @@ struct DPoPSignerTests { #expect(parameters.requestEndpoint == expectedParams.htu) }) - try await signer.buildProof( - &request, + let signedRequest = try await signer.buildProof( + request, isolation: MainActor.shared, using: assertTokenParams, nonce: "test_nonce", @@ -201,8 +201,8 @@ struct DPoPSignerTests { tokenHash: "token_hash" ) - #expect(request.value(forHTTPHeaderField: "Authorization") != nil) - #expect(request.value(forHTTPHeaderField: "DPoP") != nil) + #expect(signedRequest.value(forHTTPHeaderField: "Authorization") != nil) + #expect(signedRequest.value(forHTTPHeaderField: "DPoP") != nil) } @MainActor @@ -214,8 +214,8 @@ struct DPoPSignerTests { var request = URLRequest(url: URL(string: "https://example.com")!) request.setValue(authorization, forHTTPHeaderField: "Authorization") - try await signer.buildProof( - &request, + let signedRequest = try await signer.buildProof( + request, isolation: MainActor.shared, using: genericJWTGenerator(), nonce: "test_nonce", @@ -223,8 +223,8 @@ struct DPoPSignerTests { tokenHash: "token_hash" ) - #expect(request.value(forHTTPHeaderField: "Authorization") == "DPoP token") - #expect(request.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") + #expect(signedRequest.value(forHTTPHeaderField: "Authorization") == "DPoP token") + #expect(signedRequest.value(forHTTPHeaderField: "DPoP") == "my_fake_jwt") } @MainActor @@ -242,7 +242,7 @@ struct DPoPSignerTests { await #expect(throws: DPoPError.requestInvalid(request)) { try await signer.buildProof( - &request, + request, isolation: MainActor.shared, using: genericJWTGenerator(), nonce: "test_nonce", @@ -251,6 +251,7 @@ struct DPoPSignerTests { ) } + // Should not mutate request: #expect(request.value(forHTTPHeaderField: "Authorization") == authorization) #expect(request.value(forHTTPHeaderField: "DPoP") == nil) } From a6ef3fe1667d7c244dc11b558c665f204af44cb4 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 6 Mar 2026 17:19:07 +0100 Subject: [PATCH 3/6] 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 7bcbef944593904386e70e9c8e2d5733e478b78b Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 6 Mar 2026 17:32:21 +0100 Subject: [PATCH 4/6] 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? { From fb21998b6c1412c755f143b7b2155fcc54bd654c Mon Sep 17 00:00:00 2001 From: Anna M Date: Thu, 15 Jan 2026 13:26:56 -0800 Subject: [PATCH 5/6] Allow passing along generic additional parameters with Login This allows apps using the Bluesky client to retrieve the DID for the authenticated account from the Login. --- Sources/OAuthenticator/Models.swift | 15 ++++++++++++--- Sources/OAuthenticator/Services/Bluesky.swift | 3 ++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/OAuthenticator/Models.swift b/Sources/OAuthenticator/Models.swift index 8039615..60e19ae 100644 --- a/Sources/OAuthenticator/Models.swift +++ b/Sources/OAuthenticator/Models.swift @@ -41,15 +41,24 @@ public struct Login: Codable, Hashable, Sendable { public var accessToken: Token public var refreshToken: Token? - // User authorized scopes - public var scopes: String? + // User authorized scopes + public var scopes: String? public var issuingServer: String? + + public var additionalParams: [String: String]? - public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil, issuingServer: String? = nil) { + public init( + accessToken: Token, + refreshToken: Token? = nil, + scopes: String? = nil, + issuingServer: String? = nil, + additionalParams: [String: String]? = nil, + ) { self.accessToken = accessToken self.refreshToken = refreshToken self.scopes = scopes self.issuingServer = issuingServer + self.additionalParams = additionalParams } public init(token: String, validUntilDate: Date? = nil) { diff --git a/Sources/OAuthenticator/Services/Bluesky.swift b/Sources/OAuthenticator/Services/Bluesky.swift index 4a52971..8963dfb 100644 --- a/Sources/OAuthenticator/Services/Bluesky.swift +++ b/Sources/OAuthenticator/Services/Bluesky.swift @@ -49,7 +49,8 @@ public enum Bluesky { accessToken: Token(value: access_token, expiresIn: expires_in), refreshToken: refresh_token.map { Token(value: $0) }, scopes: scope, - issuingServer: issuingServer + issuingServer: issuingServer, + additionalParams: ["did": sub] ) } From 7feba76160e286311364ef9184feee0bb5b8b998 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 6 Mar 2026 19:38:01 +0100 Subject: [PATCH 6/6] Fix authentication error handling --- Sources/OAuthenticator/Authenticator.swift | 45 ++++++- Sources/OAuthenticator/Models.swift | 11 +- Sources/OAuthenticator/Services/Bluesky.swift | 23 +--- .../URLComponents+QueryParams.swift | 11 ++ .../AuthenticatorTests.swift | 116 ++++++++++++++++++ Tests/OAuthenticatorTests/BlueskyTests.swift | 79 +++--------- 6 files changed, 197 insertions(+), 88 deletions(-) create mode 100644 Sources/OAuthenticator/URLComponents+QueryParams.swift diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 044e545..13db782 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -14,9 +14,11 @@ public enum AuthenticatorError: Error, Hashable { case refreshUnsupported case refreshNotPossible case tokenInvalid + case accessDenied case invalidRequest(String, String) case invalidGrant(String, String) case unrecognizedError(String, String) + case invalidScope case manualAuthenticationRequired case httpResponseExpected case unauthorizedRefreshFailed @@ -319,12 +321,51 @@ extension Authenticator { let scheme = try config.appCredentials.callbackURLScheme - let callbackURL = try await userAuthenticator(tokenURL, scheme) + let redirectURL = try await userAuthenticator(tokenURL, scheme) + guard + let redirectParams = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false) + else { + throw AuthenticatorError.missingTokenURL + } + + let iss = redirectParams.firstQueryValue("iss") + let state = redirectParams.firstQueryValue("state") + + if let serverIssuer = config.tokenHandling.issuer { + if serverIssuer != iss { + throw AuthenticatorError.issuingServerMismatch(iss ?? "iss parameter missing", serverIssuer) + } + } + + if let state = state { + if state != stateToken { + throw AuthenticatorError.stateTokenMismatch(state, stateToken) + } + } + + let error = redirectParams.firstQueryValue("error") + let errorDescription = redirectParams.firstQueryValue("error_description") + + if let error = error { + switch error.lowercased() { + case "access_denied": + throw AuthenticatorError.accessDenied + case "invalid_request": + throw AuthenticatorError.invalidRequest(error, errorDescription ?? "Invalid Request") + case "invalid_scope": + throw AuthenticatorError.invalidScope + default: + // We do actually have error and error_description parameters, so + // could create a more specific error than missingAuthorizationCode + throw AuthenticatorError.missingAuthorizationCode + } + } let params = TokenHandling.LoginProviderParameters( authorizationURL: tokenURL, credentials: config.appCredentials, - redirectURL: callbackURL, + redirectURL: redirectURL, + redirectParams: redirectParams, responseProvider: { try await self.dpopResponse(for: $0, login: nil, isAuthServer: true) }, stateToken: stateToken, pcke: config.tokenHandling.pkce diff --git a/Sources/OAuthenticator/Models.swift b/Sources/OAuthenticator/Models.swift index 60e19ae..718a80d 100644 --- a/Sources/OAuthenticator/Models.swift +++ b/Sources/OAuthenticator/Models.swift @@ -144,6 +144,7 @@ public struct TokenHandling: Sendable { public let authorizationURL: URL public let credentials: AppCredentials public let redirectURL: URL + public let redirectParams: URLComponents public let responseProvider: URLResponseProvider public let stateToken: String public let pcke: PKCEVerifier? @@ -151,7 +152,8 @@ public struct TokenHandling: Sendable { public init( authorizationURL: URL, credentials: AppCredentials, - redirectURL: URL, + redirectURL: URL, // Deprecated, however, fixing in other services is too complex + redirectParams: URLComponents, responseProvider: @escaping URLResponseProvider, stateToken: String, pcke: PKCEVerifier? @@ -159,6 +161,7 @@ public struct TokenHandling: Sendable { self.authorizationURL = authorizationURL self.credentials = credentials self.redirectURL = redirectURL + self.redirectParams = redirectParams self.responseProvider = responseProvider self.stateToken = stateToken self.pcke = pcke @@ -178,6 +181,7 @@ public struct TokenHandling: Sendable { public typealias RefreshProvider = @Sendable (Login, AppCredentials, URLResponseProvider) async throws -> Login public typealias ResponseStatusProvider = @Sendable ((Data, URLResponse)) throws -> ResponseStatus + public let issuer: String? public let authorizationURLProvider: AuthorizationURLProvider public let loginProvider: LoginProvider public let refreshProvider: RefreshProvider? @@ -187,15 +191,18 @@ public struct TokenHandling: Sendable { public let pkce: PKCEVerifier? public init( + issuer: String? = nil, parConfiguration: PARConfiguration? = nil, authorizationURLProvider: @escaping AuthorizationURLProvider, loginProvider: @escaping LoginProvider, refreshProvider: RefreshProvider? = nil, - responseStatusProvider: @escaping ResponseStatusProvider = Self.refreshOrAuthorizeWhenUnauthorized, + responseStatusProvider: @escaping ResponseStatusProvider = Self + .refreshOrAuthorizeWhenUnauthorized, dpopJWTGenerator: DPoPSigner.JWTGenerator? = nil, pkce: PKCEVerifier? = nil ) { + self.issuer = issuer self.authorizationURLProvider = authorizationURLProvider self.loginProvider = loginProvider self.refreshProvider = refreshProvider diff --git a/Sources/OAuthenticator/Services/Bluesky.swift b/Sources/OAuthenticator/Services/Bluesky.swift index 8963dfb..8b92109 100644 --- a/Sources/OAuthenticator/Services/Bluesky.swift +++ b/Sources/OAuthenticator/Services/Bluesky.swift @@ -70,6 +70,7 @@ public enum Bluesky { validator: @escaping TokenSubscriberValidator ) -> TokenHandling { TokenHandling( + issuer: server.issuer, parConfiguration: PARConfiguration( url: URL(string: server.pushedAuthorizationRequestEndpoint)!, parameters: { if let account { ["login_hint": account] } else { [:] } }() @@ -124,30 +125,10 @@ public enum Bluesky { 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 { - throw AuthenticatorError.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 { + guard let authCode = params.redirectParams.firstQueryValue("code") else { throw AuthenticatorError.missingAuthorizationCode } - if state != params.stateToken { - throw AuthenticatorError.stateTokenMismatch(state, params.stateToken) - } - - if iss != server.issuer { - throw AuthenticatorError.issuingServerMismatch(iss, server.issuer) - } - guard let verifier = params.pcke?.verifier else { throw AuthenticatorError.pkceRequired } diff --git a/Sources/OAuthenticator/URLComponents+QueryParams.swift b/Sources/OAuthenticator/URLComponents+QueryParams.swift new file mode 100644 index 0000000..2478f2c --- /dev/null +++ b/Sources/OAuthenticator/URLComponents+QueryParams.swift @@ -0,0 +1,11 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension URLComponents { + public func firstQueryValue(_ name: String) -> String? { + return queryItems?.first(where: { $0.name == name })?.value + } +} diff --git a/Tests/OAuthenticatorTests/AuthenticatorTests.swift b/Tests/OAuthenticatorTests/AuthenticatorTests.swift index 8a3ca15..8ec1cf2 100644 --- a/Tests/OAuthenticatorTests/AuthenticatorTests.swift +++ b/Tests/OAuthenticatorTests/AuthenticatorTests.swift @@ -435,6 +435,122 @@ struct AuthenticatorTests { #expect(events == ["status failure"]) } + @Test + func manualAuthenticationWithStateMismatch() async throws { + let urlProvider: TokenHandling.AuthorizationURLProvider = { params in + return URL(string: "https://server-metadata.test/authorize")! + } + + let loginProvider: TokenHandling.LoginProvider = { params in + return Login(token: "TOKEN") + } + + let tokenHandling = TokenHandling( + authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) + + let userAuthenticator: Authenticator.UserAuthenticator = { url, scheme in + return URL(string: "my://auth?state=invalid_state")! + } + + // Configure Authenticator with result callback + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + tokenHandling: tokenHandling, + mode: .manualOnly, + userAuthenticator: userAuthenticator + ) + + let auth = Authenticator(config: config, urlLoader: nil) + await #expect { + try await auth.authenticate() + } throws: { error in + switch error { + // We don't actually have access to the expected value which is the private stateToken on Authenticator + case AuthenticatorError.stateTokenMismatch(let actual, let expected) + where actual == "invalid_state" && !expected.isEmpty: + return true + default: + return false + } + } + } + + @Test + func manualAuthenticationWithIssuingServerMismatch() async throws { + let urlProvider: TokenHandling.AuthorizationURLProvider = { params in + return URL(string: "https://server-metadata.test/authorize?client_id=test")! + } + + let loginProvider: TokenHandling.LoginProvider = { params in + return Login(token: "TOKEN") + } + + let tokenHandling = TokenHandling( + issuer: "https://server-metadata.test", + authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) + + let userAuthenticator: Authenticator.UserAuthenticator = { url, scheme in + return URL(string: "my://auth?iss=https://server-metadata.invalid")! + } + + // Configure Authenticator with result callback + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + tokenHandling: tokenHandling, + mode: .manualOnly, + userAuthenticator: userAuthenticator + ) + + let auth = Authenticator(config: config, urlLoader: nil) + await #expect( + throws: AuthenticatorError.issuingServerMismatch( + "https://server-metadata.invalid", "https://server-metadata.test") + ) { + try await auth.authenticate() + } + } + + @Test + func manualAuthenticationWithValidIssuerButAccessDenied() async throws { + let urlProvider: TokenHandling.AuthorizationURLProvider = { params in + return URL(string: "https://server-metadata.test/authorize?client_id=test")! + } + + let loginProvider: TokenHandling.LoginProvider = { params in + return Login(token: "TOKEN") + } + + let tokenHandling = TokenHandling( + issuer: "https://server-metadata.test", + authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) + + let userAuthenticator: Authenticator.UserAuthenticator = { url, scheme in + return URL(string: "my://auth?iss=https://server-metadata.test&error=access_denied")! + } + + // Configure Authenticator with result callback + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + tokenHandling: tokenHandling, + mode: .manualOnly, + userAuthenticator: userAuthenticator + ) + + let auth = Authenticator(config: config, urlLoader: nil) + await #expect(throws: AuthenticatorError.accessDenied) { + try await auth.authenticate() + } + } + @Test func unauthorizedRequestRefreshes() async throws { let requestedURL = URL(string: "https://example.com")! diff --git a/Tests/OAuthenticatorTests/BlueskyTests.swift b/Tests/OAuthenticatorTests/BlueskyTests.swift index 2ae535f..a13c89d 100644 --- a/Tests/OAuthenticatorTests/BlueskyTests.swift +++ b/Tests/OAuthenticatorTests/BlueskyTests.swift @@ -1,77 +1,25 @@ import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif +import OAuthenticator import Testing -import OAuthenticator +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif struct BlueskyTests { - @Test - func issuingServerMismatch() async throws { - let metadataContent = """ - {"issuer":"https://server-metadata.test","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://server-metadata.test/oauth/jwks","authorization_endpoint":"https://server-metadata.test/oauth/authorize","token_endpoint":"https://server-metadata.test/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://server-metadata.test/oauth/revoke","pushed_authorization_request_endpoint":"https://server-metadata.test/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} - """ - - let data = try #require(metadataContent.data(using: .utf8)) - - let metadata = try JSONDecoder().decode(ServerMetadata.self, from: data) - let handling = Bluesky.tokenHandling( - account: "placeholder", - server: metadata, - jwtGenerator: { _ in "" }, - pkce: PKCEVerifier(hash: "", hasher: { _ in "" }), - validator: { _, _ in true } - ) - - let provider: URLResponseProvider = { request in - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: "1.1", - headerFields: [ - "Content-Type": "application/json" - ] - )! - - let payload = """ -{"access_token":"1", "sub":"2", "scope":"3", "token_type":"DPoP","expires_in":120} -""" - - return (Data(payload.utf8), response) - } - - let verifier = PKCEVerifier(hash: "", hasher: { _ in "" }) - let params = TokenHandling.LoginProviderParameters( - authorizationURL: URL(string: "https://server-metadata.test/oauth/authorize")!, - credentials: AppCredentials( - clientId: "a", - clientPassword: "b", - scopes: [], - callbackURL: URL(string: "app.test://callback")!, - ), - redirectURL: URL(string: "app.test://callback?code=123&state=state&iss=this_is_incorrect")!, - responseProvider: provider, - stateToken: "state", - pcke: verifier - ) - - await #expect(throws: AuthenticatorError.issuingServerMismatch("this_is_incorrect", "https://server-metadata.test")) { - try await handling.loginProvider(params) - } - } @Test func tokenValidationFailure() async throws { let metadataContent = """ {"issuer":"https://server-metadata.test","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://server-metadata.test/oauth/jwks","authorization_endpoint":"https://server-metadata.test/oauth/authorize","token_endpoint":"https://server-metadata.test/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://server-metadata.test/oauth/revoke","pushed_authorization_request_endpoint":"https://server-metadata.test/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} """ - - let data = try #require(metadataContent.data(using: .utf8)) + let metadata = try JSONDecoder().decode( + ServerMetadata.self, + from: Data(metadataContent.utf8) + ) let verifier = PKCEVerifier(hash: "", hasher: { _ in "" }) - let metadata = try JSONDecoder().decode(ServerMetadata.self, from: data) let handling = Bluesky.tokenHandling( account: "placeholder", server: metadata, @@ -96,12 +44,16 @@ struct BlueskyTests { )! let payload = """ -{"access_token":"1", "sub":"2", "scope":"3", "token_type":"DPoP","expires_in":120} -""" + {"access_token":"1", "sub":"2", "scope":"3", "token_type":"DPoP","expires_in":120} + """ return (Data(payload.utf8), response) } + let redirectURL = URL( + string: "app.test:/callback?code=123&state=state&iss=https://server-metadata.test")! + let redirectParams = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false)! + let params = TokenHandling.LoginProviderParameters( authorizationURL: URL(string: "https://server-metadata.test/oauth/authorize")!, credentials: AppCredentials( @@ -110,7 +62,8 @@ struct BlueskyTests { scopes: [], callbackURL: URL(string: "app.test://callback")!, ), - redirectURL: URL(string: "app.test:/callback?code=123&state=state&iss=https://server-metadata.test")!, + redirectURL: redirectURL, + redirectParams: redirectParams, responseProvider: provider, stateToken: "state", pcke: verifier