Remove unreachable Anthropic OAuth code
Anthropic doesn't allow this kind of third-party OAuth usage, so it was never wired into any UI - no button or menu item anywhere ever triggered it. Confirmed unreachable (only construction site for AnthropicProvider used the apiKey initializer) before deleting: - AnthropicOAuthService.swift (314-line PKCE flow) removed entirely - AnthropicProvider: dropped the AuthMode enum/oauth case, now just holds a plain apiKey string - ProviderRegistry.hasValidAPIKey(.anthropic) no longer checks OAuth auth state - README: removed the "...or use OAuth" mention
This commit is contained in:
@@ -150,7 +150,7 @@ Release DMGs are signed and notarized by Apple, so Confab opens normally on firs
|
|||||||
### API Keys
|
### API Keys
|
||||||
Add your API keys in Settings (⌘,) → General tab:
|
Add your API keys in Settings (⌘,) → General tab:
|
||||||
- **OpenAI** - Get from [OpenAI Platform](https://platform.openai.com/api-keys)
|
- **OpenAI** - Get from [OpenAI Platform](https://platform.openai.com/api-keys)
|
||||||
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/) or use OAuth
|
- **Anthropic** - Get from [Anthropic Console](https://console.anthropic.com/)
|
||||||
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
- **OpenRouter** - Get from [OpenRouter Keys](https://openrouter.ai/keys)
|
||||||
- **Ollama** - Base URL (default: http://localhost:11434)
|
- **Ollama** - Base URL (default: http://localhost:11434)
|
||||||
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) - not a chat provider
|
- **Google** - API key used for Google Custom Search (web search) and Google embeddings (semantic search) - not a chat provider
|
||||||
|
|||||||
@@ -34,40 +34,20 @@ class AnthropicProvider: AIProvider {
|
|||||||
maxContextLength: nil
|
maxContextLength: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
enum AuthMode {
|
private let apiKey: String
|
||||||
case apiKey(String)
|
|
||||||
case oauth
|
|
||||||
}
|
|
||||||
|
|
||||||
private let authMode: AuthMode
|
|
||||||
private let baseURL = "https://api.anthropic.com/v1"
|
private let baseURL = "https://api.anthropic.com/v1"
|
||||||
private let apiVersion = "2023-06-01"
|
private let apiVersion = "2023-06-01"
|
||||||
private let session: URLSession
|
private let session: URLSession
|
||||||
|
|
||||||
/// Create with a standard API key
|
/// Create with a standard API key
|
||||||
init(apiKey: String) {
|
init(apiKey: String) {
|
||||||
self.authMode = .apiKey(apiKey)
|
self.apiKey = apiKey
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
|
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
|
||||||
config.timeoutIntervalForResource = 600 // 10 minutes total
|
config.timeoutIntervalForResource = 600 // 10 minutes total
|
||||||
self.session = URLSession(configuration: config)
|
self.session = URLSession(configuration: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create with OAuth (Pro/Max subscription)
|
|
||||||
init(oauth: Bool) {
|
|
||||||
self.authMode = .oauth
|
|
||||||
let config = URLSessionConfiguration.default
|
|
||||||
config.timeoutIntervalForRequest = 180 // 3 minutes for initial response (tool use needs thinking time)
|
|
||||||
config.timeoutIntervalForResource = 600 // 10 minutes total
|
|
||||||
self.session = URLSession(configuration: config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this provider is using OAuth authentication
|
|
||||||
var isOAuth: Bool {
|
|
||||||
if case .oauth = authMode { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Models
|
// MARK: - Models
|
||||||
|
|
||||||
/// Local metadata used to enrich API results (pricing, context length) and as offline fallback.
|
/// Local metadata used to enrich API results (pricing, context length) and as offline fallback.
|
||||||
@@ -555,29 +535,14 @@ class AnthropicProvider: AIProvider {
|
|||||||
|
|
||||||
// MARK: - Auth Helpers
|
// MARK: - Auth Helpers
|
||||||
|
|
||||||
/// Apply auth headers based on mode (API key or OAuth Bearer)
|
/// Apply auth headers
|
||||||
private func applyAuth(to request: inout URLRequest) async throws {
|
private func applyAuth(to request: inout URLRequest) async throws {
|
||||||
switch authMode {
|
request.addValue(apiKey, forHTTPHeaderField: "x-api-key")
|
||||||
case .apiKey(let key):
|
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
|
||||||
request.addValue(key, forHTTPHeaderField: "x-api-key")
|
|
||||||
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
|
|
||||||
|
|
||||||
case .oauth:
|
|
||||||
let token = try await AnthropicOAuthService.shared.getValidAccessToken()
|
|
||||||
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
||||||
request.addValue(apiVersion, forHTTPHeaderField: "anthropic-version")
|
|
||||||
request.addValue("oauth-2025-04-20,interleaved-thinking-2025-05-14", forHTTPHeaderField: "anthropic-beta")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the messages endpoint URL, appending ?beta=true for OAuth
|
|
||||||
private var messagesURL: URL {
|
private var messagesURL: URL {
|
||||||
switch authMode {
|
URL(string: "\(baseURL)/messages")!
|
||||||
case .apiKey:
|
|
||||||
return URL(string: "\(baseURL)/messages")!
|
|
||||||
case .oauth:
|
|
||||||
return URL(string: "\(baseURL)/messages?beta=true")!
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Request Building
|
// MARK: - Request Building
|
||||||
|
|||||||
@@ -101,8 +101,7 @@ class ProviderRegistry {
|
|||||||
case .openrouter:
|
case .openrouter:
|
||||||
return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty
|
return settings.openrouterAPIKey != nil && !settings.openrouterAPIKey!.isEmpty
|
||||||
case .anthropic:
|
case .anthropic:
|
||||||
return AnthropicOAuthService.shared.isAuthenticated
|
return settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty
|
||||||
|| (settings.anthropicAPIKey != nil && !settings.anthropicAPIKey!.isEmpty)
|
|
||||||
case .openai:
|
case .openai:
|
||||||
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
return settings.openaiAPIKey != nil && !settings.openaiAPIKey!.isEmpty
|
||||||
case .ollama:
|
case .ollama:
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
//
|
|
||||||
// AnthropicOAuthService.swift
|
|
||||||
// Confab
|
|
||||||
//
|
|
||||||
// OAuth 2.0 PKCE flow for Anthropic Pro/Max subscription login
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
||||||
// Copyright (C) 2026 Rune Olsen
|
|
||||||
//
|
|
||||||
// This file is part of Confab.
|
|
||||||
//
|
|
||||||
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
||||||
// You may use, study, modify, and share it for any noncommercial
|
|
||||||
// purpose. Commercial use — including selling Confab or any part of
|
|
||||||
// it, standalone or bundled into another product or service —
|
|
||||||
// requires a separate commercial license from the copyright holder.
|
|
||||||
//
|
|
||||||
// See the LICENSE file or
|
|
||||||
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
||||||
// the full license text. For commercial licensing, contact Rune
|
|
||||||
// Olsen via <https://confab.no>.
|
|
||||||
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
import Security
|
|
||||||
|
|
||||||
@Observable
|
|
||||||
class AnthropicOAuthService {
|
|
||||||
static let shared = AnthropicOAuthService()
|
|
||||||
|
|
||||||
// OAuth configuration (matches Claude Code CLI)
|
|
||||||
private let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
||||||
private let redirectURI = "https://console.anthropic.com/oauth/code/callback"
|
|
||||||
private let scope = "org:create_api_key user:profile user:inference"
|
|
||||||
private let tokenEndpoint = "https://console.anthropic.com/v1/oauth/token"
|
|
||||||
|
|
||||||
// Keychain keys
|
|
||||||
private enum Keys {
|
|
||||||
static let accessToken = "com.oai.anthropic.oauth.accessToken"
|
|
||||||
static let refreshToken = "com.oai.anthropic.oauth.refreshToken"
|
|
||||||
static let expiresAt = "com.oai.anthropic.oauth.expiresAt"
|
|
||||||
}
|
|
||||||
|
|
||||||
// PKCE state for current flow
|
|
||||||
private var currentVerifier: String?
|
|
||||||
|
|
||||||
// Observable state
|
|
||||||
var isAuthenticated: Bool { accessToken != nil }
|
|
||||||
var isLoggingIn = false
|
|
||||||
|
|
||||||
// MARK: - Token Access
|
|
||||||
|
|
||||||
var accessToken: String? {
|
|
||||||
getKeychainValue(for: Keys.accessToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var refreshToken: String? {
|
|
||||||
getKeychainValue(for: Keys.refreshToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var expiresAt: Date? {
|
|
||||||
guard let str = getKeychainValue(for: Keys.expiresAt),
|
|
||||||
let interval = Double(str) else { return nil }
|
|
||||||
return Date(timeIntervalSince1970: interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTokenExpired: Bool {
|
|
||||||
guard let expires = expiresAt else { return true }
|
|
||||||
return Date() >= expires
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Step 1: Generate Authorization URL
|
|
||||||
|
|
||||||
func generateAuthorizationURL() -> URL {
|
|
||||||
let verifier = generateCodeVerifier()
|
|
||||||
currentVerifier = verifier
|
|
||||||
let challenge = generateCodeChallenge(from: verifier)
|
|
||||||
|
|
||||||
var components = URLComponents(string: "https://claude.ai/oauth/authorize")!
|
|
||||||
components.queryItems = [
|
|
||||||
URLQueryItem(name: "code", value: "true"),
|
|
||||||
URLQueryItem(name: "client_id", value: clientId),
|
|
||||||
URLQueryItem(name: "response_type", value: "code"),
|
|
||||||
URLQueryItem(name: "redirect_uri", value: redirectURI),
|
|
||||||
URLQueryItem(name: "scope", value: scope),
|
|
||||||
URLQueryItem(name: "code_challenge", value: challenge),
|
|
||||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
|
||||||
URLQueryItem(name: "state", value: verifier),
|
|
||||||
]
|
|
||||||
|
|
||||||
return components.url!
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Step 2: Exchange Code for Tokens
|
|
||||||
|
|
||||||
func exchangeCode(_ pastedCode: String) async throws {
|
|
||||||
guard let verifier = currentVerifier else {
|
|
||||||
throw OAuthError.noVerifier
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code format: "auth_code#state"
|
|
||||||
let parts = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "#")
|
|
||||||
let authCode: String
|
|
||||||
let state: String
|
|
||||||
|
|
||||||
if parts.count >= 2 {
|
|
||||||
authCode = parts[0]
|
|
||||||
state = parts.dropFirst().joined(separator: "#")
|
|
||||||
} else {
|
|
||||||
// If no # separator, treat entire string as the code
|
|
||||||
authCode = pastedCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
state = verifier
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.api.info("Exchanging OAuth code for tokens")
|
|
||||||
|
|
||||||
let body: [String: String] = [
|
|
||||||
"code": authCode,
|
|
||||||
"state": state,
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"client_id": clientId,
|
|
||||||
"redirect_uri": redirectURI,
|
|
||||||
"code_verifier": verifier,
|
|
||||||
]
|
|
||||||
|
|
||||||
let tokenResponse = try await postTokenRequest(body)
|
|
||||||
saveTokens(tokenResponse)
|
|
||||||
currentVerifier = nil
|
|
||||||
|
|
||||||
Log.api.info("OAuth login successful, token expires in \(tokenResponse.expiresIn)s")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Token Refresh
|
|
||||||
|
|
||||||
func refreshAccessToken() async throws {
|
|
||||||
guard let refresh = refreshToken else {
|
|
||||||
throw OAuthError.noRefreshToken
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.api.info("Refreshing OAuth access token")
|
|
||||||
|
|
||||||
let body: [String: String] = [
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": refresh,
|
|
||||||
"client_id": clientId,
|
|
||||||
]
|
|
||||||
|
|
||||||
let tokenResponse = try await postTokenRequest(body)
|
|
||||||
saveTokens(tokenResponse)
|
|
||||||
|
|
||||||
Log.api.info("OAuth token refreshed successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a valid access token, refreshing if needed
|
|
||||||
func getValidAccessToken() async throws -> String {
|
|
||||||
guard let token = accessToken else {
|
|
||||||
throw OAuthError.notAuthenticated
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTokenExpired {
|
|
||||||
try await refreshAccessToken()
|
|
||||||
guard let newToken = accessToken else {
|
|
||||||
throw OAuthError.notAuthenticated
|
|
||||||
}
|
|
||||||
return newToken
|
|
||||||
}
|
|
||||||
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Logout
|
|
||||||
|
|
||||||
func logout() {
|
|
||||||
deleteKeychainValue(for: Keys.accessToken)
|
|
||||||
deleteKeychainValue(for: Keys.refreshToken)
|
|
||||||
deleteKeychainValue(for: Keys.expiresAt)
|
|
||||||
currentVerifier = nil
|
|
||||||
Log.api.info("OAuth logout complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - PKCE Helpers
|
|
||||||
|
|
||||||
private func generateCodeVerifier() -> String {
|
|
||||||
var bytes = [UInt8](repeating: 0, count: 32)
|
|
||||||
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
||||||
return Data(bytes).base64URLEncoded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func generateCodeChallenge(from verifier: String) -> String {
|
|
||||||
let data = Data(verifier.utf8)
|
|
||||||
let hash = SHA256.hash(data: data)
|
|
||||||
return Data(hash).base64URLEncoded()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Token Request
|
|
||||||
|
|
||||||
private func postTokenRequest(_ body: [String: String]) async throws -> TokenResponse {
|
|
||||||
var request = URLRequest(url: URL(string: tokenEndpoint)!)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.httpBody = try JSONEncoder().encode(body)
|
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
|
||||||
throw OAuthError.invalidResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
guard httpResponse.statusCode == 200 else {
|
|
||||||
let errorBody = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
||||||
Log.api.error("OAuth token exchange failed HTTP \(httpResponse.statusCode): \(errorBody)")
|
|
||||||
throw OAuthError.tokenExchangeFailed(httpResponse.statusCode, errorBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
return try JSONDecoder().decode(TokenResponse.self, from: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Token Storage
|
|
||||||
|
|
||||||
private func saveTokens(_ response: TokenResponse) {
|
|
||||||
setKeychainValue(response.accessToken, for: Keys.accessToken)
|
|
||||||
if let refresh = response.refreshToken {
|
|
||||||
setKeychainValue(refresh, for: Keys.refreshToken)
|
|
||||||
}
|
|
||||||
let expiresAt = Date().addingTimeInterval(TimeInterval(response.expiresIn))
|
|
||||||
setKeychainValue(String(expiresAt.timeIntervalSince1970), for: Keys.expiresAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Keychain Helpers
|
|
||||||
|
|
||||||
private func getKeychainValue(for key: String) -> String? {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
kSecReturnData as String: true,
|
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
||||||
]
|
|
||||||
var ref: AnyObject?
|
|
||||||
guard SecItemCopyMatching(query as CFDictionary, &ref) == errSecSuccess,
|
|
||||||
let data = ref as? Data,
|
|
||||||
let value = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setKeychainValue(_ value: String, for key: String) {
|
|
||||||
guard let data = value.data(using: .utf8) else { return }
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
]
|
|
||||||
let attrs: [String: Any] = [kSecValueData as String: data]
|
|
||||||
let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary)
|
|
||||||
if status == errSecItemNotFound {
|
|
||||||
var newItem = query
|
|
||||||
newItem[kSecValueData as String] = data
|
|
||||||
SecItemAdd(newItem as CFDictionary, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteKeychainValue(for key: String) {
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrAccount as String: key,
|
|
||||||
]
|
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Types
|
|
||||||
|
|
||||||
struct TokenResponse: Decodable {
|
|
||||||
let accessToken: String
|
|
||||||
let refreshToken: String?
|
|
||||||
let expiresIn: Int
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case accessToken = "access_token"
|
|
||||||
case refreshToken = "refresh_token"
|
|
||||||
case expiresIn = "expires_in"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OAuthError: LocalizedError {
|
|
||||||
case noVerifier
|
|
||||||
case noRefreshToken
|
|
||||||
case notAuthenticated
|
|
||||||
case invalidResponse
|
|
||||||
case tokenExchangeFailed(Int, String)
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .noVerifier: return "No PKCE verifier — start the login flow first."
|
|
||||||
case .noRefreshToken: return "No refresh token available. Please log in again."
|
|
||||||
case .notAuthenticated: return "Not authenticated. Please log in."
|
|
||||||
case .invalidResponse: return "Invalid response from Anthropic OAuth server."
|
|
||||||
case .tokenExchangeFailed(let code, let body):
|
|
||||||
return "Token exchange failed (HTTP \(code)): \(body)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Base64URL Encoding
|
|
||||||
|
|
||||||
private extension Data {
|
|
||||||
func base64URLEncoded() -> String {
|
|
||||||
base64EncodedString()
|
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
.replacingOccurrences(of: "=", with: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user