From f0da42917ba1c749301a3dfcaa68d6e33fcd2e2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 23:16:15 +0100 Subject: [PATCH] feat(macos): verify Claude OAuth in onboarding --- .../Sources/Clawdis/AnthropicOAuth.swift | 51 ++++++++++++++++ apps/macos/Sources/Clawdis/Onboarding.swift | 5 ++ .../Clawdis/OnboardingView+Monitoring.swift | 43 +++++++++++++ .../Clawdis/OnboardingView+Pages.swift | 60 +++++++++++++++---- 4 files changed, 146 insertions(+), 13 deletions(-) diff --git a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift index be2222b1e..9d107e1d7 100644 --- a/apps/macos/Sources/Clawdis/AnthropicOAuth.swift +++ b/apps/macos/Sources/Clawdis/AnthropicOAuth.swift @@ -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) ?? "" + 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 } diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 96ebebefc..5da9740f0 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -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 diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Monitoring.swift b/apps/macos/Sources/Clawdis/OnboardingView+Monitoring.swift index 10e87acd5..bb066abef 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Monitoring.swift @@ -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)" + } } } diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift index 7d863ebe7..4925d6afa 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift @@ -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 {