diff --git a/Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift b/Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift --- a/Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift +++ b/Sources/CoreATProtocol/OAuth/ATProtoOAuth.swift @@ -23,23 +23,40 @@ public let pdsEndpoint: String } +/// Client authentication method declared in the client metadata's +/// `token_endpoint_auth_method`. Determines whether OAuth flows may fall back +/// to unproxied requests when an auth proxy is temporarily unreachable. +public enum ATProtoClientAuthMethod: Sendable { + /// Public client. Direct requests to the authorization server are valid; + /// the auth proxy (if configured) is an optional signing helper. + case none + /// Confidential client using `private_key_jwt`. The auth server requires a + /// `client_assertion` JWT on every token request, which only the auth proxy + /// can produce — direct fallback would be rejected and risks consuming the + /// single-use refresh token, so it is disabled. + case privateKeyJWT +} + /// Configuration for AT Protocol OAuth public struct ATProtoOAuthConfig: Sendable { public let clientMetadataURL: String public let redirectURI: String public let scopes: [String] public let authProxyBaseURL: String? + public let clientAuthMethod: ATProtoClientAuthMethod public init( clientMetadataURL: String, redirectURI: String, scopes: [String] = ["atproto", "transition:generic"], - authProxyBaseURL: String? = nil + authProxyBaseURL: String? = nil, + clientAuthMethod: ATProtoClientAuthMethod = .none ) { self.clientMetadataURL = clientMetadataURL self.redirectURI = redirectURI self.scopes = scopes self.authProxyBaseURL = authProxyBaseURL + self.clientAuthMethod = clientAuthMethod } } @@ -470,7 +487,12 @@ refreshedLogin = try await refreshProvider(login, appCredentials, proxyResponseProvider) usedAuthProxy = true } catch { - guard shouldRetryWithoutAuthProxy(after: error) else { + // A confidential client cannot authenticate directly — the auth + // server rejects refreshes without a `client_assertion`, and a + // rejected refresh may still consume the single-use token. + // Surface the proxy error so the caller can retry later. + guard config.clientAuthMethod != .privateKeyJWT, + shouldRetryWithoutAuthProxy(after: error) else { throw error } @@ -601,7 +623,9 @@ usedAuthProxy: prefersAuthProxy ) } catch { - if let fallbackAuthenticator, shouldRetryWithoutAuthProxy(after: error) { + if let fallbackAuthenticator, + config.clientAuthMethod != .privateKeyJWT, + shouldRetryWithoutAuthProxy(after: error) { return AuthenticationAttemptResult( login: try await fallbackAuthenticator.authenticate(), usedAuthProxy: false @@ -612,7 +636,9 @@ } } - if let fallbackAuthenticator, shouldRetryWithoutAuthProxy(after: error) { + if let fallbackAuthenticator, + config.clientAuthMethod != .privateKeyJWT, + shouldRetryWithoutAuthProxy(after: error) { return AuthenticationAttemptResult( login: try await fallbackAuthenticator.authenticate(), usedAuthProxy: false