feat(macos): verify Claude OAuth in onboarding
This commit is contained in:
@@ -149,6 +149,49 @@ enum AnthropicOAuth {
|
|||||||
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
self.logger.info("Anthropic OAuth exchange ok; expiresAtMs=\(expiresAtMs, privacy: .public)")
|
||||||
return AnthropicOAuthCredentials(type: "oauth", refresh: refresh, access: access, expires: expiresAtMs)
|
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 {
|
enum ClawdisOAuthStore {
|
||||||
@@ -279,6 +322,14 @@ enum ClawdisOAuthStore {
|
|||||||
return .connected(expiresAtMs: expiresAtMs)
|
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? {
|
private static func firstString(in dict: [String: Any], keys: [String]) -> String? {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
if let value = dict[key] as? String { return value }
|
if let value = dict[key] as? String { return value }
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ struct OnboardingView: View {
|
|||||||
@State var anthropicAuthStatus: String?
|
@State var anthropicAuthStatus: String?
|
||||||
@State var anthropicAuthBusy = false
|
@State var anthropicAuthBusy = false
|
||||||
@State var anthropicAuthConnected = 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 anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
|
||||||
@State var anthropicAuthAutoDetectClipboard = true
|
@State var anthropicAuthAutoDetectClipboard = true
|
||||||
@State var anthropicAuthAutoConnectClipboard = true
|
@State var anthropicAuthAutoConnectClipboard = true
|
||||||
|
|||||||
@@ -128,8 +128,51 @@ extension OnboardingView {
|
|||||||
|
|
||||||
func refreshAnthropicOAuthStatus() {
|
func refreshAnthropicOAuthStatus() {
|
||||||
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||||
|
let previous = self.anthropicAuthDetectedStatus
|
||||||
let status = ClawdisOAuthStore.anthropicOAuthStatus()
|
let status = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||||
self.anthropicAuthDetectedStatus = status
|
self.anthropicAuthDetectedStatus = status
|
||||||
self.anthropicAuthConnected = status.isConnected
|
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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,23 +297,38 @@ extension OnboardingView {
|
|||||||
self.onboardingCard(spacing: 12, padding: 16) {
|
self.onboardingCard(spacing: 12, padding: 16) {
|
||||||
HStack(alignment: .center, spacing: 10) {
|
HStack(alignment: .center, spacing: 10) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(self.anthropicAuthConnected ? Color.green : Color.orange)
|
.fill(self.anthropicAuthVerified ? Color.green : Color.orange)
|
||||||
.frame(width: 10, height: 10)
|
.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)
|
.font(.headline)
|
||||||
Spacer()
|
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)
|
Text(self.anthropicAuthDetectedStatus.shortDescription)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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(
|
Text(
|
||||||
"This lets Clawdis use Claude immediately. Credentials are stored at " +
|
"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)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -341,20 +356,38 @@ extension OnboardingView {
|
|||||||
Divider().padding(.vertical, 2)
|
Divider().padding(.vertical, 2)
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Button {
|
if !self.anthropicAuthVerified {
|
||||||
self.startAnthropicOAuth()
|
if self.anthropicAuthConnected {
|
||||||
} label: {
|
Button("Verify") {
|
||||||
if self.anthropicAuthBusy {
|
Task { await self.verifyAnthropicOAuthIfNeeded(force: true) }
|
||||||
ProgressView()
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
||||||
|
|
||||||
|
if self.anthropicAuthVerificationFailed {
|
||||||
|
Button("Re-auth (OAuth)") {
|
||||||
|
self.startAnthropicOAuth()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying)
|
||||||
|
}
|
||||||
} else {
|
} 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) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Paste the `code#state` value")
|
Text("Paste the `code#state` value")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -405,6 +438,7 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task { await self.verifyAnthropicOAuthIfNeeded() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionsPage() -> some View {
|
func permissionsPage() -> some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user