Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions Sources/OAuthenticator/Authenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/OAuthenticator/DPoPSigner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions Sources/OAuthenticator/Services/Bluesky.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Sources/OAuthenticator/WellknownEndpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -100,7 +100,7 @@ extension ClientMetadata {
return AppCredentials(
clientId: clientId,
clientPassword: "",
scopes: scope.components(separatedBy: " "),
scopes: scope?.components(separatedBy: " ") ?? [],
callbackURL: url
)
}
Expand Down