feat(macos): verify Claude OAuth in onboarding

This commit is contained in:
Peter Steinberger
2026-01-01 23:16:15 +01:00
parent 6e87fd2d4c
commit f0da42917b
4 changed files with 146 additions and 13 deletions

View File

@@ -149,6 +149,49 @@ enum AnthropicOAuth {
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
}
static func refresh(refreshToken: String) async throws -> AnthropicOAuthCredentials {
let payload: [String: Any] = [
"grant_type": "refresh_token",
"client_id": self.clientId,
"refresh_token": refreshToken,
]
let body = try JSONSerialization.data(withJSONObject: payload, options: [])
var request = URLRequest(url: self.tokenURL)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
let text = String(data: data, encoding: .utf8) ?? "<non-utf8>"
throw NSError(
domain: "AnthropicOAuth",
code: http.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Token refresh failed: \(text)"])
}
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let access = decoded?["access_token"] as? String
let refresh = (decoded?["refresh_token"] as? String) ?? refreshToken
let expiresIn = decoded?["expires_in"] as? Double
guard let access, let expiresIn else {
throw NSError(domain: "AnthropicOAuth", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected token response.",
])
}
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
self.logger.info("Anthropic OAuth refresh ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
}
}
enum ClawdisOAuthStore {
@@ -279,6 +322,14 @@ enum ClawdisOAuthStore {
return .connected(expiresAtMs: expiresAtMs)
}
static func loadAnthropicOAuthRefreshToken() -> String? {
let url = self.oauthURL()
guard let storage = self.loadStorage(at: url) else { return nil }
guard let rawEntry = storage[self.providerKey] as? [String: Any] else { return nil }
let refresh = self.firstString(in: rawEntry, keys: ["refresh", "refresh_token", "refreshToken"])
return refresh?.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = dict[key] as? String { return value }

View File

@@ -68,6 +68,11 @@ struct OnboardingView: View {
@State var anthropicAuthStatus: String?
@State var anthropicAuthBusy = false
@State var anthropicAuthConnected = false
@State var anthropicAuthVerifying = false
@State var anthropicAuthVerified = false
@State var anthropicAuthVerificationAttempted = false
@State var anthropicAuthVerificationFailed = false
@State var anthropicAuthVerifiedAt: Date?
@State var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
@State var anthropicAuthAutoDetectClipboard = true
@State var anthropicAuthAutoConnectClipboard = true

View File

@@ -128,8 +128,51 @@ extension OnboardingView {
func refreshAnthropicOAuthStatus() {
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
let previous = self.anthropicAuthDetectedStatus
let status = ClawdisOAuthStore.anthropicOAuthStatus()
self.anthropicAuthDetectedStatus = status
self.anthropicAuthConnected = status.isConnected
if previous != status {
self.anthropicAuthVerified = false
self.anthropicAuthVerificationAttempted = false
self.anthropicAuthVerificationFailed = false
self.anthropicAuthVerifiedAt = nil
}
}
@MainActor
func verifyAnthropicOAuthIfNeeded(force: Bool = false) async {
guard self.state.connectionMode == .local else { return }
guard self.anthropicAuthDetectedStatus.isConnected else { return }
if self.anthropicAuthVerified, !force { return }
if self.anthropicAuthVerifying { return }
if self.anthropicAuthVerificationAttempted, !force { return }
self.anthropicAuthVerificationAttempted = true
self.anthropicAuthVerifying = true
self.anthropicAuthVerificationFailed = false
defer { self.anthropicAuthVerifying = false }
guard let refresh = ClawdisOAuthStore.loadAnthropicOAuthRefreshToken(), !refresh.isEmpty else {
self.anthropicAuthStatus = "OAuth verification failed: missing refresh token."
self.anthropicAuthVerificationFailed = true
return
}
do {
let updated = try await AnthropicOAuth.refresh(refreshToken: refresh)
try ClawdisOAuthStore.saveAnthropicOAuth(updated)
self.refreshAnthropicOAuthStatus()
self.anthropicAuthVerified = true
self.anthropicAuthVerifiedAt = Date()
self.anthropicAuthVerificationFailed = false
self.anthropicAuthStatus = "OAuth detected and verified."
} catch {
self.anthropicAuthVerified = false
self.anthropicAuthVerifiedAt = nil
self.anthropicAuthVerificationFailed = true
self.anthropicAuthStatus = "OAuth verification failed: \(error.localizedDescription)"
}
}
}

View File

@@ -297,23 +297,38 @@ extension OnboardingView {
self.onboardingCard(spacing: 12, padding: 16) {
HStack(alignment: .center, spacing: 10) {
Circle()
.fill(self.anthropicAuthConnected ? Color.green : Color.orange)
.fill(self.anthropicAuthVerified ? Color.green : Color.orange)
.frame(width: 10, height: 10)
Text(self.anthropicAuthConnected ? "Claude connected (OAuth)" : "Not connected yet")
Text(
self.anthropicAuthConnected
? (self.anthropicAuthVerified
? "Claude connected (OAuth) — verified"
: "Claude connected (OAuth)")
: "Not connected yet")
.font(.headline)
Spacer()
}
if !self.anthropicAuthConnected {
if self.anthropicAuthConnected, self.anthropicAuthVerifying {
Text("Verifying OAuth…")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else if !self.anthropicAuthConnected {
Text(self.anthropicAuthDetectedStatus.shortDescription)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt {
Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Text(
"This lets Clawdis use Claude immediately. Credentials are stored at " +
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
"`~/.clawdis/credentials/oauth.json` (owner-only).")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -341,20 +356,38 @@ extension OnboardingView {
Divider().padding(.vertical, 2)
HStack(spacing: 12) {
Button {
self.startAnthropicOAuth()
} label: {
if self.anthropicAuthBusy {
ProgressView()
if !self.anthropicAuthVerified {
if self.anthropicAuthConnected {
Button("Verify") {
Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
}
.buttonStyle(.borderedProminent)
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
if self.anthropicAuthVerificationFailed {
Button("Re-auth (OAuth)") {
self.startAnthropicOAuth()
}
.buttonStyle(.bordered)
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
}
} else {
Text("Open Claude sign-in (OAuth)")
Button {
self.startAnthropicOAuth()
} label: {
if self.anthropicAuthBusy {
ProgressView()
} else {
Text("Open Claude sign-in (OAuth)")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.anthropicAuthBusy)
}
}
.buttonStyle(.borderedProminent)
.disabled(self.anthropicAuthBusy)
}
if self.anthropicAuthPKCE != nil {
if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil {
VStack(alignment: .leading, spacing: 8) {
Text("Paste the `code#state` value")
.font(.headline)
@@ -405,6 +438,7 @@ extension OnboardingView {
}
}
}
.task { await self.verifyAnthropicOAuthIfNeeded() }
}
func permissionsPage() -> some View {