diff --git a/CHANGELOG.md b/CHANGELOG.md index 9277c40..3227e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,75 @@ ## Unreleased +## 2026.3.1 - 2026-09-29 + +OpenClawKit 2026.3.1 adds [Sign in with ChatGPT](https://developers.openai.com/siwc) +(SIWC): people sign in with their ChatGPT account and run eligible inference on +their ChatGPT plan instead of an API key. The release is additive; there are no +breaking changes, and the upstream parity target stays OpenClaw `2026.9.6` +(upstream has no SIWC client, so this is an SDK-native feature). + +### Added + +- `SignInWithChatGPTSession` (`OpenClawCore`, Apple platforms and Linux): the + open-source "ChatGPT plan usage" flow. The first sign-in of an account + registers with `client_id=dynamic_agent_client` and `agent_name_hint`, later + sign-ins reuse the issued `oaiapp_…` client id with `id_token_hint` and + `login_hint`, and `consent: .forceReconsent` / `.consent` re-enable plan usage. + Every authorization sends PKCE `S256`, `state`, `nonce`, + `resource=https://api.openai.com/v1` and a persisted `ext_agent_host_id`. +- `SignInWithChatGPTLoopbackListener`: a one-shot `127.0.0.1` callback server + on `/auth/callback` (port 1455, or an ephemeral port when busy) that ignores + callbacks with the wrong `state`; not built for tvOS and watchOS. +- RS256 ID-token validation (`SignInWithChatGPTIDTokenValidator`, JWKS cache + with key-rotation refresh): signature, `iss`, `aud`, `azp`, `exp`, `iat`, + `nbf`, `nonce` and `sub`; keys under 2048 bits are rejected. Verification + uses Security.framework on Apple platforms and swift-crypto's `_CryptoExtras` + on Linux (a new Linux-only dependency of `OpenClawCore`). +- Token lifecycle: code exchange, refresh five minutes before expiry (never + before `earliest_refresh_at`), one refresh per account at a time with rotated + refresh tokens replaced together with the access token, and a forced refresh + when inference returns `401`. Rejected refresh tokens clear the tokens and + throw `reauthenticationRequired` while keeping the account's client id. +- Accounts: `SignInWithChatGPTAccountStore` keeps multiple accounts (separate + registrations, never mixed), the active account, the one-time plan-welcome + flag and the documented credential record (`client_id`, `access_token`, + `refresh_token`, `id_token`, `expires_in`, ISO 8601 `saved_at`, + `ext_agent_host_id`) in any `CredentialStore`. Sign-out revokes the refresh + token with retries and keeps the client id and host id; credential records can + be exported and imported (for example to a self-hosted VM). +- `SignInWithChatGPTHostIdentifier`: `urn:uuid:`, RFC 9278 JWK-thumbprint and + `did:key:` host ids, including `init(deviceIdentity:)` from the gateway device + key. +- `ChatGPTPlanModelProvider` (`OpenClawModels`): Responses inference on the + plan with `store: false`, `stream: true`, `instructions`, developer-role + system messages, namespaced function tools, `tool_choice` mapping, and no + unsupported fields (`temperature`, `top_p`, `max_output_tokens`, …); + `listModels()` returns the `visibility == "list"` models in server order. +- `ChatGPTPlanError`: typed plan errors for the `subscription_sharing_*` and + `chatpass_v2_*` codes and direct-admission responses, with a `recovery` + (`manageUsage`, `signInAgain`, `retryLater`, `changeRequest`) and + `Retry-After`. +- Apple presenters: `SignInWithChatGPTWebAuthenticationBrowser` + (`ASWebAuthenticationSession` on iOS, macOS and visionOS) and + `SignInWithChatGPTExternalBrowser.systemDefault` on macOS. +- `OpenClawChatUI`: `SignInWithChatGPTButton` ("Continue with ChatGPT" / + "Sign in with ChatGPT", black or white, host-supplied logo), + `ChatGPTPlanWelcomeView`, `ChatGPTPlanUsageIndicator`, + `ChatGPTPlanUsageLimitView`, the `chatGPTPlanWelcomeSheet` and + `chatGPTPlanUsageLimitSheet` modifiers, and the `SignInWithChatGPTModel` + observable. +- DocC article "Sign in with ChatGPT". + +### Changed + +- `FileCredentialStore` now writes through `OpenClawFileSystem.writePrivateData`: + the file is created with `0600` before any secret is written and atomically + renamed into place (previously it was written, then `chmod`ed), and a missing + parent directory is created with `0700`. +- The Responses stream parser records the error code of `response.failed` and + `error` events. + ## 2026.3.0 - 2026-09-29 OpenClawKit 2026.3.0 brings the SDK to feasible parity with upstream OpenClaw diff --git a/Package.swift b/Package.swift index 0897be3..de0511a 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,8 @@ var targets: [Target] = [ dependencies: [ "OpenClawProtocol", .product(name: "Crypto", package: "swift-crypto"), + // RS256 verification of Sign in with ChatGPT ID tokens on Linux (Apple platforms use Security). + .product(name: "_CryptoExtras", package: "swift-crypto", condition: .when(platforms: [.linux])), ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), diff --git a/README.md b/README.md index ccea934..2e0462c 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,14 @@ The repository currently ships: - layered SwiftPM products for protocol, core runtime, gateway, agents, plugins, channels, memory, media, models, skills and MCP - an Apple-facing `OpenClawKit` facade for app and gateway-node integrations, plus Apple-only products for native state, App Intents, SwiftUI chat and an offline chat store - an in-process gateway with a public method-registration API, and a gateway client that speaks OpenClaw protocol v4 +- Sign in with ChatGPT, so people can run agents on their ChatGPT plan instead of an API key - provider routing across OpenAI (Platform and ChatGPT/Codex OAuth), OpenAI-compatible, Anthropic, Google Gemini/Vertex, xAI, Bedrock, Ollama, local runtimes and Apple Foundation Models (on-device and Private Cloud Compute) - channel adapters with upstream access policy and DM pairing, secret-aware lossless config, session transcripts, diagnostics, replay and security audit tooling - a published Swift-DocC site plus CI, SwiftLint, and release automation Current baseline: -- latest release: `2026.3.0` +- latest release: `2026.3.1` - upstream parity target: OpenClaw `v2026.9.6` at `.codex/openclaw` commit `eb377ac59e` - gateway protocol: v4 (operator clients negotiate 4; node sessions accept 3...4) - toolchain: Xcode 27.1 / Swift 6.4 for Apple platforms; the cross-platform modules stay compatible with Swift 6.2 on Linux (`swift-tools-version` 6.2) @@ -44,7 +45,7 @@ Add the package with Swift Package Manager: ```swift dependencies: [ - .package(url: "https://github.com/MarcoDotIO/OpenClawKit.git", from: "2026.3.0") + .package(url: "https://github.com/MarcoDotIO/OpenClawKit.git", from: "2026.3.1") ] ``` @@ -73,7 +74,7 @@ The experimental App Intents model-delegation surface (built on the underscored ```swift .package( url: "https://github.com/MarcoDotIO/OpenClawKit.git", - from: "2026.3.0", + from: "2026.3.1", traits: [.defaults, "ExperimentalAppleModelDelegation"] ) ``` @@ -141,6 +142,24 @@ For a persistent embedded agent (session and transcript stores, the tool-calling - Swift tools: `6.2`. Build with Xcode 27.1 (Swift 6.4) on Apple platforms; Xcode's own toolchain is required to use the 27 SDKs. - Apple 27 APIs (FoundationModels 27, Private Cloud Compute, StateReporting, NowPlaying, App Intents 27, TrustInsights, LinkSecurity, BackgroundTasks async submission, MediaIntelligence, MusicUnderstanding, CoreAI, ScreenCaptureKit on iOS) sit behind `#if compiler(>=6.4)` and per-OS `@available`, so apps with the floors above launch on older systems. `Scripts/check-apple-weak-links.sh` enforces this. +## Highlights in 2026.3.1 + +- [Sign in with ChatGPT](https://developers.openai.com/siwc): `SignInWithChatGPTSession` runs OpenAI's open-source "ChatGPT plan usage" flow (loopback PKCE sign-in with `dynamic_agent_client` registration, RS256 ID-token validation, multi-account storage, single-flight token refresh, revocation on sign-out) on Apple platforms and Linux. +- `ChatGPTPlanModelProvider` runs inference on the user's ChatGPT plan through the Responses API (`store: false`, streaming, namespaced function tools) and throws typed `ChatGPTPlanError`s such as "usage limit reached". +- `OpenClawChatUI` adds the "Continue with ChatGPT" button, the one-time plan welcome, the "Using ChatGPT plan" indicator and the usage-limit prompt, following OpenAI's UI guidelines. + +```swift +let session = SignInWithChatGPTSession( + configuration: SignInWithChatGPTClientConfiguration(agentName: "MyAgent"), + credentialStore: KeychainCredentialStore() +) +let result = try await session.signIn(using: SignInWithChatGPTWebAuthenticationBrowser()) +let models = try await ChatGPTPlanModelProvider(tokenProvider: session).listModels() +let provider = ChatGPTPlanModelProvider(tokenProvider: session, defaultModelID: models.first?.slug) +``` + +See the DocC article "Sign in with ChatGPT" for accounts, errors and UI. + ## Highlights in 2026.3.0 - OpenClaw `v2026.9.6` parity: protocol v4 models and method catalog, the upstream gateway client (socket generations, challenge-signed device proof, scoped device tokens, defensive hello-ok), native SQLite state with one-time identity import, and the in-process gateway's registration API, events and startup gating. diff --git a/Sources/OpenClawChatUI/SignInWithChatGPTViews.swift b/Sources/OpenClawChatUI/SignInWithChatGPTViews.swift new file mode 100644 index 0000000..d3ec6ec --- /dev/null +++ b/Sources/OpenClawChatUI/SignInWithChatGPTViews.swift @@ -0,0 +1,456 @@ +// Sign in with ChatGPT UI, following OpenAI's UI/UX guidelines: the "Continue with ChatGPT" / +// "Sign in with ChatGPT" button on black or white, the one-time plan welcome, the "Using ChatGPT plan" +// indicator and the "Usage limit reached" prompt. Copy strings are OpenAI's; like the rest of ChatUI they +// are English source literals that hosts can localize in their own string catalog. +#if os(iOS) || os(macOS) || os(visionOS) +import SwiftUI + +/// The Sign in with ChatGPT button. +/// +/// OpenAI's guidelines pair the label with the ChatGPT logo. The SDK does not ship OpenAI's brand +/// assets: download the logo from OpenAI and pass it as `logo` (it is rendered as a template in the +/// label color, white on ``Style/black`` and black on ``Style/white``). +public struct SignInWithChatGPTButton: View { + /// Button label. + public enum Label: Sendable, Equatable { + /// "Continue with ChatGPT" (recommended for starting sign-in). + case continueWithChatGPT + /// "Sign in with ChatGPT". + case signInWithChatGPT + + /// Label text. + public var title: String { + switch self { + case .continueWithChatGPT: + return String(localized: "Continue with ChatGPT") + case .signInWithChatGPT: + return String(localized: "Sign in with ChatGPT") + } + } + } + + /// Button colors. + public enum Style: Sendable, Equatable { + /// Black background, white label. + case black + /// White background, black label, hairline border. + case white + } + + private let label: Label + private let style: Style + private let logo: Image? + private let isLoading: Bool + private let action: () -> Void + + /// Creates the button. + /// - Parameters: + /// - label: Label text. + /// - style: Colors. + /// - logo: ChatGPT logo from OpenAI's brand assets. + /// - isLoading: Shows a progress indicator and disables the button. + /// - action: Starts the sign-in. + public init( + _ label: Label = .continueWithChatGPT, + style: Style = .black, + logo: Image? = nil, + isLoading: Bool = false, + action: @escaping () -> Void + ) { + self.label = label + self.style = style + self.logo = logo + self.isLoading = isLoading + self.action = action + } + + public var body: some View { + Button(action: self.action) { + HStack(spacing: 8) { + if self.isLoading { + ProgressView() + .controlSize(.small) + .tint(self.foreground) + } else if let logo { + logo + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + .accessibilityHidden(true) + } + Text(self.label.title) + .font(.body.weight(.semibold)) + .lineLimit(1) + } + .foregroundStyle(self.foreground) + .frame(maxWidth: .infinity, minHeight: 44) + .padding(.horizontal, 16) + .background(self.background, in: .rect(cornerRadius: 10)) + .overlay { + if self.style == .white { + RoundedRectangle(cornerRadius: 10).strokeBorder(Color.black.opacity(0.15), lineWidth: 1) + } + } + .contentShape(.rect(cornerRadius: 10)) + } + .buttonStyle(.plain) + .disabled(self.isLoading) + .accessibilityLabel(Text(self.label.title)) + } + + private var foreground: Color { + self.style == .black ? .white : .black + } + + private var background: Color { + self.style == .black ? .black : .white + } +} + +/// Links used by the ChatGPT plan views. +public enum ChatGPTPlanLinks { + /// ChatGPT usage settings ("Manage usage"). + public static let manageUsage = SignInWithChatGPTConfiguration.manageUsageURL + /// OpenAI Help Center ("Learn more"). + public static let learnMore = SignInWithChatGPTConfiguration.learnMoreURL +} + +/// One-time welcome shown after the first sign-in that granted plan usage. +/// +/// Show it once per account (``SignInWithChatGPTSignInResult/shouldShowPlanWelcome``), then call +/// ``SignInWithChatGPTSession/markPlanWelcomeSeen(subject:)``; never show it again. +public struct ChatGPTPlanWelcomeView: View { + private let learnMoreURL: URL + private let onDismiss: () -> Void + + /// Creates the welcome. + /// - Parameters: + /// - learnMoreURL: "Learn more" destination. + /// - onDismiss: Called when the user taps "Got it". + public init(learnMoreURL: URL = ChatGPTPlanLinks.learnMore, onDismiss: @escaping () -> Void) { + self.learnMoreURL = learnMoreURL + self.onDismiss = onDismiss + } + + public var body: some View { + VStack(spacing: 16) { + Text("You're using your ChatGPT plan") + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + Text("Eligible usage in this app uses your ChatGPT plan. Manage usage in your ChatGPT settings.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Link("Learn more", destination: self.learnMoreURL) + .font(.subheadline) + Button(action: self.onDismiss) { + Text("Got it") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity, minHeight: 36) + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + .padding(24) + .frame(maxWidth: 420) + } +} + +/// "Using ChatGPT plan · Manage usage", shown near the composer or model picker while requests use the plan. +public struct ChatGPTPlanUsageIndicator: View { + private let manageUsageURL: URL + + /// Creates the indicator. + /// - Parameter manageUsageURL: "Manage usage" destination. + public init(manageUsageURL: URL = ChatGPTPlanLinks.manageUsage) { + self.manageUsageURL = manageUsageURL + } + + public var body: some View { + HStack(spacing: 6) { + Text("Using ChatGPT plan") + .foregroundStyle(.secondary) + Text(verbatim: "·") + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + Link("Manage usage", destination: self.manageUsageURL) + } + .font(.caption) + .lineLimit(1) + .accessibilityElement(children: .combine) + } +} + +/// "Usage limit reached" prompt with "Manage usage" and an optional "Buy app credits" action. +public struct ChatGPTPlanUsageLimitView: View { + /// Layout. + public enum Layout: Sendable, Equatable { + /// Full modal content. + case modal + /// Compact inline banner (for example above the composer). + case compact + } + + private let layout: Layout + private let manageUsageURL: URL + private let onBuyAppCredits: (() -> Void)? + private let onDismiss: (() -> Void)? + + /// Creates the prompt. + /// - Parameters: + /// - layout: Modal or compact layout. + /// - manageUsageURL: "Manage usage" destination. + /// - onBuyAppCredits: Shows "Buy app credits" when set (for apps that sell their own credits). + /// - onDismiss: Adds a close control when set. + public init( + layout: Layout = .modal, + manageUsageURL: URL = ChatGPTPlanLinks.manageUsage, + onBuyAppCredits: (() -> Void)? = nil, + onDismiss: (() -> Void)? = nil + ) { + self.layout = layout + self.manageUsageURL = manageUsageURL + self.onBuyAppCredits = onBuyAppCredits + self.onDismiss = onDismiss + } + + public var body: some View { + switch self.layout { + case .modal: + self.modal + case .compact: + self.compact + } + } + + private var modal: some View { + VStack(spacing: 16) { + Text("Usage limit reached") + .font(.title3.weight(.semibold)) + Text("Review your plan or this app's limit in ChatGPT settings.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Link(destination: self.manageUsageURL) { + Text("Manage usage") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity, minHeight: 36) + } + .buttonStyle(.borderedProminent) + if let onBuyAppCredits { + Button(action: onBuyAppCredits) { + Text("Buy app credits") + .frame(maxWidth: .infinity, minHeight: 36) + } + .buttonStyle(.bordered) + } + if let onDismiss { + Button("Close", role: .cancel, action: onDismiss) + .buttonStyle(.borderless) + .keyboardShortcut(.cancelAction) + } + } + .padding(24) + .frame(maxWidth: 420) + } + + private var compact: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("Usage limit reached") + .font(.subheadline.weight(.semibold)) + Text("Review your plan or this app's limit in ChatGPT settings.") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Link("Manage usage", destination: self.manageUsageURL) + .font(.subheadline.weight(.semibold)) + if let onBuyAppCredits { + Button("Buy app credits", action: onBuyAppCredits) + .font(.subheadline) + } + if let onDismiss { + Button(action: onDismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .accessibilityLabel(Text("Close")) + } + } + .padding(12) + .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 12)) + } +} + +public extension View { + /// Presents ``ChatGPTPlanWelcomeView`` as a sheet. + /// - Parameters: + /// - isPresented: Presentation binding. + /// - onDismiss: Called after "Got it" (record it with ``SignInWithChatGPTSession/markPlanWelcomeSeen(subject:)``). + /// - Returns: The modified view. + func chatGPTPlanWelcomeSheet(isPresented: Binding, onDismiss: @escaping () -> Void = {}) -> some View { + self.sheet(isPresented: isPresented) { + ChatGPTPlanWelcomeView { + isPresented.wrappedValue = false + onDismiss() + } + .presentationDetents([.medium]) + } + } + + /// Presents ``ChatGPTPlanUsageLimitView`` while `error` holds a usage-limit ``ChatGPTPlanError``. + /// - Parameters: + /// - error: Latest plan error; set it from a failed request and it clears on dismiss. + /// - onBuyAppCredits: Shows "Buy app credits" when set. + /// - Returns: The modified view. + func chatGPTPlanUsageLimitSheet(error: Binding, onBuyAppCredits: (() -> Void)? = nil) -> some View { + let isPresented = Binding( + get: { error.wrappedValue?.isUsageLimit == true }, + set: { if !$0 { error.wrappedValue = nil } } + ) + return self.sheet(isPresented: isPresented) { + ChatGPTPlanUsageLimitView( + layout: .modal, + onBuyAppCredits: onBuyAppCredits.map { buy in + { + error.wrappedValue = nil + buy() + } + }, + onDismiss: { error.wrappedValue = nil } + ) + .presentationDetents([.medium]) + } + } +} + +/// Observable state for a Sign in with ChatGPT settings screen or onboarding step. +/// +/// ```swift +/// @State private var chatGPT = SignInWithChatGPTModel(session: session) { +/// SignInWithChatGPTWebAuthenticationBrowser(presentationAnchor: { window }) +/// } +/// +/// SignInWithChatGPTButton(logo: Image("ChatGPTLogo"), isLoading: chatGPT.isSigningIn) { +/// Task { await chatGPT.signIn() } +/// } +/// .chatGPTPlanWelcomeSheet(isPresented: $chatGPT.showsPlanWelcome) { Task { await chatGPT.acknowledgePlanWelcome() } } +/// .chatGPTPlanUsageLimitSheet(error: $chatGPT.planError) +/// ``` +@MainActor +@Observable +public final class SignInWithChatGPTModel { + /// Session that owns accounts and tokens. + public let session: SignInWithChatGPTSession + /// Known accounts, most recent first. + public private(set) var accounts: [SignInWithChatGPTAccount] = [] + /// Active account. + public private(set) var activeAccount: SignInWithChatGPTAccount? + /// Whether a sign-in is running. + public private(set) var isSigningIn = false + /// Latest user-facing error message. + public var errorMessage: String? + /// Whether the one-time plan welcome should be presented. + public var showsPlanWelcome = false + /// Latest plan error (drives `chatGPTPlanUsageLimitSheet(error:onBuyAppCredits:)`). + public var planError: ChatGPTPlanError? + + @ObservationIgnored private let makeBrowser: @MainActor () -> any SignInWithChatGPTBrowser + @ObservationIgnored private var welcomeSubject: String? + + /// Creates the model. + /// - Parameters: + /// - session: Sign in with ChatGPT session. + /// - browser: Creates the browser presenter for each sign-in. + public init(session: SignInWithChatGPTSession, browser: @escaping @MainActor () -> any SignInWithChatGPTBrowser) { + self.session = session + self.makeBrowser = browser + } + + /// Whether the active account is signed in with plan usage. + public var isUsingChatGPTPlan: Bool { + self.activeAccount.map { $0.isSignedIn && $0.usesChatGPTPlan } ?? false + } + + /// Reloads accounts from the session. + public func reload() async { + do { + self.accounts = try await self.session.accounts() + self.activeAccount = try await self.session.activeAccount() + } catch { + self.errorMessage = error.localizedDescription + } + } + + /// Signs in (or re-authenticates a known account) and presents the plan welcome when due. + /// - Parameters: + /// - subject: Known account to re-authenticate. + /// - consent: Re-consent parameter. + public func signIn(reauthenticating subject: String? = nil, consent: SignInWithChatGPTConsentPrompt = .automatic) async { + guard !self.isSigningIn else { return } + self.isSigningIn = true + self.errorMessage = nil + defer { self.isSigningIn = false } + do { + let result = try await self.session.signIn(using: self.makeBrowser(), reauthenticating: subject, consent: consent) + if result.shouldShowPlanWelcome { + self.welcomeSubject = result.account.subject + self.showsPlanWelcome = true + } + await self.reload() + } catch SignInWithChatGPTError.cancelled, SignInWithChatGPTError.accessDenied { + await self.reload() + } catch { + self.errorMessage = error.localizedDescription + await self.reload() + } + } + + /// Records that the plan welcome was shown. + public func acknowledgePlanWelcome() async { + self.showsPlanWelcome = false + guard let subject = self.welcomeSubject else { return } + self.welcomeSubject = nil + try? await self.session.markPlanWelcomeSeen(subject: subject) + await self.reload() + } + + /// Makes an account active. + /// - Parameter subject: Account subject. + public func selectAccount(subject: String) async { + do { + try await self.session.setActiveAccount(subject: subject) + } catch { + self.errorMessage = error.localizedDescription + } + await self.reload() + } + + /// Signs an account out (revoking its refresh token). + /// - Parameter subject: Account subject; `nil` signs out the active account. + public func signOut(subject: String? = nil) async { + do { + try await self.session.signOut(subject: subject) + } catch { + self.errorMessage = error.localizedDescription + } + await self.reload() + } + + /// Routes a request error: usage-limit errors present the usage-limit sheet, sign-in errors set + /// ``errorMessage``. + /// - Parameter error: Error thrown by a ChatGPT plan request. + public func handle(_ error: any Error) { + if let planError = error as? ChatGPTPlanError { + self.planError = planError + if !planError.isUsageLimit { + self.errorMessage = planError.localizedDescription + } + } else { + self.errorMessage = error.localizedDescription + } + } +} +#endif diff --git a/Sources/OpenClawCore/CredentialStore.swift b/Sources/OpenClawCore/CredentialStore.swift index 6dde61d..89f713b 100644 --- a/Sources/OpenClawCore/CredentialStore.swift +++ b/Sources/OpenClawCore/CredentialStore.swift @@ -82,6 +82,9 @@ private struct CredentialFilePayload: Codable, Sendable { } /// File-backed credential store for non-Apple or fallback environments. +/// +/// The file is written atomically with owner-only (`0600`) permissions; a missing parent directory is +/// created with `0700`. public actor FileCredentialStore: CredentialStore { private let fileURL: URL private let encoder: JSONEncoder @@ -130,12 +133,10 @@ public actor FileCredentialStore: CredentialStore { private func persist(_ payload: CredentialFilePayload) throws { let directory = self.fileURL.deletingLastPathComponent() - try OpenClawFileSystem.ensureDirectory(directory) + try OpenClawFileSystem.ensurePrivateDirectory(directory) let data = try self.encoder.encode(payload) - try OpenClawFileSystem.writeData(data, to: self.fileURL) - #if !os(Windows) - try? FileManager().setAttributes([.posixPermissions: 0o600], ofItemAtPath: self.fileURL.path) - #endif + // Created as 0600 before any secret is written, then atomically renamed into place. + try OpenClawFileSystem.writePrivateData(data, to: self.fileURL) } private static func normalizedKey(_ key: String) throws -> String { diff --git a/Sources/OpenClawCore/FileSystemCompat.swift b/Sources/OpenClawCore/FileSystemCompat.swift index c9c4811..030b95f 100644 --- a/Sources/OpenClawCore/FileSystemCompat.swift +++ b/Sources/OpenClawCore/FileSystemCompat.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif /// Minimal filesystem helpers used by OpenClawKit core subsystems. public enum OpenClawFileSystem { @@ -33,6 +38,50 @@ public enum OpenClawFileSystem { try data.write(to: url, options: [.atomic]) } + /// Writes file contents atomically with owner-only permissions: the bytes go to a temporary file + /// created with `0600` (so they are never readable by others, not even briefly) that then + /// replaces `url`. + /// - Parameters: + /// - data: Bytes to persist. + /// - url: Destination file URL. + public static func writePrivateData(_ data: Data, to url: URL) throws { + #if os(Windows) + try self.writeData(data, to: url) + #else + let temporary = url.deletingLastPathComponent() + .appendingPathComponent(".\(url.lastPathComponent).\(UUID().uuidString).tmp") + let descriptor = open(temporary.path, O_WRONLY | O_CREAT | O_EXCL, 0o600) + guard descriptor >= 0 else { + throw OpenClawCoreError.unavailable("Could not create \(temporary.lastPathComponent) (errno \(errno))") + } + var committed = false + defer { + if !committed { + _ = unlink(temporary.path) + } + } + let written = data.withUnsafeBytes { raw -> Bool in + var offset = 0 + while offset < raw.count { + let count = write(descriptor, raw.baseAddress!.advanced(by: offset), raw.count - offset) + if count < 0, errno == EINTR { continue } + guard count > 0 else { return false } + offset += count + } + return true + } + let synced = fsync(descriptor) == 0 + _ = close(descriptor) + guard written, synced else { + throw OpenClawCoreError.unavailable("Could not write \(url.lastPathComponent)") + } + guard rename(temporary.path, url.path) == 0 else { + throw OpenClawCoreError.unavailable("Could not replace \(url.lastPathComponent) (errno \(errno))") + } + committed = true + #endif + } + /// Ensures a private directory exists: missing directories (and missing intermediates) are created /// with `0700`; existing directories keep their permissions. /// - Parameter url: Directory URL. diff --git a/Sources/OpenClawCore/InteractiveAuthSupport.swift b/Sources/OpenClawCore/InteractiveAuthSupport.swift index 8041536..f8668be 100644 --- a/Sources/OpenClawCore/InteractiveAuthSupport.swift +++ b/Sources/OpenClawCore/InteractiveAuthSupport.swift @@ -58,13 +58,30 @@ public struct InteractiveAuthFlowDescriptor: Sendable, Equatable { public enum InteractiveAuthFlowCatalog { /// Known interactive auth descriptors: the upstream 2026.9.6 flows /// (``upstreamProviderDescriptors``: OpenAI ChatGPT login and device pairing, xAI, OpenRouter, - /// Chutes) followed by the SDK device-code flows (GitHub Copilot, Qwen Portal, MiniMax Portal). + /// Chutes), then Sign in with ChatGPT (`chatgpt-plan`, see ``SignInWithChatGPTSession``), then the + /// SDK device-code flows (GitHub Copilot, Qwen Portal, MiniMax Portal). /// /// - Note: 2026.3.0 merged `openai-codex` into `openai`. The ChatGPT browser flow is listed under /// `openai` and uses the upstream loopback callback `http://localhost:1455/auth/callback` /// (previously `http://127.0.0.1:1455/oauth-callback`); `descriptor(for: "openai-codex")` still /// resolves to it. - public static let descriptors: [InteractiveAuthFlowDescriptor] = upstreamProviderDescriptors + sdkDeviceCodeDescriptors + public static let descriptors: [InteractiveAuthFlowDescriptor] = upstreamProviderDescriptors + sdkBrowserDescriptors + + sdkDeviceCodeDescriptors + + /// SDK browser flows not covered by the upstream descriptors: Sign in with ChatGPT for plan usage + /// (registration client id; later sign-ins use the issued client id). + static let sdkBrowserDescriptors: [InteractiveAuthFlowDescriptor] = [ + InteractiveAuthFlowDescriptor( + providerID: "chatgpt-plan", + displayName: "Sign in with ChatGPT", + kind: .browserOAuth, + authorizationURL: SignInWithChatGPTConfiguration.authorizationURL, + tokenURL: SignInWithChatGPTConfiguration.tokenURL, + callbackURL: SignInWithChatGPTConfiguration.callbackURL(), + clientID: SignInWithChatGPTConfiguration.dynamicClientID, + scopes: SignInWithChatGPTConfiguration.identityScopes + SignInWithChatGPTConfiguration.planUsageScopes + ), + ] /// SDK device-code flows not covered by the upstream descriptors. static let sdkDeviceCodeDescriptors: [InteractiveAuthFlowDescriptor] = [ diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTAccounts.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTAccounts.swift new file mode 100644 index 0000000..17e77a3 --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTAccounts.swift @@ -0,0 +1,229 @@ +import Foundation + +/// A ChatGPT account known on this host. +/// +/// The account keeps its issued client id after sign-out so the next sign-in re-authenticates the +/// same registration instead of registering a new client. +public struct SignInWithChatGPTAccount: Codable, Sendable, Equatable, Identifiable { + /// Stable account identifier (`sub`). + public var subject: String + /// Account email, when shared. + public var email: String? + /// Display name, when shared. + public var name: String? + /// Issued client id of this account's registration. + public var clientID: String + /// Scopes granted by the latest sign-in or refresh. + public var grantedScopes: [String] + /// Whether tokens are stored for the account. + public var isSignedIn: Bool + /// Whether the "You're using your ChatGPT plan" welcome was shown. + public var hasSeenPlanWelcome: Bool + /// First sign-in time. + public var createdAt: Date + /// Latest sign-in time. + public var lastSignedInAt: Date? + + /// Creates an account. + /// - Parameters: + /// - subject: Account subject. + /// - email: Email. + /// - name: Display name. + /// - clientID: Issued client id. + /// - grantedScopes: Granted scopes. + /// - isSignedIn: Whether tokens are stored. + /// - hasSeenPlanWelcome: Whether the plan welcome was shown. + /// - createdAt: First sign-in time. + /// - lastSignedInAt: Latest sign-in time. + public init( + subject: String, + email: String? = nil, + name: String? = nil, + clientID: String, + grantedScopes: [String] = [], + isSignedIn: Bool = false, + hasSeenPlanWelcome: Bool = false, + createdAt: Date = Date(), + lastSignedInAt: Date? = nil + ) { + self.subject = subject + self.email = email + self.name = name + self.clientID = clientID + self.grantedScopes = grantedScopes + self.isSignedIn = isSignedIn + self.hasSeenPlanWelcome = hasSeenPlanWelcome + self.createdAt = createdAt + self.lastSignedInAt = lastSignedInAt + } + + /// Identity for SwiftUI lists (the subject). + public var id: String { + self.subject + } + + /// Whether the latest grant allows ChatGPT plan inference. + public var usesChatGPTPlan: Bool { + self.grantedScopes.contains(SignInWithChatGPTConfiguration.planUsageScope) + } + + /// Label for account pickers: the email, the name, or a shortened subject. + public var displayLabel: String { + if let email, !email.isEmpty { return email } + if let name, !name.isEmpty { return name } + return "ChatGPT account \(self.subject.suffix(6))" + } +} + +/// Persists SIWC host identity, accounts and credential records in a ``CredentialStore``. +/// +/// Keys (under ``keyPrefix``): `host-id`, `accounts` (account index), `active` (active subject) and +/// `credential.` (one credential record per account). Use a Keychain store on Apple platforms +/// (``CredentialStoreFactory/makeDefault(fallbackFileURL:keychainService:keychainAccessGroup:)``); the +/// file store writes `0600` files. +public actor SignInWithChatGPTAccountStore { + /// Default key prefix. + public static let defaultKeyPrefix = "openclaw.siwc" + + /// Backing store. + public let credentialStore: any CredentialStore + /// Key prefix. + public let keyPrefix: String + private var cachedHostIdentifier: SignInWithChatGPTHostIdentifier? + + /// Creates an account store. + /// - Parameters: + /// - credentialStore: Backing secret store. + /// - keyPrefix: Key prefix (use distinct prefixes for separate app profiles). + public init(credentialStore: any CredentialStore, keyPrefix: String = SignInWithChatGPTAccountStore.defaultKeyPrefix) { + self.credentialStore = credentialStore + self.keyPrefix = keyPrefix + } + + /// Returns the persisted host identifier, creating and saving a random `urn:uuid:` one first. + /// - Returns: The host identifier. + public func hostIdentifier() async throws -> SignInWithChatGPTHostIdentifier { + if let cachedHostIdentifier { + return cachedHostIdentifier + } + if let stored = try await self.credentialStore.loadSecret(for: self.key("host-id")), + let identifier = SignInWithChatGPTHostIdentifier(rawValue: stored) { + self.cachedHostIdentifier = identifier + return identifier + } + let identifier = SignInWithChatGPTHostIdentifier.randomUUID() + try await self.setHostIdentifier(identifier) + return identifier + } + + /// Replaces the host identifier (for example with ``SignInWithChatGPTHostIdentifier/jwkThumbprint(ed25519PublicKey:)`` + /// of a device key). Do this before the first sign-in; changing it later splits usage attribution. + /// - Parameter identifier: Host identifier. + public func setHostIdentifier(_ identifier: SignInWithChatGPTHostIdentifier) async throws { + try await self.credentialStore.saveSecret(identifier.rawValue, for: self.key("host-id")) + self.cachedHostIdentifier = identifier + } + + /// Known accounts, most recently signed in first. + /// - Returns: Accounts. + public func accounts() async throws -> [SignInWithChatGPTAccount] { + guard let raw = try await self.credentialStore.loadSecret(for: self.key("accounts")), let data = raw.data(using: .utf8) else { + return [] + } + let accounts = (try? Self.decoder.decode([SignInWithChatGPTAccount].self, from: data)) ?? [] + return accounts.sorted { ($0.lastSignedInAt ?? $0.createdAt) > ($1.lastSignedInAt ?? $1.createdAt) } + } + + /// Returns one account. + /// - Parameter subject: Account subject. + /// - Returns: The account, when known. + public func account(subject: String) async throws -> SignInWithChatGPTAccount? { + try await self.accounts().first { $0.subject == subject } + } + + /// Inserts or replaces an account. + /// - Parameter account: Account. + public func save(_ account: SignInWithChatGPTAccount) async throws { + var accounts = try await self.accounts() + accounts.removeAll { $0.subject == account.subject } + accounts.append(account) + try await self.persist(accounts) + } + + /// Forgets an account and its credential. + /// - Parameter subject: Account subject. + public func remove(subject: String) async throws { + var accounts = try await self.accounts() + accounts.removeAll { $0.subject == subject } + try await self.persist(accounts) + try await self.deleteCredential(subject: subject) + if try await self.activeSubject() == subject { + try await self.credentialStore.deleteSecret(for: self.key("active")) + } + } + + /// Subject of the active account. + /// - Returns: Subject, when set. + public func activeSubject() async throws -> String? { + try await self.credentialStore.loadSecret(for: self.key("active")) + } + + /// Sets or clears the active account. + /// - Parameter subject: Account subject, or `nil` to clear. + public func setActiveSubject(_ subject: String?) async throws { + if let subject { + try await self.credentialStore.saveSecret(subject, for: self.key("active")) + } else { + try await self.credentialStore.deleteSecret(for: self.key("active")) + } + } + + /// Loads an account's credential record. + /// - Parameter subject: Account subject. + /// - Returns: The credential, when stored. + public func credential(subject: String) async throws -> SignInWithChatGPTCredential? { + guard let raw = try await self.credentialStore.loadSecret(for: self.credentialKey(subject)), let data = raw.data(using: .utf8) else { + return nil + } + return try? SignInWithChatGPTCredential.decodeRecord(data) + } + + /// Saves an account's credential record. + /// - Parameter credential: Credential. + public func saveCredential(_ credential: SignInWithChatGPTCredential) async throws { + let data = try Self.encoder.encode(credential) + try await self.credentialStore.saveSecret(String(decoding: data, as: UTF8.self), for: self.credentialKey(credential.subject)) + } + + /// Deletes an account's credential record. + /// - Parameter subject: Account subject. + public func deleteCredential(subject: String) async throws { + try await self.credentialStore.deleteSecret(for: self.credentialKey(subject)) + } + + private func persist(_ accounts: [SignInWithChatGPTAccount]) async throws { + let data = try Self.encoder.encode(accounts) + try await self.credentialStore.saveSecret(String(decoding: data, as: UTF8.self), for: self.key("accounts")) + } + + private func key(_ suffix: String) -> String { + "\(self.keyPrefix).\(suffix)" + } + + private func credentialKey(_ subject: String) -> String { + self.key("credential.\(OpenClawCrypto.sha256Hex(Data(subject.utf8)).prefix(32))") + } + + private static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .secondsSince1970 + return encoder + } + + private static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + return decoder + } +} diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTAuthorization.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTAuthorization.swift new file mode 100644 index 0000000..2d27a9b --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTAuthorization.swift @@ -0,0 +1,490 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +/// Stable, opaque identifier of the machine or app install that runs the agent (`ext_agent_host_id`). +/// +/// OpenAI attributes plan usage to a host, so the identifier must stay the same across sign-ins, +/// accounts and token refreshes, and must be persisted before the first sign-in +/// (``SignInWithChatGPTAccountStore/hostIdentifier()`` does this). It must not identify the user: +/// never derive it from an email address, hostname or hardware serial. +/// +/// Accepted formats: `urn:ietf:params:oauth:jwk-thumbprint:…` (RFC 9278, recommended when the host +/// already holds a stable key pair), `urn:uuid:…` (random UUIDv4) and `did:key:…`. +public struct SignInWithChatGPTHostIdentifier: RawRepresentable, Codable, Sendable, Hashable, CustomStringConvertible { + /// RFC 9278 JWK thumbprint URN prefix for SHA-256 thumbprints. + public static let jwkThumbprintPrefix = "urn:ietf:params:oauth:jwk-thumbprint:sha-256:" + /// UUID URN prefix. + public static let uuidPrefix = "urn:uuid:" + /// DID key prefix. + public static let didKeyPrefix = "did:key:" + + /// Identifier value sent as `ext_agent_host_id`. + public let rawValue: String + + /// Validates an identifier. + /// - Parameter rawValue: Identifier in one of the accepted formats. + public init?(rawValue: String) { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard (1...512).contains(value.count), value.allSatisfy({ $0.isASCII && !$0.isWhitespace }) else { return nil } + if value.hasPrefix("urn:ietf:params:oauth:jwk-thumbprint:") { + guard value.split(separator: ":").count >= 7, value.last != ":" else { return nil } + } else if value.hasPrefix(Self.uuidPrefix) { + guard UUID(uuidString: String(value.dropFirst(Self.uuidPrefix.count))) != nil else { return nil } + } else if value.hasPrefix(Self.didKeyPrefix) { + guard value.count > Self.didKeyPrefix.count else { return nil } + } else { + return nil + } + self.rawValue = value + } + + /// Creates a random `urn:uuid:` identifier (UUIDv4, lowercase). + /// - Returns: A new identifier; persist it and reuse it for every sign-in on this host. + public static func randomUUID() -> Self { + Self(rawValue: Self.uuidPrefix + UUID().uuidString.lowercased())! + } + + /// Creates an RFC 9278 identifier from the SHA-256 JWK thumbprint (RFC 7638) of a stable Ed25519 + /// public key, such as a device identity key. + /// - Parameter ed25519PublicKey: 32-byte raw public key. + /// - Returns: The identifier, or `nil` for a key of the wrong length. + public static func jwkThumbprint(ed25519PublicKey: Data) -> Self? { + guard ed25519PublicKey.count == 32 else { return nil } + return self.jwkThumbprint(members: ["crv": "Ed25519", "kty": "OKP", "x": SignInWithChatGPTBase64URL.encode(ed25519PublicKey)]) + } + + /// Creates an RFC 9278 identifier from the SHA-256 JWK thumbprint of a stable P-256 public key. + /// - Parameter p256PublicKey: Raw `x‖y` (64 bytes) or uncompressed `0x04‖x‖y` (65 bytes) point. + /// - Returns: The identifier, or `nil` for a key of the wrong length. + public static func jwkThumbprint(p256PublicKey: Data) -> Self? { + var point = [UInt8](p256PublicKey) + if point.count == 65, point[0] == 0x04 { + point.removeFirst() + } + guard point.count == 64 else { return nil } + return self.jwkThumbprint(members: [ + "crv": "P-256", + "kty": "EC", + "x": SignInWithChatGPTBase64URL.encode(Data(point[0..<32])), + "y": SignInWithChatGPTBase64URL.encode(Data(point[32..<64])), + ]) + } + + /// Creates an RFC 9278 identifier from the required members of a public JWK. + /// - Parameter members: Required JWK members only (for example `crv`, `kty`, `x`, `y`). + /// - Returns: The identifier. + public static func jwkThumbprint(members: [String: String]) -> Self? { + guard !members.isEmpty else { return nil } + // RFC 7638: required members, lexicographic order, no whitespace. Values are base64url or + // short identifiers, so JSON string escaping only needs quotes and backslashes handled. + let body = members.keys.sorted().map { key in + "\(Self.jsonString(key)):\(Self.jsonString(members[key] ?? ""))" + }.joined(separator: ",") + let digest = SHA256.hash(data: Data("{\(body)}".utf8)) + return Self(rawValue: Self.jwkThumbprintPrefix + SignInWithChatGPTBase64URL.encode(Data(digest))) + } + + /// The identifier value. + public var description: String { + self.rawValue + } + + /// Decodes and validates an identifier string. + /// - Parameter decoder: Decoder. + public init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + guard let identifier = Self(rawValue: value) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Invalid ext_agent_host_id")) + } + self = identifier + } + + /// Encodes the identifier as a string. + /// - Parameter encoder: Encoder. + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.rawValue) + } + + private static func jsonString(_ value: String) -> String { + "\"" + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" + } +} + +/// Base64url helpers (RFC 4648 §5, no padding). +public enum SignInWithChatGPTBase64URL { + /// Encodes bytes without padding. + /// - Parameter data: Bytes. + /// - Returns: Base64url text. + public static func encode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + /// Decodes base64url text with or without padding. + /// - Parameter text: Base64url text. + /// - Returns: Bytes, or `nil` when the text is not base64url. + public static func decode(_ text: String) -> Data? { + guard text.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "=") }) else { return nil } + var base64 = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/").replacingOccurrences(of: "=", with: "") + let remainder = base64.count % 4 + if remainder == 1 { return nil } + if remainder > 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + return Data(base64Encoded: base64) + } + + /// Random bytes encoded as base64url. + /// - Parameter byteCount: Number of random bytes. + /// - Returns: Base64url text. + public static func random(byteCount: Int) -> String { + var generator = SystemRandomNumberGenerator() + return self.encode(Data((0.. Self { + Self(verifier: SignInWithChatGPTBase64URL.random(byteCount: 32)) + } + + /// Computes the `S256` challenge for a verifier. + /// - Parameter verifier: Code verifier. + /// - Returns: Challenge. + public static func challenge(for verifier: String) -> String { + SignInWithChatGPTBase64URL.encode(Data(SHA256.hash(data: Data(verifier.utf8)))) + } +} + +/// Re-consent request added to an authorization. +public enum SignInWithChatGPTConsentPrompt: String, Sendable, Equatable, Codable { + /// No consent prompt parameter. + case automatic + /// `prompt=consent`: show the consent screen again (re-enable plan usage). + case consent + /// `force_reconsent=true`: OpenAI's dedicated re-consent parameter (once rolled out). + case forceReconsent +} + +/// A started authorization waiting for its browser callback. +/// +/// Keep it in memory (or protected storage) until the callback arrives; it holds the PKCE verifier. +public struct SignInWithChatGPTPendingAuthorization: Sendable, Equatable, Codable { + /// URL to open in the browser. + public let authorizationURL: URL + /// Loopback redirect URI. + public let redirectURI: URL + /// `state` sent with the request. + public let state: String + /// `nonce` expected in the ID token. + public let nonce: String + /// PKCE code verifier. + public let codeVerifier: String + /// Client id the authorization was started with (`dynamic_agent_client` for a registration). + public let clientID: String + /// Host identifier sent as `ext_agent_host_id`. + public let hostIdentifier: SignInWithChatGPTHostIdentifier + /// Requested scopes. + public let requestedScopes: [String] + /// Subject of the account being re-authenticated, when this is not a registration. + public let accountSubject: String? + /// When the authorization started. + public let createdAt: Date + + /// Whether this authorization registers a new client (`dynamic_agent_client`). + public var isRegistration: Bool { + self.clientID == SignInWithChatGPTConfiguration.dynamicClientID + } + + /// Creates a pending authorization. Prefer ``SignInWithChatGPTAuthorizationRequest``, which + /// generates the `state`, `nonce` and PKCE values. + /// - Parameters: + /// - authorizationURL: URL to open. + /// - redirectURI: Loopback redirect URI. + /// - state: `state` value. + /// - nonce: `nonce` value. + /// - codeVerifier: PKCE verifier. + /// - clientID: Client id used. + /// - hostIdentifier: Host identifier. + /// - requestedScopes: Requested scopes. + /// - accountSubject: Account being re-authenticated. + /// - createdAt: Start time. + public init( + authorizationURL: URL, + redirectURI: URL, + state: String, + nonce: String, + codeVerifier: String, + clientID: String, + hostIdentifier: SignInWithChatGPTHostIdentifier, + requestedScopes: [String], + accountSubject: String?, + createdAt: Date + ) { + self.authorizationURL = authorizationURL + self.redirectURI = redirectURI + self.state = state + self.nonce = nonce + self.codeVerifier = codeVerifier + self.clientID = clientID + self.hostIdentifier = hostIdentifier + self.requestedScopes = requestedScopes + self.accountSubject = accountSubject + self.createdAt = createdAt + } +} + +/// Builds SIWC authorization requests. +public enum SignInWithChatGPTAuthorizationRequest { + /// Re-authentication of a known account with its issued client id. + public struct Reauthentication: Sendable, Equatable { + /// Issued client id saved for the account (for example `oaiapp_…`). + public var clientID: String + /// Account subject. + public var subject: String + /// Last ID token, sent as `id_token_hint` (it may be expired). + public var idTokenHint: String? + /// Account email, sent as `login_hint`. + public var loginHint: String? + + /// Creates a re-authentication request. + /// - Parameters: + /// - clientID: Issued client id. + /// - subject: Account subject. + /// - idTokenHint: Last ID token. + /// - loginHint: Account email. + public init(clientID: String, subject: String, idTokenHint: String? = nil, loginHint: String? = nil) { + self.clientID = clientID + self.subject = subject + self.idTokenHint = idTokenHint + self.loginHint = loginHint + } + } + + /// Creates a pending authorization with fresh `state`, `nonce` and PKCE values. + /// + /// Without `reauthentication` the request registers a new client: `client_id=dynamic_agent_client` + /// plus `agent_name_hint`. With it, the account's issued client id is used and `agent_name_hint` + /// is omitted. + /// - Parameters: + /// - endpoints: Authorization-server endpoints. + /// - redirectURI: Loopback redirect URI (see ``SignInWithChatGPTConfiguration/isValidCallbackURL(_:)``). + /// - hostIdentifier: Persisted host identifier. + /// - scopes: Scopes to request. + /// - agentName: App name for `agent_name_hint`. + /// - reauthentication: Known account to re-authenticate. + /// - consent: Re-consent parameter. + /// - now: Current time. + /// - Returns: The pending authorization. + /// - Throws: ``SignInWithChatGPTError/invalidCallback(_:)`` for an unusable redirect URI. + public static func make( + endpoints: SignInWithChatGPTEndpoints = .production, + redirectURI: URL, + hostIdentifier: SignInWithChatGPTHostIdentifier, + scopes: [String], + agentName: String, + reauthentication: Reauthentication? = nil, + consent: SignInWithChatGPTConsentPrompt = .automatic, + now: Date = Date() + ) throws -> SignInWithChatGPTPendingAuthorization { + try self.make( + endpoints: endpoints, + redirectURI: redirectURI, + hostIdentifier: hostIdentifier, + scopes: scopes, + agentName: agentName, + reauthentication: reauthentication, + consent: consent, + now: now, + secrets: nil + ) + } + + /// Fixed `state`, `nonce` and verifier (tests only). + struct Secrets: Sendable { + var state: String + var nonce: String + var verifier: String + } + + static func make( + endpoints: SignInWithChatGPTEndpoints, + redirectURI: URL, + hostIdentifier: SignInWithChatGPTHostIdentifier, + scopes: [String], + agentName: String, + reauthentication: Reauthentication?, + consent: SignInWithChatGPTConsentPrompt, + now: Date, + secrets: Secrets? + ) throws -> SignInWithChatGPTPendingAuthorization { + guard SignInWithChatGPTConfiguration.isValidCallbackURL(redirectURI) else { + throw SignInWithChatGPTError.invalidCallback("redirect URI must be http://127.0.0.1:/auth/callback") + } + let state = secrets?.state ?? SignInWithChatGPTBase64URL.random(byteCount: 32) + let nonce = secrets?.nonce ?? SignInWithChatGPTBase64URL.random(byteCount: 32) + let pkce = secrets.map { SignInWithChatGPTPKCE(verifier: $0.verifier) } ?? SignInWithChatGPTPKCE.generate() + let clientID = reauthentication?.clientID ?? SignInWithChatGPTConfiguration.dynamicClientID + var items: [URLQueryItem] = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI.absoluteString), + URLQueryItem(name: "scope", value: scopes.joined(separator: " ")), + URLQueryItem(name: "state", value: state), + URLQueryItem(name: "nonce", value: nonce), + URLQueryItem(name: "code_challenge", value: pkce.challenge), + URLQueryItem(name: "code_challenge_method", value: "S256"), + URLQueryItem(name: "resource", value: SignInWithChatGPTConfiguration.resource), + URLQueryItem(name: "ext_agent_host_id", value: hostIdentifier.rawValue), + ] + if let reauthentication { + if let hint = reauthentication.idTokenHint, !hint.isEmpty { + items.append(URLQueryItem(name: "id_token_hint", value: hint)) + } + if let login = reauthentication.loginHint, !login.isEmpty { + items.append(URLQueryItem(name: "login_hint", value: login)) + } + } else { + let name = agentName.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { + items.append(URLQueryItem(name: "agent_name_hint", value: name)) + } + } + switch consent { + case .automatic: + break + case .consent: + items.append(URLQueryItem(name: "prompt", value: "consent")) + case .forceReconsent: + items.append(URLQueryItem(name: "force_reconsent", value: "true")) + } + guard var components = URLComponents(url: endpoints.authorizationURL, resolvingAgainstBaseURL: false) else { + throw SignInWithChatGPTError.invalidServerResponse("authorization endpoint is not a valid URL") + } + components.percentEncodedQuery = SignInWithChatGPTFormEncoding.encode(items) + guard let url = components.url else { + throw SignInWithChatGPTError.invalidServerResponse("authorization URL could not be built") + } + return SignInWithChatGPTPendingAuthorization( + authorizationURL: url, + redirectURI: redirectURI, + state: state, + nonce: nonce, + codeVerifier: pkce.verifier, + clientID: clientID, + hostIdentifier: hostIdentifier, + requestedScopes: scopes, + accountSubject: reauthentication?.subject, + createdAt: now + ) + } +} + +/// A validated authorization callback. +public struct SignInWithChatGPTAuthorizationCallback: Sendable, Equatable { + /// Authorization code. + public let code: String + /// Client id to use for the code exchange: the issued id from a registration callback, or the + /// pending client id for a re-authentication. + public let clientID: String + /// Scopes reported on the callback, when present. + public let grantedScopes: [String]? + + /// Parses and validates a callback URL against its pending authorization. + /// + /// Checks the redirect path, `error` (`access_denied` → ``SignInWithChatGPTError/accessDenied``), + /// `state`, `code` and `client_id` (a registration must return an issued client id; a + /// re-authentication must not return a different one). + /// - Parameters: + /// - url: Callback URL received on the loopback listener (or pasted by the user). + /// - pending: Pending authorization. + /// - Returns: The validated callback. + /// - Throws: ``SignInWithChatGPTError`` for any mismatch. + public static func parse(_ url: URL, pending: SignInWithChatGPTPendingAuthorization) throws -> Self { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw SignInWithChatGPTError.invalidCallback("callback is not a URL") + } + guard components.path == pending.redirectURI.path else { + throw SignInWithChatGPTError.invalidCallback("callback path does not match the redirect URI") + } + var values: [String: String] = [:] + for item in components.queryItems ?? [] where values[item.name] == nil { + values[item.name] = item.value ?? "" + } + guard let state = values["state"], Self.constantTimeEquals(state, pending.state) else { + throw SignInWithChatGPTError.stateMismatch + } + if let error = values["error"], !error.isEmpty { + if error == "access_denied" { + throw SignInWithChatGPTError.accessDenied + } + throw SignInWithChatGPTError.authorizationFailed(code: error, description: values["error_description"].flatMap { $0.isEmpty ? nil : $0 }) + } + guard let code = values["code"], !code.isEmpty else { + throw SignInWithChatGPTError.missingAuthorizationCode + } + let returnedClientID = values["client_id"].flatMap { $0.isEmpty ? nil : $0 } + let clientID: String + if pending.isRegistration { + guard let returnedClientID, returnedClientID != SignInWithChatGPTConfiguration.dynamicClientID else { + throw SignInWithChatGPTError.missingIssuedClientID + } + clientID = returnedClientID + } else { + if let returnedClientID, returnedClientID != pending.clientID { + throw SignInWithChatGPTError.clientMismatch(expected: pending.clientID, received: returnedClientID) + } + clientID = pending.clientID + } + let scopes = values["scope"].map { $0.split(whereSeparator: { $0 == " " || $0 == "+" }).map(String.init) } + return Self(code: code, clientID: clientID, grantedScopes: scopes) + } + + static func constantTimeEquals(_ lhs: String, _ rhs: String) -> Bool { + let left = Array(lhs.utf8) + let right = Array(rhs.utf8) + guard left.count == right.count else { return false } + return zip(left, right).reduce(UInt8(0)) { $0 | ($1.0 ^ $1.1) } == 0 + } +} + +/// `application/x-www-form-urlencoded` encoding shared by SIWC requests. +enum SignInWithChatGPTFormEncoding { + private static let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + + static func encode(_ items: [URLQueryItem]) -> String { + items.map { item in + "\(self.escape(item.name))=\(self.escape(item.value ?? ""))" + }.joined(separator: "&") + } + + static func encode(_ pairs: KeyValuePairs) -> String { + self.encode(pairs.map { URLQueryItem(name: $0.key, value: $0.value) }) + } + + static func escape(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: self.unreserved) ?? value + } +} diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTConfiguration.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTConfiguration.swift new file mode 100644 index 0000000..a042b38 --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTConfiguration.swift @@ -0,0 +1,272 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Sign in with ChatGPT (SIWC) constants for the open-source "ChatGPT plan usage" flow. +/// +/// SIWC lets a user sign in with their ChatGPT account and, when they grant the plan scopes, run +/// eligible inference against their ChatGPT plan instead of an API key. The first sign-in registers +/// the app with `client_id=dynamic_agent_client`; the callback returns the issued client id (for +/// example `oaiapp_…`) that every later request for that account uses. +/// +/// This is a different OAuth client from ``OpenAIChatGPTOAuthConfiguration`` (the Codex login used by +/// the `openai-chatgpt-responses` route): SIWC tokens call `https://api.openai.com/v1/responses`. +public enum SignInWithChatGPTConfiguration { + /// OAuth issuer (`iss` of ID tokens). + public static let issuer = URL(string: "https://auth.openai.com")! + /// OpenID discovery document. + public static let discoveryURL = URL(string: "https://auth.openai.com/.well-known/openid-configuration")! + /// Browser authorization endpoint. + public static let authorizationURL = URL(string: "https://auth.openai.com/api/accounts/authorize")! + /// Token endpoint (code exchange and refresh). + public static let tokenURL = URL(string: "https://auth.openai.com/api/accounts/oauth/token")! + /// Token revocation endpoint. + public static let revocationURL = URL(string: "https://auth.openai.com/api/accounts/oauth/revoke")! + /// OpenID userinfo endpoint. + public static let userInfoURL = URL(string: "https://auth.openai.com/api/accounts/oauth/userinfo")! + /// JSON Web Key Set used to verify RS256 ID tokens. + public static let jwksURL = URL(string: "https://auth.openai.com/.well-known/jwks.json")! + + /// Client id sent on the first (registration) sign-in of an account. + public static let dynamicClientID = "dynamic_agent_client" + /// Resource indicator sent on authorization, code exchange and refresh. + public static let resource = "https://api.openai.com/v1" + /// Identity scopes (always requested). + public static let identityScopes = ["openid", "profile", "email"] + /// Additional scopes that enable ChatGPT plan usage. + public static let planUsageScopes = ["offline_access", "resource.invoke", "chatgpt.tokens.use.direct"] + /// Scope that must be granted before a token may be used for inference. + public static let planUsageScope = "chatgpt.tokens.use.direct" + + /// Loopback host used in the redirect URI. OpenAI requires the literal IPv4 loopback address; + /// `localhost` is rejected. + public static let callbackHost = "127.0.0.1" + /// Redirect path. + public static let callbackPath = "/auth/callback" + /// Preferred loopback port. Only the port of the redirect URI may vary. + public static let defaultCallbackPort: UInt16 = 1455 + + /// Responses endpoint used with ChatGPT plan access tokens. + public static let responsesURL = URL(string: "https://api.openai.com/v1/responses")! + /// Model list endpoint used with ChatGPT plan access tokens. + public static let modelsURL = URL(string: "https://api.openai.com/v1/models")! + + /// ChatGPT settings page where users manage plan usage ("Manage usage"). + public static let manageUsageURL = URL(string: "https://chatgpt.com/settings/usage")! + /// OpenAI Help Center ("Learn more"). + public static let learnMoreURL = URL(string: "https://help.openai.com/")! + + /// Loopback redirect URI for a port: `http://127.0.0.1:/auth/callback`. + /// - Parameter port: Loopback port. + /// - Returns: Redirect URI. + public static func callbackURL(port: UInt16 = defaultCallbackPort) -> URL { + URL(string: "http://\(callbackHost):\(port)\(callbackPath)")! + } + + /// Returns whether `url` is a valid SIWC loopback redirect URI (`http`, host `127.0.0.1`, an + /// explicit port and the `/auth/callback` path, without query or fragment). + /// - Parameter url: Candidate redirect URI. + /// - Returns: `true` when OpenAI accepts the URI. + public static func isValidCallbackURL(_ url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + return components.scheme == "http" + && components.host == callbackHost + && components.port.map { (1...65_535).contains($0) } == true + && components.path == callbackPath + && components.query == nil + && components.fragment == nil + && components.user == nil + } +} + +/// Authorization-server endpoints used by ``SignInWithChatGPTSession``. +/// +/// ``production`` holds the documented OpenAI endpoints; ``discover(transport:discoveryURL:)`` reads +/// them from the OpenID discovery document instead. Tests point these at local fixtures. +public struct SignInWithChatGPTEndpoints: Sendable, Equatable, Codable { + /// Expected `iss` claim. + public var issuer: URL + /// Authorization endpoint. + public var authorizationURL: URL + /// Token endpoint. + public var tokenURL: URL + /// Revocation endpoint. + public var revocationURL: URL + /// JWKS endpoint. + public var jwksURL: URL + /// Userinfo endpoint. + public var userInfoURL: URL? + + /// Creates an endpoint set. + /// - Parameters: + /// - issuer: Expected `iss` claim. + /// - authorizationURL: Authorization endpoint. + /// - tokenURL: Token endpoint. + /// - revocationURL: Revocation endpoint. + /// - jwksURL: JWKS endpoint. + /// - userInfoURL: Userinfo endpoint. + public init(issuer: URL, authorizationURL: URL, tokenURL: URL, revocationURL: URL, jwksURL: URL, userInfoURL: URL? = nil) { + self.issuer = issuer + self.authorizationURL = authorizationURL + self.tokenURL = tokenURL + self.revocationURL = revocationURL + self.jwksURL = jwksURL + self.userInfoURL = userInfoURL + } + + /// Documented OpenAI endpoints. + public static let production = Self( + issuer: SignInWithChatGPTConfiguration.issuer, + authorizationURL: SignInWithChatGPTConfiguration.authorizationURL, + tokenURL: SignInWithChatGPTConfiguration.tokenURL, + revocationURL: SignInWithChatGPTConfiguration.revocationURL, + jwksURL: SignInWithChatGPTConfiguration.jwksURL, + userInfoURL: SignInWithChatGPTConfiguration.userInfoURL + ) + + /// Reads endpoints from an OpenID discovery document. + /// + /// The document must advertise the `S256` PKCE method and the `code` response type, and its + /// issuer must match the discovery URL's origin. + /// - Parameters: + /// - transport: HTTP transport. + /// - discoveryURL: Discovery document URL. + /// - Returns: Discovered endpoints. + /// - Throws: ``SignInWithChatGPTError/invalidServerResponse(_:)`` for a malformed document. + public static func discover( + transport: SignInWithChatGPTHTTPTransport = .urlSession(), + discoveryURL: URL = SignInWithChatGPTConfiguration.discoveryURL + ) async throws -> Self { + var request = URLRequest(url: discoveryURL) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let response = try await transport.send(request) + guard (200..<300).contains(response.statusCode) else { + throw SignInWithChatGPTError.invalidServerResponse("discovery returned status \(response.statusCode)") + } + guard let document = try? JSONDecoder().decode(DiscoveryDocument.self, from: response.body) else { + throw SignInWithChatGPTError.invalidServerResponse("discovery document is not valid JSON") + } + guard + let issuer = URL(string: document.issuer), + let authorization = URL(string: document.authorizationEndpoint), + let token = URL(string: document.tokenEndpoint), + let jwks = URL(string: document.jwksURI) + else { + throw SignInWithChatGPTError.invalidServerResponse("discovery document is missing endpoints") + } + guard issuer.host == discoveryURL.host, issuer.scheme == discoveryURL.scheme else { + throw SignInWithChatGPTError.invalidServerResponse("discovery issuer does not match the discovery URL") + } + if let methods = document.codeChallengeMethodsSupported, !methods.contains("S256") { + throw SignInWithChatGPTError.invalidServerResponse("authorization server does not support S256 PKCE") + } + let revocation = document.revocationEndpoint.flatMap(URL.init(string:)) ?? SignInWithChatGPTConfiguration.revocationURL + return Self( + issuer: issuer, + authorizationURL: authorization, + tokenURL: token, + revocationURL: revocation, + jwksURL: jwks, + userInfoURL: document.userInfoEndpoint.flatMap(URL.init(string:)) + ) + } + + private struct DiscoveryDocument: Decodable { + let issuer: String + let authorizationEndpoint: String + let tokenEndpoint: String + let jwksURI: String + let revocationEndpoint: String? + let userInfoEndpoint: String? + let codeChallengeMethodsSupported: [String]? + + enum CodingKeys: String, CodingKey { + case issuer + case authorizationEndpoint = "authorization_endpoint" + case tokenEndpoint = "token_endpoint" + case jwksURI = "jwks_uri" + case revocationEndpoint = "revocation_endpoint" + case userInfoEndpoint = "userinfo_endpoint" + case codeChallengeMethodsSupported = "code_challenge_methods_supported" + } + } +} + +/// App-level settings for ``SignInWithChatGPTSession``. +public struct SignInWithChatGPTClientConfiguration: Sendable, Equatable { + /// App name sent as `agent_name_hint` on the first registration of an account (the app's actual + /// name, for example `"OpenClaw"`). It is shown to the user on the consent screen. + public var agentName: String + /// Requests the ChatGPT plan scopes (`offline_access resource.invoke chatgpt.tokens.use.direct`). + /// When `false` only identity scopes are requested and no refresh token is issued. + public var requestsPlanUsage: Bool + /// Preferred loopback callback port. + public var callbackPort: UInt16 + /// Falls back to an ephemeral loopback port when ``callbackPort`` is busy. + public var allowsCallbackPortFallback: Bool + /// Clock skew tolerated when validating ID-token times. + public var clockSkew: TimeInterval + /// Access tokens are refreshed this long before they expire. + public var refreshLeeway: TimeInterval + /// How long ``SignInWithChatGPTSession/signIn(using:reauthenticating:consent:timeout:)`` waits for + /// the browser callback. + public var signInTimeout: TimeInterval + /// Authorization-server endpoints. + public var endpoints: SignInWithChatGPTEndpoints + + /// Creates a client configuration. + /// - Parameters: + /// - agentName: App name sent as `agent_name_hint`. + /// - requestsPlanUsage: Request the ChatGPT plan scopes. + /// - callbackPort: Preferred loopback callback port. + /// - allowsCallbackPortFallback: Use an ephemeral port when `callbackPort` is busy. + /// - clockSkew: Tolerated ID-token clock skew in seconds. + /// - refreshLeeway: Seconds before expiry at which access tokens refresh. + /// - signInTimeout: Seconds to wait for the browser callback. + /// - endpoints: Authorization-server endpoints. + public init( + agentName: String, + requestsPlanUsage: Bool = true, + callbackPort: UInt16 = SignInWithChatGPTConfiguration.defaultCallbackPort, + allowsCallbackPortFallback: Bool = true, + clockSkew: TimeInterval = 60, + refreshLeeway: TimeInterval = 300, + signInTimeout: TimeInterval = 600, + endpoints: SignInWithChatGPTEndpoints = .production + ) { + self.agentName = agentName + self.requestsPlanUsage = requestsPlanUsage + self.callbackPort = callbackPort + self.allowsCallbackPortFallback = allowsCallbackPortFallback + self.clockSkew = clockSkew + self.refreshLeeway = refreshLeeway + self.signInTimeout = signInTimeout + self.endpoints = endpoints + } + + /// Scopes requested on authorization. + public var scopes: [String] { + SignInWithChatGPTConfiguration.identityScopes + (self.requestsPlanUsage ? SignInWithChatGPTConfiguration.planUsageScopes : []) + } +} + +/// HTTP transport used by the SIWC token, revocation and JWKS requests. +public struct SignInWithChatGPTHTTPTransport: Sendable { + /// Sends a request and returns the response (any status code). + public let send: @Sendable (URLRequest) async throws -> HTTPResponseData + + /// Creates a transport from a send closure. + /// - Parameter send: Sends a request and returns the response. + public init(send: @escaping @Sendable (URLRequest) async throws -> HTTPResponseData) { + self.send = send + } + + /// Transport backed by a `URLSession`. + /// - Parameter session: URL session (defaults to `.shared`). + /// - Returns: A transport. + public static func urlSession(_ session: URLSession = .shared) -> Self { + let client = HTTPClient(session: session) + return Self { request in try await client.data(for: request) } + } +} diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTErrors.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTErrors.swift new file mode 100644 index 0000000..e32b7d5 --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTErrors.swift @@ -0,0 +1,284 @@ +import Foundation + +/// Errors raised by the Sign in with ChatGPT authorization, token and account flows. +/// +/// Messages never include tokens, authorization codes or PKCE verifiers. +public enum SignInWithChatGPTError: Error, LocalizedError, Sendable, Equatable { + /// The user declined the consent screen (`error=access_denied`). Stop; do not retry automatically. + case accessDenied + /// The authorization server returned another `error` on the callback. + case authorizationFailed(code: String, description: String?) + /// The callback `state` does not match the pending authorization. + case stateMismatch + /// The callback `client_id` differs from the client id the authorization was started with. + case clientMismatch(expected: String, received: String) + /// The callback has no `code`. + case missingAuthorizationCode + /// A registration callback did not return the issued client id. + case missingIssuedClientID + /// The callback URL is not the pending redirect URI. + case invalidCallback(String) + /// The code exchange returned `invalid_grant`; restart the sign-in. + case authorizationCodeRejected(String?) + /// The refresh token was rejected (expired, revoked, reused …); tokens were cleared and the + /// account must sign in again with its saved client id. + case reauthenticationRequired(subject: String, reason: String) + /// The token endpoint returned `invalid_client`: a configuration error, not a user error. + case invalidClient(String?) + /// The grant does not include `chatgpt.tokens.use.direct`, so the token cannot be used for plan + /// inference. Ask the user to sign in again and re-enable plan usage. + case planUsageNotGranted + /// The ID token failed validation. + case invalidIDToken(String) + /// A token-endpoint request failed. + case tokenRequestFailed(statusCode: Int, code: String?, description: String?) + /// The server returned a response the client could not use. + case invalidServerResponse(String) + /// No signed-in account is available. + case notSignedIn + /// No account with this subject is known. + case unknownAccount(String) + /// The user closed the browser before finishing. + case cancelled + /// The browser callback did not arrive in time. + case timedOut + /// The loopback callback listener could not start. + case callbackListenerUnavailable(String) + /// Token revocation failed after retries. + case revocationFailed(String) + + public var errorDescription: String? { + switch self { + case .accessDenied: + return "Sign in with ChatGPT was cancelled on the consent screen." + case .authorizationFailed(let code, let description): + return "Sign in with ChatGPT failed (\(code))\(description.map { ": \($0)" } ?? "")." + case .stateMismatch: + return "Sign in with ChatGPT callback state does not match this sign-in." + case .clientMismatch: + return "Sign in with ChatGPT callback was issued for a different client." + case .missingAuthorizationCode: + return "Sign in with ChatGPT callback did not include an authorization code." + case .missingIssuedClientID: + return "Sign in with ChatGPT registration did not return a client id." + case .invalidCallback(let detail): + return "Sign in with ChatGPT callback is invalid: \(detail)." + case .authorizationCodeRejected(let description): + return "Sign in with ChatGPT authorization code was rejected\(description.map { ": \($0)" } ?? ""); sign in again." + case .reauthenticationRequired(_, let reason): + return "Your ChatGPT session ended (\(reason)); sign in again." + case .invalidClient(let description): + return "Sign in with ChatGPT client is not valid\(description.map { ": \($0)" } ?? "")." + case .planUsageNotGranted: + return "ChatGPT plan usage was not granted; sign in again and allow plan usage." + case .invalidIDToken(let detail): + return "Sign in with ChatGPT ID token is invalid: \(detail)." + case .tokenRequestFailed(let statusCode, let code, let description): + let detail = [code, description].compactMap { $0 }.joined(separator: ": ") + return "Sign in with ChatGPT token request failed with status \(statusCode)\(detail.isEmpty ? "" : " (\(detail))")." + case .invalidServerResponse(let detail): + return "Sign in with ChatGPT received an invalid response: \(detail)." + case .notSignedIn: + return "No ChatGPT account is signed in." + case .unknownAccount: + return "The ChatGPT account is not known on this device." + case .cancelled: + return "Sign in with ChatGPT was cancelled." + case .timedOut: + return "Sign in with ChatGPT timed out waiting for the browser." + case .callbackListenerUnavailable(let detail): + return "Sign in with ChatGPT could not listen for the browser callback: \(detail)." + case .revocationFailed(let detail): + return "Signing out of ChatGPT could not revoke the session: \(detail)." + } + } + + /// Whether signing in again (with the saved client id) resolves the error. + public var requiresSignIn: Bool { + switch self { + case .reauthenticationRequired, .authorizationCodeRejected, .planUsageNotGranted, .notSignedIn, .stateMismatch, .timedOut: + return true + default: + return false + } + } +} + +/// An inference or admission error returned while using a ChatGPT plan access token. +/// +/// Built from structured error codes (`subscription_sharing_*`, `chatpass_v2_*`) found in HTTP error +/// bodies or `response.failed` stream events, and from direct-admission `401`/`403`/`503` responses +/// that carry `{"detail": …}`. ``recovery`` tells the app which UI to show; ``Kind/usageLimitReached`` +/// maps to the "Usage limit reached" modal. +public struct ChatGPTPlanError: Error, LocalizedError, Sendable, Equatable { + /// Error category. + public enum Kind: String, Sendable, Equatable, CaseIterable { + /// `subscription_sharing_user_not_eligible` (403): the plan does not include app usage. + case notEligible = "subscription_sharing_user_not_eligible" + /// `subscription_sharing_usage_limit_exceeded` (429): the plan or app limit is used up. + case usageLimitReached = "subscription_sharing_usage_limit_exceeded" + /// `subscription_sharing_usage_unavailable` (503): usage accounting is temporarily down. + case usageUnavailable = "subscription_sharing_usage_unavailable" + /// `subscription_sharing_unsupported_capability` (400): the request uses a feature plan usage + /// does not support (for example a hosted tool or audio input). + case unsupportedCapability = "subscription_sharing_unsupported_capability" + /// `subscription_sharing_route_not_supported` (403): the endpoint is not available with plan usage. + case routeNotSupported = "subscription_sharing_route_not_supported" + /// `subscription_sharing_invalid_user` (401): the user behind the token is not valid. + case invalidUser = "subscription_sharing_invalid_user" + /// `chatpass_v2_scope_not_authorized` (403): plan usage is not authorized for this grant. + case scopeNotAuthorized = "chatpass_v2_scope_not_authorized" + /// `chatpass_v2_invalid_authorization_context` (403): the grant's authorization context is invalid. + case invalidAuthorizationContext = "chatpass_v2_invalid_authorization_context" + /// `subscription_sharing_user_unavailable` (503): the account is temporarily unavailable. + case userUnavailable = "subscription_sharing_user_unavailable" + /// Direct-admission rejection (`401`, `403` or `503` with a `detail` message). + case admissionDenied = "direct_admission" + } + + /// What the app should offer the user. + public enum Recovery: String, Sendable, Equatable { + /// Show the usage-limit UI ("Manage usage", optionally "Buy app credits"). + case manageUsage + /// Sign in again (re-enabling plan usage when needed). + case signInAgain + /// Retry later. + case retryLater + /// Change the request (remove the unsupported feature). + case changeRequest + } + + /// Category. + public let kind: Kind + /// HTTP status, when the error came from an HTTP response. + public let statusCode: Int? + /// Raw error code as returned by the server. + public let code: String? + /// Server message (already free of credentials). + public let message: String? + /// `Retry-After` delay in seconds, when present. + public let retryAfter: TimeInterval? + + /// Creates an error. + /// - Parameters: + /// - kind: Category. + /// - statusCode: HTTP status. + /// - code: Raw server error code. + /// - message: Server message. + /// - retryAfter: `Retry-After` delay in seconds. + public init(kind: Kind, statusCode: Int? = nil, code: String? = nil, message: String? = nil, retryAfter: TimeInterval? = nil) { + self.kind = kind + self.statusCode = statusCode + self.code = code ?? (kind == .admissionDenied ? nil : kind.rawValue) + self.message = message + self.retryAfter = retryAfter + } + + /// Suggested recovery. + public var recovery: Recovery { + switch self.kind { + case .usageLimitReached, .notEligible: + return .manageUsage + case .invalidUser, .scopeNotAuthorized, .invalidAuthorizationContext: + return .signInAgain + case .usageUnavailable, .userUnavailable: + return .retryLater + case .unsupportedCapability, .routeNotSupported: + return .changeRequest + case .admissionDenied: + switch self.statusCode { + case 401?: return .signInAgain + case 503?: return .retryLater + default: return .manageUsage + } + } + } + + /// Whether this error should present the "Usage limit reached" UI. + public var isUsageLimit: Bool { + self.kind == .usageLimitReached + } + + /// Whether signing in again should re-request plan consent (`force_reconsent` / `prompt=consent`). + public var requiresPlanReconsent: Bool { + self.kind == .scopeNotAuthorized || self.kind == .invalidAuthorizationContext + } + + public var errorDescription: String? { + let detail = self.message.map { ": \($0)" } ?? "" + switch self.kind { + case .usageLimitReached: + return "Usage limit reached\(detail). Review your plan or this app's limit in ChatGPT settings." + case .notEligible: + return "Your ChatGPT plan can't be used in this app\(detail)." + case .usageUnavailable, .userUnavailable: + return "ChatGPT plan usage is temporarily unavailable\(detail). Try again later." + case .unsupportedCapability: + return "This request uses a feature that isn't available with ChatGPT plan usage\(detail)." + case .routeNotSupported: + return "This endpoint isn't available with ChatGPT plan usage\(detail)." + case .invalidUser, .scopeNotAuthorized, .invalidAuthorizationContext: + return "ChatGPT plan usage needs you to sign in again\(detail)." + case .admissionDenied: + return "ChatGPT plan request was not admitted (status \(self.statusCode ?? 0))\(detail)." + } + } + + /// Maximum characters of a server message kept on the error. + static let maxMessageLength = 500 + + /// Classifies a structured error code (for example from a `response.failed` stream event). + /// - Parameters: + /// - code: Server error code. + /// - message: Server message. + /// - statusCode: HTTP status, when known. + /// - retryAfter: `Retry-After` delay in seconds. + /// - Returns: The error, or `nil` for codes that are not plan-usage errors. + public static func classify(code: String?, message: String? = nil, statusCode: Int? = nil, retryAfter: TimeInterval? = nil) -> Self? { + guard let code = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), let kind = Kind(rawValue: code), kind != .admissionDenied else { + return nil + } + return Self(kind: kind, statusCode: statusCode, code: code, message: self.trimmed(message), retryAfter: retryAfter) + } + + /// Classifies an HTTP error response from the Responses or models endpoint. + /// + /// Recognizes `{"error": {"code": …, "message": …}}`, `{"code": …, "message": …}`, + /// `{"error": ""}` and direct-admission `{"detail": …}` bodies (`401`, `403`, `503`). + /// - Parameters: + /// - statusCode: HTTP status. + /// - body: Response body. + /// - headers: Response headers (for `Retry-After`). + /// - Returns: The error, or `nil` when the response is not a plan-usage error. + public static func classify(statusCode: Int, body: Data, headers: [String: String] = [:]) -> Self? { + guard !(200..<300).contains(statusCode) else { return nil } + let retryAfter = headers.first { $0.key.lowercased() == "retry-after" }.flatMap { TimeInterval($0.value.trimmingCharacters(in: .whitespaces)) } + let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] + let error = object?["error"] + let nested = error as? [String: Any] + let code = (nested?["code"] as? String) ?? (object?["code"] as? String) ?? (error as? String) + let message = (nested?["message"] as? String) ?? (object?["message"] as? String) ?? (object?["error_description"] as? String) + if let classified = self.classify(code: code, message: message, statusCode: statusCode, retryAfter: retryAfter) { + return classified + } + if [401, 403, 503].contains(statusCode), let detail = self.detailMessage(object?["detail"]) { + return Self(kind: .admissionDenied, statusCode: statusCode, code: nil, message: self.trimmed(detail), retryAfter: retryAfter) + } + return nil + } + + private static func detailMessage(_ value: Any?) -> String? { + if let text = value as? String { + return text + } + if let object = value as? [String: Any] { + return (object["message"] as? String) ?? (object["code"] as? String) + } + return nil + } + + private static func trimmed(_ message: String?) -> String? { + guard let message = message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty else { return nil } + return message.count > self.maxMessageLength ? String(message.prefix(self.maxMessageLength)) + "…" : message + } +} diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTIDToken.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTIDToken.swift new file mode 100644 index 0000000..a8194f1 --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTIDToken.swift @@ -0,0 +1,378 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +#if canImport(Security) +import Security +#elseif canImport(_CryptoExtras) +import _CryptoExtras +#endif + +/// Validated ID-token claims. +public struct SignInWithChatGPTIDTokenClaims: Sendable, Equatable { + /// Issuer (`iss`). + public let issuer: String + /// Stable account identifier (`sub`). + public let subject: String + /// Audiences (`aud`). + public let audience: [String] + /// Expiry (`exp`). + public let expiresAt: Date + /// Issue time (`iat`). + public let issuedAt: Date? + /// Nonce (`nonce`). + public let nonce: String? + /// Email (`email`). + public let email: String? + /// Whether the email is verified (`email_verified`). + public let emailVerified: Bool? + /// Display name (`name`). + public let name: String? + + /// Creates claims. + /// - Parameters: + /// - issuer: Issuer. + /// - subject: Subject. + /// - audience: Audiences. + /// - expiresAt: Expiry. + /// - issuedAt: Issue time. + /// - nonce: Nonce. + /// - email: Email. + /// - emailVerified: Email verification flag. + /// - name: Display name. + public init( + issuer: String, + subject: String, + audience: [String], + expiresAt: Date, + issuedAt: Date? = nil, + nonce: String? = nil, + email: String? = nil, + emailVerified: Bool? = nil, + name: String? = nil + ) { + self.issuer = issuer + self.subject = subject + self.audience = audience + self.expiresAt = expiresAt + self.issuedAt = issuedAt + self.nonce = nonce + self.email = email + self.emailVerified = emailVerified + self.name = name + } +} + +/// An RSA public key from a JWKS document. +public struct SignInWithChatGPTRSAPublicKey: Sendable, Equatable { + /// Key id (`kid`). + public let keyID: String? + /// Modulus (`n`) bytes. + public let modulus: Data + /// Public exponent (`e`) bytes. + public let exponent: Data + + /// Creates a key. + /// - Parameters: + /// - keyID: Key id. + /// - modulus: Modulus bytes. + /// - exponent: Exponent bytes. + public init(keyID: String?, modulus: Data, exponent: Data) { + self.keyID = keyID + self.modulus = modulus + self.exponent = exponent + } + + /// Parses the RSA signing keys of a JWKS document (other key types and `use` values are skipped). + /// - Parameter data: JWKS JSON. + /// - Returns: RSA keys. + public static func keys(fromJWKS data: Data) throws -> [Self] { + guard + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let keys = object["keys"] as? [[String: Any]] + else { + throw SignInWithChatGPTError.invalidServerResponse("JWKS document is not valid") + } + return keys.compactMap { jwk in + guard + jwk["kty"] as? String == "RSA", + (jwk["use"] as? String).map({ $0 == "sig" }) ?? true, + (jwk["alg"] as? String).map({ $0 == "RS256" }) ?? true, + let n = (jwk["n"] as? String).flatMap(SignInWithChatGPTBase64URL.decode), + let e = (jwk["e"] as? String).flatMap(SignInWithChatGPTBase64URL.decode) + else { + return nil + } + return Self(keyID: jwk["kid"] as? String, modulus: n, exponent: e) + } + } + + /// Verifies an RSASSA-PKCS1-v1_5 SHA-256 (RS256) signature. Keys shorter than 2048 bits are rejected. + /// + /// Uses Security.framework on Apple platforms and swift-crypto's `_CryptoExtras` on Linux. + /// - Parameters: + /// - signature: Signature bytes. + /// - message: Signed bytes. + /// - Returns: `true` when the signature is valid. + public func verifyRS256(signature: Data, message: Data) -> Bool { + let modulus = Self.stripLeadingZeros(self.modulus) + guard modulus.count * 8 >= 2048, !self.exponent.isEmpty else { return false } + #if canImport(Security) + let der = Self.pkcs1PublicKeyDER(modulus: modulus, exponent: Self.stripLeadingZeros(self.exponent)) + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeyClass as String: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits as String: modulus.count * 8, + ] + var error: Unmanaged? + guard let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error) else { + return false + } + return SecKeyVerifySignature(key, .rsaSignatureMessagePKCS1v15SHA256, message as CFData, signature as CFData, &error) + #elseif canImport(_CryptoExtras) + guard let key = try? _RSA.Signing.PublicKey(n: modulus, e: Self.stripLeadingZeros(self.exponent)) else { + return false + } + return key.isValidSignature(_RSA.Signing.RSASignature(rawRepresentation: signature), for: message, padding: .insecurePKCS1v1_5) + #else + return false + #endif + } + + static func stripLeadingZeros(_ data: Data) -> Data { + Data(data.drop { $0 == 0 }) + } + + /// DER `RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }` (PKCS #1). + static func pkcs1PublicKeyDER(modulus: Data, exponent: Data) -> Data { + func integer(_ bytes: Data) -> Data { + var content = bytes.isEmpty ? Data([0]) : bytes + if let first = content.first, first & 0x80 != 0 { + content.insert(0, at: 0) + } + return Data([0x02]) + length(content.count) + content + } + func length(_ count: Int) -> Data { + if count < 0x80 { + return Data([UInt8(count)]) + } + var value = count + var bytes: [UInt8] = [] + while value > 0 { + bytes.insert(UInt8(value & 0xFF), at: 0) + value >>= 8 + } + return Data([0x80 | UInt8(bytes.count)] + bytes) + } + let body = integer(modulus) + integer(exponent) + return Data([0x30]) + length(body.count) + body + } +} + +/// Caches the authorization server's JWKS and refreshes it when an unknown `kid` appears. +public actor SignInWithChatGPTJWKSCache { + private let url: URL + private let transport: SignInWithChatGPTHTTPTransport + private let minimumRefreshInterval: TimeInterval + private var keys: [SignInWithChatGPTRSAPublicKey] = [] + private var lastFetch: Date? + + /// Creates a cache. + /// - Parameters: + /// - url: JWKS URL. + /// - transport: HTTP transport. + /// - minimumRefreshInterval: Minimum seconds between refreshes triggered by unknown key ids. + public init( + url: URL = SignInWithChatGPTConfiguration.jwksURL, + transport: SignInWithChatGPTHTTPTransport = .urlSession(), + minimumRefreshInterval: TimeInterval = 60 + ) { + self.url = url + self.transport = transport + self.minimumRefreshInterval = minimumRefreshInterval + } + + /// Returns the key for `keyID`, fetching the JWKS when it is not cached (at most once per + /// `minimumRefreshInterval` for unknown ids). + /// - Parameters: + /// - keyID: Key id from the JWT header (`nil` when absent). + /// - now: Current time. + /// - Returns: The key, or `nil` when the JWKS has no matching key. + public func key(for keyID: String?, now: Date = Date()) async throws -> SignInWithChatGPTRSAPublicKey? { + if let key = self.match(keyID) { + return key + } + if let lastFetch, now.timeIntervalSince(lastFetch) < self.minimumRefreshInterval, !self.keys.isEmpty { + return nil + } + var request = URLRequest(url: self.url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + let response = try await self.transport.send(request) + guard (200..<300).contains(response.statusCode) else { + throw SignInWithChatGPTError.invalidServerResponse("JWKS returned status \(response.statusCode)") + } + self.keys = try SignInWithChatGPTRSAPublicKey.keys(fromJWKS: response.body) + self.lastFetch = now + return self.match(keyID) + } + + private func match(_ keyID: String?) -> SignInWithChatGPTRSAPublicKey? { + if let keyID { + return self.keys.first { $0.keyID == keyID } + } + return self.keys.count == 1 ? self.keys.first : nil + } +} + +/// Validates SIWC ID tokens: RS256 signature (JWKS), `iss`, `aud`, `azp`, `exp`, `iat`, `nbf`, +/// `nonce` and a non-empty `sub`. +public struct SignInWithChatGPTIDTokenValidator: Sendable { + /// Expected issuer. + public let issuer: URL + /// JWKS cache. + public let jwks: SignInWithChatGPTJWKSCache + /// Tolerated clock skew in seconds. + public let clockSkew: TimeInterval + + /// Creates a validator. + /// - Parameters: + /// - issuer: Expected issuer. + /// - jwks: JWKS cache. + /// - clockSkew: Tolerated clock skew in seconds. + public init(issuer: URL = SignInWithChatGPTConfiguration.issuer, jwks: SignInWithChatGPTJWKSCache, clockSkew: TimeInterval = 60) { + self.issuer = issuer + self.jwks = jwks + self.clockSkew = clockSkew + } + + /// Validates an ID token. + /// - Parameters: + /// - idToken: Compact JWT. + /// - clientID: Issued client id (expected audience). + /// - nonce: Expected nonce; `nil` skips the nonce check (refresh responses). + /// - now: Current time. + /// - Returns: Validated claims. + /// - Throws: ``SignInWithChatGPTError/invalidIDToken(_:)``. + public func validate(_ idToken: String, clientID: String, nonce: String?, now: Date = Date()) async throws -> SignInWithChatGPTIDTokenClaims { + let parts = idToken.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3, + let headerData = SignInWithChatGPTBase64URL.decode(String(parts[0])), + let payloadData = SignInWithChatGPTBase64URL.decode(String(parts[1])), + let signature = SignInWithChatGPTBase64URL.decode(String(parts[2])), + let header = (try? JSONSerialization.jsonObject(with: headerData)) as? [String: Any], + let payload = (try? JSONSerialization.jsonObject(with: payloadData)) as? [String: Any] + else { + throw SignInWithChatGPTError.invalidIDToken("malformed token") + } + guard header["alg"] as? String == "RS256" else { + throw SignInWithChatGPTError.invalidIDToken("unsupported algorithm") + } + guard let key = try await self.jwks.key(for: header["kid"] as? String, now: now) else { + throw SignInWithChatGPTError.invalidIDToken("unknown signing key") + } + let signingInput = Data("\(parts[0]).\(parts[1])".utf8) + guard key.verifyRS256(signature: signature, message: signingInput) else { + throw SignInWithChatGPTError.invalidIDToken("signature is invalid") + } + let claims = try Self.claims(from: payload) + guard Self.normalizedIssuer(claims.issuer) == Self.normalizedIssuer(self.issuer.absoluteString) else { + throw SignInWithChatGPTError.invalidIDToken("issuer does not match") + } + guard claims.audience.contains(clientID) else { + throw SignInWithChatGPTError.invalidIDToken("audience does not match the client id") + } + if let authorizedParty = payload["azp"] as? String, authorizedParty != clientID { + throw SignInWithChatGPTError.invalidIDToken("authorized party does not match the client id") + } + guard now < claims.expiresAt.addingTimeInterval(self.clockSkew) else { + throw SignInWithChatGPTError.invalidIDToken("token is expired") + } + if let issuedAt = claims.issuedAt, issuedAt > now.addingTimeInterval(self.clockSkew) { + throw SignInWithChatGPTError.invalidIDToken("token was issued in the future") + } + if let notBefore = Self.seconds(payload["nbf"]), Date(timeIntervalSince1970: notBefore) > now.addingTimeInterval(self.clockSkew) { + throw SignInWithChatGPTError.invalidIDToken("token is not valid yet") + } + if let nonce { + guard let tokenNonce = claims.nonce, SignInWithChatGPTAuthorizationCallback.constantTimeEquals(tokenNonce, nonce) else { + throw SignInWithChatGPTError.invalidIDToken("nonce does not match") + } + } + return claims + } + + /// Decodes the claims of a JWT without verifying it (for display only, never for trust decisions). + /// - Parameter idToken: Compact JWT. + /// - Returns: Claims, or `nil` when the token cannot be decoded. + public static func unverifiedClaims(_ idToken: String) -> SignInWithChatGPTIDTokenClaims? { + let parts = idToken.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3, + let data = SignInWithChatGPTBase64URL.decode(String(parts[1])), + let payload = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + else { + return nil + } + return try? self.claims(from: payload) + } + + static func claims(from payload: [String: Any]) throws -> SignInWithChatGPTIDTokenClaims { + guard let issuer = payload["iss"] as? String else { + throw SignInWithChatGPTError.invalidIDToken("missing issuer") + } + guard let subject = payload["sub"] as? String, !subject.isEmpty else { + throw SignInWithChatGPTError.invalidIDToken("missing subject") + } + let audience: [String] + if let single = payload["aud"] as? String { + audience = [single] + } else if let list = payload["aud"] as? [String] { + audience = list + } else { + throw SignInWithChatGPTError.invalidIDToken("missing audience") + } + guard let expiry = self.seconds(payload["exp"]) else { + throw SignInWithChatGPTError.invalidIDToken("missing expiry") + } + return SignInWithChatGPTIDTokenClaims( + issuer: issuer, + subject: subject, + audience: audience, + expiresAt: Date(timeIntervalSince1970: expiry), + issuedAt: self.seconds(payload["iat"]).map { Date(timeIntervalSince1970: $0) }, + nonce: payload["nonce"] as? String, + email: payload["email"] as? String, + emailVerified: self.bool(payload["email_verified"]), + name: payload["name"] as? String + ) + } + + private static func seconds(_ value: Any?) -> TimeInterval? { + switch value { + case let number as Int: + return TimeInterval(number) + case let number as Double: + return number + case let number as NSNumber: + return number.doubleValue + case let text as String: + return TimeInterval(text) + default: + return nil + } + } + + private static func bool(_ value: Any?) -> Bool? { + switch value { + case let flag as Bool: + return flag + case let number as NSNumber: + return number.boolValue + default: + return nil + } + } + + private static func normalizedIssuer(_ value: String) -> String { + value.hasSuffix("/") ? String(value.dropLast()) : value + } +} diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTLoopbackListener.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTLoopbackListener.swift new file mode 100644 index 0000000..7464bea --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTLoopbackListener.swift @@ -0,0 +1,405 @@ +#if !os(tvOS) && !os(watchOS) +import Foundation +#if canImport(Glibc) +import Glibc +#elseif canImport(Darwin) +import Darwin +#endif + +/// One-shot HTTP listener on `127.0.0.1` that receives the Sign in with ChatGPT browser callback. +/// +/// The listener answers `GET /auth/callback?…` with a small "return to the app" page and delivers the +/// callback URL to ``waitForCallback()``. Requests for other paths get `404`; callbacks whose `state` +/// does not match ``expect(state:)`` get `400` and are ignored, so another local process cannot end the +/// sign-in with a forged callback. Only the loopback interface is bound. +/// +/// Available on macOS, iOS, visionOS and Linux (tvOS and watchOS have no browser to sign in with). +public final class SignInWithChatGPTLoopbackListener: @unchecked Sendable { + /// Page shown in the browser after the callback arrives. + public struct CompletionPage: Sendable, Equatable { + /// Page title and heading after a successful callback. + public var successTitle: String + /// Body text after a successful callback. + public var successMessage: String + /// Heading when the callback carries an `error` (for example the user declined). + public var failureTitle: String + /// Body text when the callback carries an `error`. + public var failureMessage: String + + /// Creates a completion page. + /// - Parameters: + /// - successTitle: Success heading. + /// - successMessage: Success body. + /// - failureTitle: Failure heading. + /// - failureMessage: Failure body. + public init(successTitle: String, successMessage: String, failureTitle: String, failureMessage: String) { + self.successTitle = successTitle + self.successMessage = successMessage + self.failureTitle = failureTitle + self.failureMessage = failureMessage + } + + /// Default copy naming the app. + /// - Parameter appName: App name. + /// - Returns: A completion page. + public static func `default`(appName: String) -> Self { + let name = appName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "the app" : appName + return Self( + successTitle: "Signed in with ChatGPT", + successMessage: "You can close this window and return to \(name).", + failureTitle: "Sign in with ChatGPT didn't finish", + failureMessage: "You can close this window and return to \(name) to try again." + ) + } + } + + /// Bound port. + public let port: UInt16 + /// Redirect URI for this listener: `http://127.0.0.1:/auth/callback`. + public let redirectURI: URL + + private let socketDescriptor: Int32 + private let page: CompletionPage + private let lock = NSLock() + private var expectedState: String? + private var delivered: Result? + private var waiter: CheckedContinuation? + private var isStopped = false + private var isSocketClosed = false + + private init(socketDescriptor: Int32, port: UInt16, page: CompletionPage) { + self.socketDescriptor = socketDescriptor + self.port = port + self.redirectURI = SignInWithChatGPTConfiguration.callbackURL(port: port) + self.page = page + } + + deinit { + self.stop() + } + + /// Binds the loopback listener and starts accepting connections on a background thread. + /// - Parameters: + /// - port: Preferred port (`0` picks an ephemeral port). + /// - allowsFallback: Bind an ephemeral port when `port` is in use. + /// - page: Completion page copy. + /// - Returns: A running listener; call ``stop()`` when done. + /// - Throws: ``SignInWithChatGPTError/callbackListenerUnavailable(_:)``. + public static func start( + port: UInt16 = SignInWithChatGPTConfiguration.defaultCallbackPort, + allowsFallback: Bool = true, + page: CompletionPage = .default(appName: "") + ) throws -> SignInWithChatGPTLoopbackListener { + let descriptor: Int32 + do { + descriptor = try self.bindLoopback(port: port) + } catch let error as BindError where error.code == EADDRINUSE && allowsFallback && port != 0 { + do { + descriptor = try self.bindLoopback(port: 0) + } catch let fallback as BindError { + throw SignInWithChatGPTError.callbackListenerUnavailable(fallback.message) + } + } catch let error as BindError { + throw SignInWithChatGPTError.callbackListenerUnavailable(error.message) + } + guard let boundPort = self.boundPort(descriptor) else { + _ = close(descriptor) + throw SignInWithChatGPTError.callbackListenerUnavailable("could not read the bound port") + } + let listener = SignInWithChatGPTLoopbackListener(socketDescriptor: descriptor, port: boundPort, page: page) + let thread = Thread { [weak listener] in + listener?.acceptLoop() + } + thread.name = "OpenClawKit.SignInWithChatGPT.loopback" + thread.start() + return listener + } + + /// Callback URL delivered so far, if any (non-blocking). + public var receivedCallbackURL: URL? { + self.lock.withLock { + if case .success(let url)? = self.delivered { + return url + } + return nil + } + } + + /// Only callbacks whose `state` equals `state` are delivered. + /// - Parameter state: Pending authorization state. + public func expect(state: String) { + self.lock.withLock { self.expectedState = state } + } + + /// Waits for the callback URL. Cancelling the task stops the listener. + /// - Returns: The callback URL (`http://127.0.0.1:/auth/callback?…`). + /// - Throws: `CancellationError` when cancelled or stopped before a callback arrived. + public func waitForCallback() async throws -> URL { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.lock.lock() + if let delivered = self.delivered { + self.lock.unlock() + continuation.resume(with: delivered) + return + } + if self.isStopped { + self.lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + self.waiter = continuation + self.lock.unlock() + } + } onCancel: { + self.stop() + } + } + + /// Stops listening. Pending ``waitForCallback()`` calls throw `CancellationError`. Always call it + /// when done: the accept thread keeps the listener alive until it stops or delivers a callback. + public func stop() { + self.lock.lock() + guard !self.isStopped else { + self.lock.unlock() + return + } + self.isStopped = true + let waiter = self.waiter + self.waiter = nil + if self.delivered == nil { + self.delivered = .failure(CancellationError()) + } + if !self.isSocketClosed { + // Wakes the accept loop, which closes the descriptor; closing here could race with a + // descriptor number being reused while the loop still polls it. + _ = shutdown(self.socketDescriptor, Int32(SHUT_RDWR)) + } + self.lock.unlock() + waiter?.resume(throwing: CancellationError()) + } + + // MARK: - Accept loop + + private var stopped: Bool { + self.lock.withLock { self.isStopped } + } + + private func acceptLoop() { + while !self.stopped { + var descriptor = pollfd(fd: self.socketDescriptor, events: Int16(POLLIN), revents: 0) + let ready = poll(&descriptor, 1, 250) + if ready <= 0 || self.stopped { + continue + } + let client = accept(self.socketDescriptor, nil, nil) + guard client >= 0 else { continue } + if self.handle(client: client) { + break + } + } + self.lock.withLock { + self.isSocketClosed = true + _ = close(self.socketDescriptor) + } + } + + /// Serves one connection; returns `true` once the callback was delivered. + private func handle(client: Int32) -> Bool { + defer { _ = close(client) } + Self.configureClientSocket(client) + guard let target = Self.readRequestTarget(client) else { + Self.respond(client, status: "400 Bad Request", html: nil) + return false + } + guard target.method == "GET" else { + Self.respond(client, status: "405 Method Not Allowed", html: nil) + return false + } + let path = target.path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? "" + guard path == SignInWithChatGPTConfiguration.callbackPath else { + Self.respond(client, status: "404 Not Found", html: nil) + return false + } + guard let url = URL(string: "http://\(SignInWithChatGPTConfiguration.callbackHost):\(self.port)\(target.path)") else { + Self.respond(client, status: "400 Bad Request", html: nil) + return false + } + let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let state = query.first { $0.name == "state" }?.value ?? "" + let expected = self.lock.withLock { self.expectedState } + if let expected, !SignInWithChatGPTAuthorizationCallback.constantTimeEquals(state, expected) { + Self.respond(client, status: "400 Bad Request", html: nil) + return false + } + let failed = query.contains { $0.name == "error" } + let title = failed ? self.page.failureTitle : self.page.successTitle + let message = failed ? self.page.failureMessage : self.page.successMessage + Self.respond(client, status: "200 OK", html: Self.html(title: title, message: message)) + self.deliver(url) + return true + } + + private func deliver(_ url: URL) { + self.lock.lock() + guard self.delivered == nil else { + self.lock.unlock() + return + } + self.delivered = .success(url) + let waiter = self.waiter + self.waiter = nil + self.lock.unlock() + waiter?.resume(returning: url) + } + + // MARK: - HTTP + + struct RequestTarget: Equatable { + var method: String + var path: String + } + + /// Parses the request line of an HTTP/1.x request head. + static func parseRequestLine(_ head: String) -> RequestTarget? { + guard let line = head.split(separator: "\r\n", maxSplits: 1, omittingEmptySubsequences: false).first else { return nil } + let parts = line.split(separator: " ", omittingEmptySubsequences: true) + guard parts.count == 3, parts[2].hasPrefix("HTTP/1."), parts[1].hasPrefix("/") else { return nil } + return RequestTarget(method: String(parts[0]), path: String(parts[1])) + } + + private static func readRequestTarget(_ client: Int32) -> RequestTarget? { + var buffer = [UInt8](repeating: 0, count: 4_096) + var head = Data() + while head.count < 16_384 { + let count = recv(client, &buffer, buffer.count, 0) + guard count > 0 else { break } + head.append(contentsOf: buffer[0..\(status)

\(status)

" + let bodyData = Data(body.utf8) + let head = [ + "HTTP/1.1 \(status)", + "Content-Type: text/html; charset=utf-8", + "Content-Length: \(bodyData.count)", + "Cache-Control: no-store", + "Referrer-Policy: no-referrer", + "X-Content-Type-Options: nosniff", + "Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'", + "Connection: close", + "", + "", + ].joined(separator: "\r\n") + let payload = [UInt8](Data(head.utf8) + bodyData) + var offset = 0 + while offset < payload.count { + let sent = payload.withUnsafeBytes { raw in + send(client, raw.baseAddress!.advanced(by: offset), payload.count - offset, Self.sendFlags) + } + guard sent > 0 else { return } + offset += sent + } + } + + static func html(title: String, message: String) -> String { + let title = self.escapeHTML(title) + let message = self.escapeHTML(message) + return """ + \ + \(title)\ +

\(title)

\(message)

+ """ + } + + static func escapeHTML(_ text: String) -> String { + text.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + // MARK: - Sockets + + private struct BindError: Error { + let code: Int32 + let message: String + } + + #if canImport(Glibc) + private static let sendFlags = Int32(MSG_NOSIGNAL) + #else + private static let sendFlags: Int32 = 0 + #endif + + private static func bindLoopback(port: UInt16) throws -> Int32 { + #if canImport(Glibc) + let streamType = Int32(SOCK_STREAM.rawValue) + #else + let streamType = SOCK_STREAM + #endif + let descriptor = socket(AF_INET, streamType, 0) + guard descriptor >= 0 else { + throw BindError(code: errno, message: "socket() failed (errno \(errno))") + } + var reuse: Int32 = 1 + _ = setsockopt(descriptor, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + var address = sockaddr_in() + #if canImport(Darwin) + address.sin_len = UInt8(MemoryLayout.size) + #endif + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(port.bigEndian) + address.sin_addr = in_addr(s_addr: in_addr_t(0x7F00_0001).bigEndian) + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + let code = errno + _ = close(descriptor) + throw BindError(code: code, message: code == EADDRINUSE ? "port \(port) is in use" : "bind() failed (errno \(code))") + } + guard listen(descriptor, 8) == 0 else { + let code = errno + _ = close(descriptor) + throw BindError(code: code, message: "listen() failed (errno \(code))") + } + return descriptor + } + + private static func boundPort(_ descriptor: Int32) -> UInt16? { + var address = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(descriptor, $0, &length) + } + } + guard result == 0 else { return nil } + return UInt16(bigEndian: address.sin_port) + } + + private static func configureClientSocket(_ client: Int32) { + var timeout = timeval(tv_sec: 5, tv_usec: 0) + _ = setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + _ = setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + #if canImport(Darwin) + var noSigPipe: Int32 = 1 + _ = setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size)) + #endif + } +} +#endif diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTSession.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTSession.swift new file mode 100644 index 0000000..9683b8b --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTSession.swift @@ -0,0 +1,637 @@ +import Foundation + +/// Supplies ChatGPT plan access tokens to inference clients such as `ChatGPTPlanModelProvider`. +public protocol ChatGPTPlanAccessTokenProvider: Sendable { + /// Returns a current access token that allows plan inference. + /// - Parameter rejectedAccessToken: Token the server just rejected with `401`; the provider must + /// refresh instead of returning it again. + /// - Returns: Access token for `Authorization: Bearer`. + func chatGPTPlanAccessToken(rejectedAccessToken: String?) async throws -> String +} + +/// Presents the SIWC authorization page. +/// +/// ``SignInWithChatGPTSession/signIn(using:reauthenticating:consent:timeout:)`` receives the result on +/// its loopback listener, then calls ``dismiss()``. +public protocol SignInWithChatGPTBrowser: Sendable { + /// Shows the authorization page. + /// + /// Return right away after handing the URL to an external browser, or suspend until an in-app + /// browser closes. Throw (for example ``SignInWithChatGPTError/cancelled``) when the user closes + /// the browser before finishing; the sign-in then stops. + /// - Parameter authorizationURL: Authorization URL. + func present(_ authorizationURL: URL) async throws + + /// Closes the browser UI after the callback arrived (no-op for external browsers). + func dismiss() async +} + +/// Opens the authorization page with a host-supplied closure (for example the system browser). +public struct SignInWithChatGPTExternalBrowser: SignInWithChatGPTBrowser { + private let open: @Sendable (URL) async -> Bool + + /// Creates an external-browser presenter. + /// - Parameter open: Opens a URL and returns whether it succeeded (for example + /// `{ await NSWorkspace.shared.open($0) }` on macOS). + public init(open: @escaping @Sendable (URL) async -> Bool) { + self.open = open + } + + /// Opens the URL. + /// - Parameter authorizationURL: Authorization URL. + public func present(_ authorizationURL: URL) async throws { + guard await self.open(authorizationURL) else { + throw OpenClawCoreError.unavailable("Could not open the browser for Sign in with ChatGPT") + } + } + + /// No-op: an external browser tab cannot be closed by the app. + public func dismiss() async {} +} + +/// Result of a completed sign-in. +public struct SignInWithChatGPTSignInResult: Sendable, Equatable { + /// Signed-in account. + public let account: SignInWithChatGPTAccount + /// Whether this sign-in created the account on this host. + public let isNewAccount: Bool + + /// Creates a result. + /// - Parameters: + /// - account: Signed-in account. + /// - isNewAccount: Whether the account is new on this host. + public init(account: SignInWithChatGPTAccount, isNewAccount: Bool) { + self.account = account + self.isNewAccount = isNewAccount + } + + /// Whether to show the one-time "You're using your ChatGPT plan" welcome: plan usage was granted + /// and the welcome was never shown for this account. Call + /// ``SignInWithChatGPTSession/markPlanWelcomeSeen(subject:)`` once it was shown. + public var shouldShowPlanWelcome: Bool { + self.account.usesChatGPTPlan && !self.account.hasSeenPlanWelcome + } +} + +/// Outcome of ``SignInWithChatGPTSession/signOut(subject:)``. +public enum SignInWithChatGPTSignOutResult: Sendable, Equatable { + /// The refresh token was revoked and the tokens were cleared. + case revoked + /// No refresh token was stored; local tokens were cleared. + case clearedLocally + /// Revocation failed after retries; local tokens were cleared anyway. + case revocationFailed(String) +} + +/// Sign in with ChatGPT client: sign-in, token refresh, accounts and sign-out. +/// +/// ```swift +/// let session = SignInWithChatGPTSession( +/// configuration: SignInWithChatGPTClientConfiguration(agentName: "MyAgent"), +/// credentialStore: KeychainCredentialStore() +/// ) +/// let result = try await session.signIn(using: SignInWithChatGPTWebAuthenticationBrowser()) +/// if result.shouldShowPlanWelcome { showWelcome() } +/// let provider = ChatGPTPlanModelProvider(tokenProvider: session, defaultModelID: modelSlug) +/// ``` +/// +/// - Registration vs re-authentication: the first sign-in of an account registers a client with +/// `dynamic_agent_client` and `agent_name_hint`; later sign-ins of a known account reuse its issued +/// client id with `id_token_hint` / `login_hint`. Client ids and tokens of different accounts are +/// never mixed. +/// - Refresh: access tokens refresh ``SignInWithChatGPTClientConfiguration/refreshLeeway`` before they +/// expire (never before `earliest_refresh_at`), one refresh per account at a time; rotated refresh +/// tokens replace the stored ones together with the access token. A rejected refresh token clears +/// the tokens and throws ``SignInWithChatGPTError/reauthenticationRequired(subject:reason:)``. +/// - Sign-out revokes the refresh token (with retries), clears tokens and keeps the account's client +/// id and the host identifier. +/// - Refreshes are serialized within this process. Share one session per credential store; other +/// processes using the same store must coordinate on their own. +public actor SignInWithChatGPTSession: ChatGPTPlanAccessTokenProvider { + /// Client configuration. + public let configuration: SignInWithChatGPTClientConfiguration + /// Account and credential storage. + public let store: SignInWithChatGPTAccountStore + private let tokenClient: SignInWithChatGPTTokenClient + private let validator: SignInWithChatGPTIDTokenValidator + private let now: @Sendable () -> Date + private var refreshTasks: [String: Task] = [:] + + /// Creates a session. + /// - Parameters: + /// - configuration: Client configuration. + /// - credentialStore: Secret store (Keychain on Apple platforms). + /// - keyPrefix: Credential-store key prefix. + /// - transport: HTTP transport for token, revocation and JWKS requests. + /// - now: Clock. + public init( + configuration: SignInWithChatGPTClientConfiguration, + credentialStore: any CredentialStore, + keyPrefix: String = SignInWithChatGPTAccountStore.defaultKeyPrefix, + transport: SignInWithChatGPTHTTPTransport = .urlSession(), + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.init( + configuration: configuration, + store: SignInWithChatGPTAccountStore(credentialStore: credentialStore, keyPrefix: keyPrefix), + tokenClient: SignInWithChatGPTTokenClient(endpoints: configuration.endpoints, transport: transport), + jwks: SignInWithChatGPTJWKSCache(url: configuration.endpoints.jwksURL, transport: transport), + now: now + ) + } + + init( + configuration: SignInWithChatGPTClientConfiguration, + store: SignInWithChatGPTAccountStore, + tokenClient: SignInWithChatGPTTokenClient, + jwks: SignInWithChatGPTJWKSCache, + now: @escaping @Sendable () -> Date + ) { + self.configuration = configuration + self.store = store + self.tokenClient = tokenClient + self.validator = SignInWithChatGPTIDTokenValidator(issuer: configuration.endpoints.issuer, jwks: jwks, clockSkew: configuration.clockSkew) + self.now = now + } + + // MARK: - Accounts + + /// Known accounts, most recent first. + /// - Returns: Accounts. + public func accounts() async throws -> [SignInWithChatGPTAccount] { + try await self.store.accounts() + } + + /// The active account: the one set with ``setActiveAccount(subject:)`` or the latest sign-in. + /// - Returns: The account, or `nil` when none is signed in. + public func activeAccount() async throws -> SignInWithChatGPTAccount? { + let accounts = try await self.store.accounts() + if let subject = try await self.store.activeSubject(), let account = accounts.first(where: { $0.subject == subject }) { + return account + } + return accounts.first { $0.isSignedIn } + } + + /// Selects the account used by ``accessToken(for:requirePlanUsage:)`` and ``chatGPTPlanAccessToken(rejectedAccessToken:)``. + /// - Parameter subject: Account subject. + public func setActiveAccount(subject: String) async throws { + guard try await self.store.account(subject: subject) != nil else { + throw SignInWithChatGPTError.unknownAccount(subject) + } + try await self.store.setActiveSubject(subject) + } + + /// Records that the plan welcome was shown for an account. + /// - Parameter subject: Account subject. + public func markPlanWelcomeSeen(subject: String) async throws { + guard var account = try await self.store.account(subject: subject) else { + throw SignInWithChatGPTError.unknownAccount(subject) + } + account.hasSeenPlanWelcome = true + try await self.store.save(account) + } + + /// Persisted host identifier (created on first use). + /// - Returns: The host identifier. + public func hostIdentifier() async throws -> SignInWithChatGPTHostIdentifier { + try await self.store.hostIdentifier() + } + + // MARK: - Sign-in + + #if !os(tvOS) && !os(watchOS) + /// Runs the complete browser sign-in: starts the loopback listener, opens the authorization page, + /// waits for the callback and exchanges the code. + /// - Parameters: + /// - browser: Browser presenter. + /// - subject: Known account to re-authenticate (uses its issued client id); `nil` signs in a new + /// or returning account (a returning account is matched by `sub` after the sign-in). + /// - consent: Re-consent parameter (use ``SignInWithChatGPTConsentPrompt/forceReconsent`` or + /// ``SignInWithChatGPTConsentPrompt/consent`` to re-enable plan usage). + /// - timeout: Seconds to wait for the callback (defaults to the configuration). + /// - Returns: The sign-in result. + public func signIn( + using browser: any SignInWithChatGPTBrowser, + reauthenticating subject: String? = nil, + consent: SignInWithChatGPTConsentPrompt = .automatic, + timeout: TimeInterval? = nil + ) async throws -> SignInWithChatGPTSignInResult { + try await self.signIn(using: browser, reauthenticating: subject, consent: consent, timeout: timeout, secrets: nil) + } + + func signIn( + using browser: any SignInWithChatGPTBrowser, + reauthenticating subject: String?, + consent: SignInWithChatGPTConsentPrompt, + timeout: TimeInterval?, + secrets: SignInWithChatGPTAuthorizationRequest.Secrets? + ) async throws -> SignInWithChatGPTSignInResult { + let listener = try SignInWithChatGPTLoopbackListener.start( + port: self.configuration.callbackPort, + allowsFallback: self.configuration.allowsCallbackPortFallback, + page: .default(appName: self.configuration.agentName) + ) + defer { listener.stop() } + let pending = try await self.beginAuthorization(redirectURI: listener.redirectURI, reauthenticating: subject, consent: consent, secrets: secrets) + listener.expect(state: pending.state) + let callbackURL = try await Self.awaitCallback( + listener: listener, + browser: browser, + authorizationURL: pending.authorizationURL, + timeout: timeout ?? self.configuration.signInTimeout + ) + return try await self.completeAuthorization(callbackURL: callbackURL, pending: pending) + } + + private enum CallbackRace: Sendable { + case callback(URL) + case browserReturned + case timedOut + } + + private static func awaitCallback( + listener: SignInWithChatGPTLoopbackListener, + browser: any SignInWithChatGPTBrowser, + authorizationURL: URL, + timeout: TimeInterval + ) async throws -> URL { + try await withThrowingTaskGroup(of: CallbackRace.self) { group in + group.addTask { .callback(try await listener.waitForCallback()) } + group.addTask { + try await browser.present(authorizationURL) + return .browserReturned + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(max(1, timeout) * 1_000_000_000)) + return .timedOut + } + do { + while let outcome = try await group.next() { + switch outcome { + case .callback(let url): + await browser.dismiss() + group.cancelAll() + return url + case .browserReturned: + continue + case .timedOut: + await browser.dismiss() + group.cancelAll() + throw SignInWithChatGPTError.timedOut + } + } + } catch { + // The user may close the browser right after the redirect already reached us. + if let url = listener.receivedCallbackURL { + return url + } + listener.stop() + if !(error is SignInWithChatGPTError) { + await browser.dismiss() + } + throw error + } + throw SignInWithChatGPTError.cancelled + } + } + #endif + + /// Starts an authorization for a loopback redirect URI you serve yourself. + /// + /// Use with ``completeAuthorization(callbackURL:pending:)``; ``signIn(using:reauthenticating:consent:timeout:)`` + /// does both. + /// - Parameters: + /// - redirectURI: `http://127.0.0.1:/auth/callback`. + /// - subject: Known account to re-authenticate. + /// - consent: Re-consent parameter. + /// - Returns: The pending authorization (open its ``SignInWithChatGPTPendingAuthorization/authorizationURL``). + public func beginAuthorization( + redirectURI: URL, + reauthenticating subject: String? = nil, + consent: SignInWithChatGPTConsentPrompt = .automatic + ) async throws -> SignInWithChatGPTPendingAuthorization { + try await self.beginAuthorization(redirectURI: redirectURI, reauthenticating: subject, consent: consent, secrets: nil) + } + + func beginAuthorization( + redirectURI: URL, + reauthenticating subject: String?, + consent: SignInWithChatGPTConsentPrompt, + secrets: SignInWithChatGPTAuthorizationRequest.Secrets? + ) async throws -> SignInWithChatGPTPendingAuthorization { + let hostIdentifier = try await self.store.hostIdentifier() + var reauthentication: SignInWithChatGPTAuthorizationRequest.Reauthentication? + if let subject { + guard let account = try await self.store.account(subject: subject) else { + throw SignInWithChatGPTError.unknownAccount(subject) + } + let credential = try await self.store.credential(subject: subject) + reauthentication = .init(clientID: account.clientID, subject: subject, idTokenHint: credential?.idToken, loginHint: account.email) + } + return try SignInWithChatGPTAuthorizationRequest.make( + endpoints: self.configuration.endpoints, + redirectURI: redirectURI, + hostIdentifier: hostIdentifier, + scopes: self.configuration.scopes, + agentName: self.configuration.agentName, + reauthentication: reauthentication, + consent: consent, + now: self.now(), + secrets: secrets + ) + } + + /// Validates the callback, exchanges the code, validates the ID token and stores the account. + /// - Parameters: + /// - callbackURL: Callback URL received on the redirect URI. + /// - pending: Pending authorization from ``beginAuthorization(redirectURI:reauthenticating:consent:)``. + /// - Returns: The sign-in result. + public func completeAuthorization(callbackURL: URL, pending: SignInWithChatGPTPendingAuthorization) async throws -> SignInWithChatGPTSignInResult { + let callback = try SignInWithChatGPTAuthorizationCallback.parse(callbackURL, pending: pending) + let tokens = try await self.tokenClient.exchange(callback, pending: pending) + guard let idToken = tokens.idToken else { + throw SignInWithChatGPTError.invalidServerResponse("token response did not include an ID token") + } + let now = self.now() + let claims = try await self.validator.validate(idToken, clientID: callback.clientID, nonce: pending.nonce, now: now) + if let expected = pending.accountSubject, claims.subject != expected { + throw SignInWithChatGPTError.invalidIDToken("signed in with a different account than the one being re-authenticated") + } + let scopes = tokens.grantedScopes ?? callback.grantedScopes ?? pending.requestedScopes + let credential = SignInWithChatGPTCredential( + email: claims.email, + issuer: claims.issuer, + subject: claims.subject, + clientID: callback.clientID, + hostIdentifier: pending.hostIdentifier, + idToken: idToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + tokenType: tokens.tokenType, + expiresIn: tokens.expiresIn, + scopes: scopes, + savedAt: now, + earliestRefreshAt: tokens.earliestRefreshAt + ) + let existing = try await self.store.account(subject: claims.subject) + if let previous = try await self.store.credential(subject: claims.subject), + previous.clientID != credential.clientID, + let refreshToken = previous.refreshToken { + // A new registration replaced the account's client; retire the old registration's tokens. + let tokenClient = self.tokenClient + Task.detached { try? await tokenClient.revoke(refreshToken: refreshToken, clientID: previous.clientID) } + } + try await self.store.saveCredential(credential) + var account = existing ?? SignInWithChatGPTAccount(subject: claims.subject, clientID: callback.clientID, createdAt: now) + account.clientID = callback.clientID + account.email = claims.email ?? account.email + account.name = claims.name ?? account.name + account.grantedScopes = scopes + account.isSignedIn = true + account.lastSignedInAt = now + try await self.store.save(account) + try await self.store.setActiveSubject(account.subject) + return SignInWithChatGPTSignInResult(account: account, isNewAccount: existing == nil) + } + + // MARK: - Tokens + + /// Returns a current access token, refreshing it first when it is (nearly) expired. + /// - Parameters: + /// - subject: Account subject; `nil` uses the active account. + /// - requirePlanUsage: Throw ``SignInWithChatGPTError/planUsageNotGranted`` unless the grant allows + /// plan inference. + /// - Returns: Access token. + public func accessToken(for subject: String? = nil, requirePlanUsage: Bool = true) async throws -> String { + try await self.currentCredential(for: subject, requirePlanUsage: requirePlanUsage, rejectedAccessToken: nil).accessToken + } + + /// Returns a plan access token for the active account (``ChatGPTPlanAccessTokenProvider``). + /// - Parameter rejectedAccessToken: Token rejected with `401`; forces a refresh. + /// - Returns: Access token. + public func chatGPTPlanAccessToken(rejectedAccessToken: String?) async throws -> String { + try await self.currentCredential(for: nil, requirePlanUsage: true, rejectedAccessToken: rejectedAccessToken).accessToken + } + + /// A token provider pinned to one account (for apps that run several accounts side by side). + /// - Parameter subject: Account subject. + /// - Returns: Token provider. + nonisolated public func tokenProvider(for subject: String) -> any ChatGPTPlanAccessTokenProvider { + AccountTokenProvider(session: self, subject: subject) + } + + /// Refreshes an account's tokens now. + /// - Parameter subject: Account subject; `nil` uses the active account. + /// - Returns: The refreshed credential. + @discardableResult + public func refresh(subject: String? = nil) async throws -> SignInWithChatGPTCredential { + let subject = try await self.resolveSubject(subject) + guard let credential = try await self.store.credential(subject: subject) else { + throw SignInWithChatGPTError.notSignedIn + } + return try await self.refreshed(credential, force: true) + } + + private func currentCredential(for subject: String?, requirePlanUsage: Bool, rejectedAccessToken: String?) async throws -> SignInWithChatGPTCredential { + let subject = try await self.resolveSubject(subject) + guard var credential = try await self.store.credential(subject: subject) else { + throw SignInWithChatGPTError.notSignedIn + } + if let rejectedAccessToken, credential.accessToken == rejectedAccessToken { + guard credential.refreshToken != nil else { + throw SignInWithChatGPTError.reauthenticationRequired(subject: subject, reason: "access token rejected") + } + credential = try await self.refreshed(credential, force: true) + } else if credential.needsRefresh(now: self.now(), leeway: self.configuration.refreshLeeway) { + credential = try await self.refreshed(credential, force: false) + } else if let expiresAt = credential.expiresAt, credential.refreshToken == nil, self.now() >= expiresAt { + throw SignInWithChatGPTError.reauthenticationRequired(subject: subject, reason: "access token expired") + } + if requirePlanUsage, !credential.allowsPlanUsage { + throw SignInWithChatGPTError.planUsageNotGranted + } + return credential + } + + private func resolveSubject(_ subject: String?) async throws -> String { + if let subject { + return subject + } + guard let account = try await self.activeAccount(), account.isSignedIn else { + throw SignInWithChatGPTError.notSignedIn + } + return account.subject + } + + /// Single-flight refresh per account. + private func refreshed(_ credential: SignInWithChatGPTCredential, force: Bool) async throws -> SignInWithChatGPTCredential { + let subject = credential.subject + let rejectedAccessToken = credential.accessToken + if let running = self.refreshTasks[subject] { + let value = try await running.value + // A forced refresh joined a refresh that kept the rejected token: refresh again below. + if !force || value.accessToken != rejectedAccessToken { + return value + } + } + let task = Task { try await self.performRefresh(subject: subject, rejectedAccessToken: rejectedAccessToken, force: force) } + self.refreshTasks[subject] = task + defer { + if self.refreshTasks[subject] == task { + self.refreshTasks[subject] = nil + } + } + return try await task.value + } + + private func performRefresh(subject: String, rejectedAccessToken: String, force: Bool) async throws -> SignInWithChatGPTCredential { + // Re-read: another session sharing the store may have rotated the tokens already. + guard let current = try await self.store.credential(subject: subject) else { + throw SignInWithChatGPTError.notSignedIn + } + if current.accessToken != rejectedAccessToken, !current.needsRefresh(now: self.now(), leeway: self.configuration.refreshLeeway) { + return current + } + if !force, !current.needsRefresh(now: self.now(), leeway: self.configuration.refreshLeeway) { + return current + } + guard let refreshToken = current.refreshToken else { + throw SignInWithChatGPTError.reauthenticationRequired(subject: subject, reason: "no refresh token") + } + let response: SignInWithChatGPTTokenResponse + do { + response = try await self.tokenClient.refresh(refreshToken: refreshToken, clientID: current.clientID, subject: subject) + } catch let error as SignInWithChatGPTError { + if case .reauthenticationRequired = error { + try await self.clearTokens(subject: subject) + } + throw error + } + let now = self.now() + var updated = current.applying(response, now: now) + if let idToken = response.idToken { + // Keep the previous ID token when the new one does not validate for this account. + let claims = try? await self.validator.validate(idToken, clientID: current.clientID, nonce: nil, now: now) + if claims?.subject != subject { + updated.idToken = current.idToken + } else if let email = claims?.email { + updated.email = email + } + } + try await self.store.saveCredential(updated) + if var account = try await self.store.account(subject: subject) { + account.grantedScopes = updated.scopes + account.email = updated.email ?? account.email + try await self.store.save(account) + } + return updated + } + + // MARK: - Sign-out and records + + /// Signs an account out: revokes its refresh token (with retries), then clears its tokens. The + /// account's client id and the host identifier are kept for the next sign-in. + /// - Parameter subject: Account subject; `nil` uses the active account. + /// - Returns: Whether the server confirmed the revocation. + @discardableResult + public func signOut(subject: String? = nil) async throws -> SignInWithChatGPTSignOutResult { + let subject = try await self.resolveSubject(subject) + let credential = try await self.store.credential(subject: subject) + var result = SignInWithChatGPTSignOutResult.clearedLocally + if let credential, let refreshToken = credential.refreshToken { + do { + try await self.tokenClient.revoke(refreshToken: refreshToken, clientID: credential.clientID) + result = .revoked + } catch let error as SignInWithChatGPTError { + result = .revocationFailed(error.localizedDescription) + } + } + try await self.clearTokens(subject: subject) + if try await self.store.activeSubject() == subject { + let next = try await self.store.accounts().first { $0.isSignedIn && $0.subject != subject } + try await self.store.setActiveSubject(next?.subject) + } + return result + } + + /// Signs out (when signed in) and forgets the account, including its client id. + /// - Parameter subject: Account subject. + public func removeAccount(subject: String) async throws { + if try await self.store.credential(subject: subject) != nil { + try await self.signOut(subject: subject) + } + try await self.store.remove(subject: subject) + } + + /// Exports an account's credential record (documented JSON), for example to move a session to a + /// self-hosted VM. The record contains live tokens. + /// - Parameter subject: Account subject; `nil` uses the active account. + /// - Returns: Record JSON. + public func exportCredentialRecord(subject: String? = nil) async throws -> Data { + let subject = try await self.resolveSubject(subject) + guard let credential = try await self.store.credential(subject: subject) else { + throw SignInWithChatGPTError.notSignedIn + } + return try credential.recordJSON() + } + + /// Imports a credential record produced on another machine. This host keeps its own host identifier + /// for later authorizations and refreshes the imported tokens itself. + /// - Parameter data: Record JSON. + /// - Returns: The imported account. + @discardableResult + public func importCredentialRecord(_ data: Data) async throws -> SignInWithChatGPTAccount { + var credential: SignInWithChatGPTCredential + do { + credential = try SignInWithChatGPTCredential.decodeRecord(data) + } catch { + throw SignInWithChatGPTError.invalidServerResponse("credential record is not valid") + } + let claims = credential.idToken.flatMap(SignInWithChatGPTIDTokenValidator.unverifiedClaims) + if credential.subject.isEmpty { + guard let subject = claims?.subject else { + throw SignInWithChatGPTError.invalidServerResponse("credential record has no subject") + } + credential.subject = subject + } + credential.email = credential.email ?? claims?.email + _ = try await self.store.hostIdentifier() + try await self.store.saveCredential(credential) + var account = try await self.store.account(subject: credential.subject) + ?? SignInWithChatGPTAccount(subject: credential.subject, clientID: credential.clientID, createdAt: self.now()) + account.clientID = credential.clientID + account.email = credential.email ?? account.email + account.grantedScopes = credential.scopes + account.isSignedIn = true + account.lastSignedInAt = self.now() + try await self.store.save(account) + if try await self.store.activeSubject() == nil { + try await self.store.setActiveSubject(account.subject) + } + return account + } + + private func clearTokens(subject: String) async throws { + try await self.store.deleteCredential(subject: subject) + if var account = try await self.store.account(subject: subject) { + account.isSignedIn = false + try await self.store.save(account) + } + } +} + +/// Token provider pinned to one account. +private struct AccountTokenProvider: ChatGPTPlanAccessTokenProvider { + let session: SignInWithChatGPTSession + let subject: String + + func chatGPTPlanAccessToken(rejectedAccessToken: String?) async throws -> String { + try await self.session.planAccessToken(subject: self.subject, rejectedAccessToken: rejectedAccessToken) + } +} + +extension SignInWithChatGPTSession { + func planAccessToken(subject: String, rejectedAccessToken: String?) async throws -> String { + try await self.currentCredential(for: subject, requirePlanUsage: true, rejectedAccessToken: rejectedAccessToken).accessToken + } +} diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTTokens.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTTokens.swift new file mode 100644 index 0000000..bc0e466 --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTTokens.swift @@ -0,0 +1,494 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Token-endpoint response for the code exchange and the refresh grant. +public struct SignInWithChatGPTTokenResponse: Sendable, Equatable, Decodable { + /// Access token (one hour). + public let accessToken: String + /// Rotating refresh token (30 days), present when `offline_access` was granted. + public let refreshToken: String? + /// ID token. + public let idToken: String? + /// Token type (`Bearer`). + public let tokenType: String + /// Access-token lifetime in seconds. + public let expiresIn: Int? + /// Space-separated granted scopes. + public let scope: String? + /// Earliest time a refresh should be attempted. + public let earliestRefreshAt: Date? + + /// Creates a token response. + /// - Parameters: + /// - accessToken: Access token. + /// - refreshToken: Refresh token. + /// - idToken: ID token. + /// - tokenType: Token type. + /// - expiresIn: Lifetime in seconds. + /// - scope: Granted scopes. + /// - earliestRefreshAt: Earliest refresh time. + public init( + accessToken: String, + refreshToken: String? = nil, + idToken: String? = nil, + tokenType: String = "Bearer", + expiresIn: Int? = nil, + scope: String? = nil, + earliestRefreshAt: Date? = nil + ) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.idToken = idToken + self.tokenType = tokenType + self.expiresIn = expiresIn + self.scope = scope + self.earliestRefreshAt = earliestRefreshAt + } + + /// Granted scopes as a list. + public var grantedScopes: [String]? { + self.scope.map { $0.split(separator: " ").map(String.init) } + } + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case idToken = "id_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scope + case earliestRefreshAt = "earliest_refresh_at" + } + + /// Decodes a token response; `earliest_refresh_at` may be Unix seconds or an ISO 8601 string. + /// - Parameter decoder: Decoder. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.accessToken = try container.decode(String.self, forKey: .accessToken) + self.refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) + self.idToken = try container.decodeIfPresent(String.self, forKey: .idToken) + self.tokenType = try container.decodeIfPresent(String.self, forKey: .tokenType) ?? "Bearer" + if let seconds = try? container.decodeIfPresent(Int.self, forKey: .expiresIn) { + self.expiresIn = seconds + } else if let seconds = try? container.decodeIfPresent(Double.self, forKey: .expiresIn) { + self.expiresIn = Int(seconds) + } else { + self.expiresIn = (try? container.decodeIfPresent(String.self, forKey: .expiresIn)).flatMap { $0.flatMap(Int.init) } + } + self.scope = try container.decodeIfPresent(String.self, forKey: .scope) + self.earliestRefreshAt = SignInWithChatGPTDates.flexibleDate(container, forKey: .earliestRefreshAt) + } +} + +/// Stored credential for one SIWC account (the documented credential record). +/// +/// Encodes to the documented JSON field names (`client_id`, `access_token`, `refresh_token`, +/// `id_token`, `expires_in`, `saved_at` as ISO 8601, `ext_agent_host_id`, …) so a record can be moved +/// to a self-hosted VM. Never log it; persist it only in a ``CredentialStore`` (Keychain on Apple +/// platforms, a `0600` file elsewhere). +public struct SignInWithChatGPTCredential: Codable, Sendable, Equatable { + /// Account email, when shared. + public var email: String? + /// Token issuer. + public var issuer: String + /// Account subject (`sub`). + public var subject: String + /// Issued client id of this account's registration. + public var clientID: String + /// Host identifier the credential was issued to. + public var hostIdentifier: SignInWithChatGPTHostIdentifier + /// Last ID token (kept for `id_token_hint`). + public var idToken: String? + /// Current access token. + public var accessToken: String + /// Current refresh token. + public var refreshToken: String? + /// Token type. + public var tokenType: String + /// Access-token lifetime in seconds from ``savedAt``. + public var expiresIn: Int? + /// Granted scopes. + public var scopes: [String] + /// When the tokens were saved. + public var savedAt: Date + /// Earliest time a refresh should be attempted. + public var earliestRefreshAt: Date? + + /// Creates a credential. + /// - Parameters: + /// - email: Account email. + /// - issuer: Token issuer. + /// - subject: Account subject. + /// - clientID: Issued client id. + /// - hostIdentifier: Host identifier. + /// - idToken: ID token. + /// - accessToken: Access token. + /// - refreshToken: Refresh token. + /// - tokenType: Token type. + /// - expiresIn: Lifetime in seconds. + /// - scopes: Granted scopes. + /// - savedAt: Save time. + /// - earliestRefreshAt: Earliest refresh time. + public init( + email: String?, + issuer: String, + subject: String, + clientID: String, + hostIdentifier: SignInWithChatGPTHostIdentifier, + idToken: String?, + accessToken: String, + refreshToken: String?, + tokenType: String = "Bearer", + expiresIn: Int?, + scopes: [String], + savedAt: Date, + earliestRefreshAt: Date? = nil + ) { + self.email = email + self.issuer = issuer + self.subject = subject + self.clientID = clientID + self.hostIdentifier = hostIdentifier + self.idToken = idToken + self.accessToken = accessToken + self.refreshToken = refreshToken + self.tokenType = tokenType + self.expiresIn = expiresIn + self.scopes = scopes + self.savedAt = savedAt + self.earliestRefreshAt = earliestRefreshAt + } + + /// When the access token expires, when its lifetime is known. + public var expiresAt: Date? { + self.expiresIn.map { self.savedAt.addingTimeInterval(TimeInterval($0)) } + } + + /// Whether the grant allows ChatGPT plan inference. + public var allowsPlanUsage: Bool { + self.scopes.contains(SignInWithChatGPTConfiguration.planUsageScope) + } + + /// Whether the access token should be refreshed at `now`. + /// - Parameters: + /// - now: Current time. + /// - leeway: Seconds before expiry at which to refresh. + /// - Returns: `true` when a refresh token exists and the access token is (nearly) expired. + public func needsRefresh(now: Date, leeway: TimeInterval) -> Bool { + guard self.refreshToken != nil, let expiresAt = self.expiresAt else { return false } + if now >= expiresAt { + return true + } + if let earliest = self.earliestRefreshAt, now < earliest { + return false + } + return now >= expiresAt.addingTimeInterval(-leeway) + } + + /// Applies a refresh response: every token is replaced together (refresh tokens rotate). + /// - Parameters: + /// - response: Refresh response. + /// - now: Save time. + /// - Returns: The updated credential. + public func applying(_ response: SignInWithChatGPTTokenResponse, now: Date) -> Self { + var updated = self + updated.accessToken = response.accessToken + updated.refreshToken = response.refreshToken ?? self.refreshToken + updated.idToken = response.idToken ?? self.idToken + updated.tokenType = response.tokenType + updated.expiresIn = response.expiresIn + updated.scopes = response.grantedScopes ?? self.scopes + updated.savedAt = now + updated.earliestRefreshAt = response.earliestRefreshAt + return updated + } + + enum CodingKeys: String, CodingKey { + case email + case issuer + case subject + case clientID = "client_id" + case hostIdentifier = "ext_agent_host_id" + case idToken = "id_token" + case accessToken = "access_token" + case refreshToken = "refresh_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + case scopes + case savedAt = "saved_at" + case earliestRefreshAt = "earliest_refresh_at" + } + + /// Decodes a credential record. + /// - Parameter decoder: Decoder. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.email = try container.decodeIfPresent(String.self, forKey: .email) + self.issuer = try container.decodeIfPresent(String.self, forKey: .issuer) ?? SignInWithChatGPTConfiguration.issuer.absoluteString + self.subject = try container.decodeIfPresent(String.self, forKey: .subject) ?? "" + self.clientID = try container.decode(String.self, forKey: .clientID) + self.hostIdentifier = try container.decode(SignInWithChatGPTHostIdentifier.self, forKey: .hostIdentifier) + self.idToken = try container.decodeIfPresent(String.self, forKey: .idToken) + self.accessToken = try container.decode(String.self, forKey: .accessToken) + self.refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) + self.tokenType = try container.decodeIfPresent(String.self, forKey: .tokenType) ?? "Bearer" + self.expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn) + if let scopes = try? container.decodeIfPresent([String].self, forKey: .scopes) { + self.scopes = scopes + } else { + self.scopes = (try container.decodeIfPresent(String.self, forKey: .scopes))?.split(separator: " ").map(String.init) ?? [] + } + self.savedAt = SignInWithChatGPTDates.flexibleDate(container, forKey: .savedAt) ?? Date(timeIntervalSince1970: 0) + self.earliestRefreshAt = SignInWithChatGPTDates.flexibleDate(container, forKey: .earliestRefreshAt) + } + + /// Encodes a credential record (`saved_at` as ISO 8601). + /// - Parameter encoder: Encoder. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.email, forKey: .email) + try container.encode(self.issuer, forKey: .issuer) + try container.encode(self.subject, forKey: .subject) + try container.encode(self.clientID, forKey: .clientID) + try container.encode(self.hostIdentifier, forKey: .hostIdentifier) + try container.encodeIfPresent(self.idToken, forKey: .idToken) + try container.encode(self.accessToken, forKey: .accessToken) + try container.encodeIfPresent(self.refreshToken, forKey: .refreshToken) + try container.encode(self.tokenType, forKey: .tokenType) + try container.encodeIfPresent(self.expiresIn, forKey: .expiresIn) + try container.encode(self.scopes, forKey: .scopes) + try container.encode(SignInWithChatGPTDates.iso8601(self.savedAt), forKey: .savedAt) + try container.encodeIfPresent(self.earliestRefreshAt.map(SignInWithChatGPTDates.iso8601), forKey: .earliestRefreshAt) + } + + /// Encodes the record as JSON (sorted keys), for example to move it to a self-hosted VM. + /// - Returns: JSON bytes containing live tokens; protect them like a password. + public func recordJSON() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes] + return try encoder.encode(self) + } + + /// Decodes a credential record. + /// - Parameter data: Record JSON. + /// - Returns: The credential. + public static func decodeRecord(_ data: Data) throws -> Self { + try JSONDecoder().decode(Self.self, from: data) + } +} + +/// Date helpers for SIWC payloads. +enum SignInWithChatGPTDates { + static func iso8601(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } + + static func parseISO8601(_ text: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: text) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: text) + } + + static func flexibleDate(_ container: KeyedDecodingContainer, forKey key: Key) -> Date? { + if let seconds = try? container.decodeIfPresent(Double.self, forKey: key) { + // Millisecond timestamps are far beyond any plausible seconds value. + return Date(timeIntervalSince1970: seconds > 100_000_000_000 ? seconds / 1_000 : seconds) + } + if let text = try? container.decodeIfPresent(String.self, forKey: key) { + if let seconds = Double(text) { + return Date(timeIntervalSince1970: seconds > 100_000_000_000 ? seconds / 1_000 : seconds) + } + return self.parseISO8601(text) + } + return nil + } +} + +/// Token-endpoint client: code exchange, refresh and revocation (public client, no secret). +public struct SignInWithChatGPTTokenClient: Sendable { + /// Endpoints. + public let endpoints: SignInWithChatGPTEndpoints + /// HTTP transport. + public let transport: SignInWithChatGPTHTTPTransport + /// Delays between revocation retries (seconds). + let revocationRetryDelays: [TimeInterval] + + /// Creates a token client. + /// - Parameters: + /// - endpoints: Endpoints. + /// - transport: HTTP transport. + public init(endpoints: SignInWithChatGPTEndpoints = .production, transport: SignInWithChatGPTHTTPTransport = .urlSession()) { + self.init(endpoints: endpoints, transport: transport, revocationRetryDelays: [0.5, 1, 2]) + } + + init(endpoints: SignInWithChatGPTEndpoints, transport: SignInWithChatGPTHTTPTransport, revocationRetryDelays: [TimeInterval]) { + self.endpoints = endpoints + self.transport = transport + self.revocationRetryDelays = revocationRetryDelays + } + + /// Exchanges an authorization code (`grant_type=authorization_code`). + /// - Parameters: + /// - callback: Validated callback (its client id is the issued id). + /// - pending: Pending authorization (verifier and redirect URI). + /// - Returns: Token response. + /// - Throws: ``SignInWithChatGPTError/authorizationCodeRejected(_:)`` for `invalid_grant`. + public func exchange( + _ callback: SignInWithChatGPTAuthorizationCallback, + pending: SignInWithChatGPTPendingAuthorization + ) async throws -> SignInWithChatGPTTokenResponse { + let form: KeyValuePairs = [ + "grant_type": "authorization_code", + "client_id": callback.clientID, + "code": callback.code, + "code_verifier": pending.codeVerifier, + "redirect_uri": pending.redirectURI.absoluteString, + "resource": SignInWithChatGPTConfiguration.resource, + ] + do { + return try await self.tokenRequest(form) + } catch let OAuthFailure.rejected(status, code, description) { + switch code { + case "invalid_grant"?: + throw SignInWithChatGPTError.authorizationCodeRejected(description) + case "invalid_client"?: + throw SignInWithChatGPTError.invalidClient(description) + default: + throw SignInWithChatGPTError.tokenRequestFailed(statusCode: status, code: code, description: description) + } + } + } + + /// Refreshes tokens (`grant_type=refresh_token`, no `scope`). + /// - Parameters: + /// - refreshToken: Current refresh token. + /// - clientID: Issued client id of the account. + /// - subject: Account subject (for error reporting). + /// - Returns: Token response with a rotated refresh token. + /// - Throws: ``SignInWithChatGPTError/reauthenticationRequired(subject:reason:)`` when the refresh + /// token is no longer usable; ``SignInWithChatGPTError/invalidClient(_:)`` for `invalid_client`. + public func refresh(refreshToken: String, clientID: String, subject: String) async throws -> SignInWithChatGPTTokenResponse { + let form: KeyValuePairs = [ + "grant_type": "refresh_token", + "client_id": clientID, + "refresh_token": refreshToken, + "resource": SignInWithChatGPTConfiguration.resource, + ] + do { + return try await self.tokenRequest(form) + } catch let OAuthFailure.rejected(status, code, description) { + if let code, Self.reauthenticationCodes.contains(code) { + throw SignInWithChatGPTError.reauthenticationRequired(subject: subject, reason: code) + } + if code == "invalid_client" { + throw SignInWithChatGPTError.invalidClient(description) + } + if status == 401 { + throw SignInWithChatGPTError.reauthenticationRequired(subject: subject, reason: code ?? "unauthorized") + } + throw SignInWithChatGPTError.tokenRequestFailed(statusCode: status, code: code, description: description) + } + } + + /// Revokes a refresh token. Retries network failures and `5xx` responses with backoff. + /// - Parameters: + /// - refreshToken: Refresh token to revoke. + /// - clientID: Issued client id of the account. + /// - Throws: ``SignInWithChatGPTError/revocationFailed(_:)`` after the retries are exhausted or for + /// a `4xx` response. + public func revoke(refreshToken: String, clientID: String) async throws { + let body = SignInWithChatGPTFormEncoding.encode([ + "token": refreshToken, + "token_type_hint": "refresh_token", + "client_id": clientID, + ] as KeyValuePairs) + var request = URLRequest(url: self.endpoints.revocationURL) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = Data(body.utf8) + var lastFailure = "no attempt" + for attempt in 0...self.revocationRetryDelays.count { + if attempt > 0 { + try await Task.sleep(nanoseconds: UInt64(self.revocationRetryDelays[attempt - 1] * 1_000_000_000)) + } + do { + let response = try await self.transport.send(request) + if (200..<300).contains(response.statusCode) { + return + } + let failure = Self.oauthError(response.body) + lastFailure = "status \(response.statusCode)" + (failure.code.map { " (\($0))" } ?? "") + if response.statusCode < 500 { + throw SignInWithChatGPTError.revocationFailed(lastFailure) + } + } catch let error as SignInWithChatGPTError { + throw error + } catch is CancellationError { + throw CancellationError() + } catch { + lastFailure = "network error" + } + } + throw SignInWithChatGPTError.revocationFailed(lastFailure) + } + + /// Refresh-grant error codes that mean the refresh token can no longer be used. + public static let reauthenticationCodes: Set = [ + "invalid_grant", + "invalid_refresh_token", + "token_expired", + "refresh_token_expired", + "refresh_token_invalidated", + "refresh_token_reused", + ] + + enum OAuthFailure: Error { + case rejected(status: Int, code: String?, description: String?) + } + + private func tokenRequest(_ form: KeyValuePairs) async throws -> SignInWithChatGPTTokenResponse { + var request = URLRequest(url: self.endpoints.tokenURL) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = Data(SignInWithChatGPTFormEncoding.encode(form).utf8) + let response = try await self.transport.send(request) + guard (200..<300).contains(response.statusCode) else { + let failure = Self.oauthError(response.body) + throw OAuthFailure.rejected(status: response.statusCode, code: failure.code, description: failure.description) + } + do { + return try JSONDecoder().decode(SignInWithChatGPTTokenResponse.self, from: response.body) + } catch { + throw SignInWithChatGPTError.invalidServerResponse("token response is not valid JSON") + } + } + + /// Reads `{"error": "…", "error_description": "…"}` or `{"error": {"code": …, "message": …}}`. + static func oauthError(_ body: Data) -> (code: String?, description: String?) { + guard let object = (try? JSONSerialization.jsonObject(with: body)) as? [String: Any] else { + return (nil, nil) + } + if let nested = object["error"] as? [String: Any] { + let code = (nested["code"] as? String) ?? (nested["type"] as? String) + return (code, Self.shortened(nested["message"] as? String)) + } + let code = (object["error"] as? String) ?? (object["code"] as? String) + let description = (object["error_description"] as? String) ?? (object["message"] as? String) ?? (object["detail"] as? String) + return (code, Self.shortened(description)) + } + + private static func shortened(_ text: String?) -> String? { + guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil } + return text.count > 300 ? String(text.prefix(300)) + "…" : text + } +} diff --git a/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTWebAuthenticationBrowser.swift b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTWebAuthenticationBrowser.swift new file mode 100644 index 0000000..5a4cfb9 --- /dev/null +++ b/Sources/OpenClawCore/SignInWithChatGPT/SignInWithChatGPTWebAuthenticationBrowser.swift @@ -0,0 +1,94 @@ +#if canImport(AuthenticationServices) && !os(tvOS) && !os(watchOS) +import AuthenticationServices +import Foundation + +/// Presents the Sign in with ChatGPT page in an `ASWebAuthenticationSession`. +/// +/// The redirect goes to the loopback listener (`http://127.0.0.1:/auth/callback`), which +/// `ASWebAuthenticationSession` cannot intercept, so the session is started without a callback scheme +/// and ``dismiss()`` cancels it once the listener received the callback. The session shares cookies +/// with Safari by default, so users already signed in to ChatGPT only confirm consent. +@MainActor +public final class SignInWithChatGPTWebAuthenticationBrowser: NSObject, SignInWithChatGPTBrowser, ASWebAuthenticationPresentationContextProviding { + private let prefersEphemeralSession: Bool + private let presentationAnchorProvider: (() -> ASPresentationAnchor)? + private var session: ASWebAuthenticationSession? + private var continuation: CheckedContinuation? + + /// Creates a browser presenter. + /// - Parameters: + /// - prefersEphemeralSession: Use a private browsing session (no shared ChatGPT cookies). + /// - presentationAnchor: Window that presents the sheet (required on macOS and in multi-window apps). + public init(prefersEphemeralSession: Bool = false, presentationAnchor: (() -> ASPresentationAnchor)? = nil) { + self.prefersEphemeralSession = prefersEphemeralSession + self.presentationAnchorProvider = presentationAnchor + } + + /// Shows the page and suspends until ``dismiss()`` or the user closes it. + /// - Parameter authorizationURL: Authorization URL. + /// - Throws: ``SignInWithChatGPTError/cancelled`` when the user closes the sheet. + public func present(_ authorizationURL: URL) async throws { + guard self.session == nil else { + throw OpenClawCoreError.unavailable("A Sign in with ChatGPT browser session is already open") + } + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.continuation = continuation + let session = ASWebAuthenticationSession(url: authorizationURL, callbackURLScheme: nil) { [weak self] _, error in + Task { @MainActor [weak self] in + self?.finish(error: error) + } + } + session.prefersEphemeralWebBrowserSession = self.prefersEphemeralSession + session.presentationContextProvider = self + self.session = session + if !session.start() { + self.session = nil + self.continuation = nil + continuation.resume(throwing: OpenClawCoreError.unavailable("Could not start the Sign in with ChatGPT browser session")) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.session?.cancel() + } + } + } + + /// Cancels the browser session after the callback arrived. + public func dismiss() async { + self.session?.cancel() + } + + /// Anchor for the authentication sheet. + /// - Parameter session: Requesting session. + /// - Returns: Presentation anchor. + public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + _ = session + if let anchor = self.presentationAnchorProvider?() { + return anchor + } + #if os(visionOS) + let windowType: NSObject.Type = ASPresentationAnchor.self + return unsafeDowncast(windowType.init(), to: ASPresentationAnchor.self) + #else + return ASPresentationAnchor() + #endif + } + + private func finish(error: (any Error)?) { + self.session = nil + guard let continuation = self.continuation else { return } + self.continuation = nil + if let error { + if let authError = error as? ASWebAuthenticationSessionError, authError.code == .canceledLogin { + continuation.resume(throwing: SignInWithChatGPTError.cancelled) + } else { + continuation.resume(throwing: error) + } + } else { + continuation.resume() + } + } +} +#endif diff --git a/Sources/OpenClawKit/OpenClawKit.docc/GettingStarted.md b/Sources/OpenClawKit/OpenClawKit.docc/GettingStarted.md index 076e53c..d434e69 100644 --- a/Sources/OpenClawKit/OpenClawKit.docc/GettingStarted.md +++ b/Sources/OpenClawKit/OpenClawKit.docc/GettingStarted.md @@ -7,7 +7,7 @@ unless you already know you need lower-level modules. ```swift dependencies: [ - .package(url: "https://github.com/MarcoDotIO/OpenClawKit.git", from: "2026.3.0") + .package(url: "https://github.com/MarcoDotIO/OpenClawKit.git", from: "2026.3.1") ] ``` diff --git a/Sources/OpenClawKit/OpenClawKit.docc/OpenClawKit.md b/Sources/OpenClawKit/OpenClawKit.docc/OpenClawKit.md index 47fbffe..21c7dcc 100644 --- a/Sources/OpenClawKit/OpenClawKit.docc/OpenClawKit.md +++ b/Sources/OpenClawKit/OpenClawKit.docc/OpenClawKit.md @@ -7,9 +7,10 @@ optional shared chat UI. ## Overview `OpenClawKit` packages the OpenClaw runtime surface into SwiftPM targets that can be -used together or independently. Release `2026.3.0` tracks upstream OpenClaw `v2026.9.6` -(gateway protocol v4) and adopts the Apple 27 frameworks behind availability gates, so -apps keep their iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 26 floors. +used together or independently. Release `2026.3.1` tracks upstream OpenClaw `v2026.9.6` +(gateway protocol v4), adopts the Apple 27 frameworks behind availability gates, so +apps keep their iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 26 floors, and adds + for inference on the user's ChatGPT plan. Most host apps start with ``OpenClawSDK`` and then drop to lower-level modules only when they need custom runtime, transport, or UI behavior. The docs are organized around the @@ -40,6 +41,7 @@ Upgrading from 2026.2.x? Start with . ### Agents and Models - +- - - - diff --git a/Sources/OpenClawKit/OpenClawKit.docc/SignInWithChatGPT.md b/Sources/OpenClawKit/OpenClawKit.docc/SignInWithChatGPT.md new file mode 100644 index 0000000..7c7b38a --- /dev/null +++ b/Sources/OpenClawKit/OpenClawKit.docc/SignInWithChatGPT.md @@ -0,0 +1,188 @@ +# Sign in with ChatGPT + +Let people sign in with their ChatGPT account and run eligible inference on their ChatGPT +plan instead of an API key. + +## Overview + +[Sign in with ChatGPT](https://developers.openai.com/siwc) (SIWC) is OpenAI's OAuth flow +for apps and agents. With the ChatGPT plan scopes granted, the access token calls the +Responses API on the user's plan: they pay nothing extra up to their plan's limits, and +you ship no API key. OpenClawKit 2026.3.1 implements the open-source "ChatGPT plan usage" +flow end to end: + +- ``SignInWithChatGPTSession`` (`OpenClawCore`, also on Linux) runs the browser sign-in, + validates ID tokens, stores accounts, refreshes and revokes tokens. +- ``ChatGPTPlanModelProvider`` (`OpenClawModels`) is a ``ModelProvider`` that sends plan + inference to `https://api.openai.com/v1/responses`. +- `OpenClawChatUI` adds the branded button, the one-time welcome, the "Using ChatGPT plan" + indicator and the "Usage limit reached" prompt, plus the `SignInWithChatGPTModel` + observable. + +SIWC is a separate OAuth client from the ChatGPT/Codex login used by the +`openai-chatgpt-responses` route (``OpenAIChatGPTOAuthConfiguration``). SIWC is in +preview at OpenAI; read [Preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations) +before shipping. + +## Sign in + +```swift +import OpenClawKit + +let session = SignInWithChatGPTSession( + configuration: SignInWithChatGPTClientConfiguration(agentName: "MyAgent"), + credentialStore: KeychainCredentialStore() +) + +// iOS, visionOS, macOS: an ASWebAuthenticationSession sheet that shares Safari's cookies. +let browser = SignInWithChatGPTWebAuthenticationBrowser(presentationAnchor: { window }) +// macOS alternative: the user's default browser. +// let browser = SignInWithChatGPTExternalBrowser.systemDefault + +let result = try await session.signIn(using: browser) +if result.shouldShowPlanWelcome { + // Present ChatGPTPlanWelcomeView once, then: + try await session.markPlanWelcomeSeen(subject: result.account.subject) +} +``` + +``SignInWithChatGPTSession/signIn(using:reauthenticating:consent:timeout:)`` starts a +one-shot listener on `http://127.0.0.1:1455/auth/callback` (another free port when 1455 +is busy; OpenAI only lets the port vary and rejects `localhost`), opens the authorization +page, waits for the callback, exchanges the code with PKCE (`S256`) and validates the +RS256 ID token (JWKS signature, issuer, audience, expiry and nonce). Callbacks with the +wrong `state` are answered with `400` and ignored, so another local process cannot finish +the sign-in for you. + +- **Registration.** The first sign-in of an account uses `client_id=dynamic_agent_client` + with `agent_name_hint` (your ``SignInWithChatGPTClientConfiguration/agentName``). The + callback returns the issued client id (`oaiapp_…`) that the account keeps from then on. +- **Re-authentication.** Pass `reauthenticating:` with a known account's subject: the saved + client id is reused with `id_token_hint` and `login_hint`. To re-enable plan usage after + the user turned it off, pass `consent: .forceReconsent` (or `.consent` for + `prompt=consent`). +- **Host identifier.** Every authorization carries `ext_agent_host_id`. The account store + persists a random `urn:uuid:` identifier before the first sign-in; apps that already hold + a stable device key can call ``SignInWithChatGPTAccountStore/setHostIdentifier(_:)`` + with `SignInWithChatGPTHostIdentifier(deviceIdentity:)` (an RFC 9278 JWK thumbprint of the + gateway device key) before signing in. +- **Custom callback handling.** ``SignInWithChatGPTSession/beginAuthorization(redirectURI:reauthenticating:consent:)`` + and ``SignInWithChatGPTSession/completeAuthorization(callbackURL:pending:)`` split the + flow for hosts that serve the redirect themselves. + +tvOS and watchOS have no browser, so the listener and `signIn(using:)` are not available +there; tokens and inference still work with a credential record imported from another +device. + +## Run inference on the plan + +```swift +let catalog = ChatGPTPlanModelProvider(tokenProvider: session) +let models = try await catalog.listModels() // `visibility == "list"` models, in server order + +let provider = ChatGPTPlanModelProvider(tokenProvider: session, defaultModelID: models.first?.slug) +let reply = try await provider.generate(ModelGenerationRequest(sessionKey: "main", prompt: "Hi")) +``` + +``SignInWithChatGPTSession`` conforms to ``ChatGPTPlanAccessTokenProvider``: it refreshes +access tokens five minutes before they expire (never before `earliest_refresh_at`), runs +one refresh per account at a time and stores rotated refresh tokens together with the new +access token. Use ``SignInWithChatGPTSession/tokenProvider(for:)`` to pin a provider to one +account when several are signed in. + +The provider shapes every request for plan usage: + +- `store: false` and `stream: true` always; only `response.completed` counts as success, and + a `response.failed` mid-stream throws. +- The system prompt becomes `instructions`; transcript system messages become `developer` + messages. +- Unsupported fields (`temperature`, `top_p`, `max_output_tokens`, `service_tier`, + `metadata`, `truncation`, `user`, `previous_response_id`, …) are never sent, whatever the + request policy says. +- Function tools are grouped in one `namespace` tool (``ChatGPTPlanModelProvider/Options/toolNamespace``, + default `openclaw`), and replayed function calls carry the namespace. A named tool choice + sends only that tool with `tool_choice: "required"`. +- A `401` refreshes the token once and retries. + +Hosted tools (image generation, file search, code interpreter, computer use, hosted MCP, +`tool_search`) and audio or video input are not available on plan usage; the server answers +with `subscription_sharing_unsupported_capability`. + +## Handle plan errors + +Plan errors are thrown as ``ChatGPTPlanError``, built from `subscription_sharing_*` and +`chatpass_v2_*` codes or from direct-admission `401`/`403`/`503` responses. +``ChatGPTPlanError/recovery`` says what to offer: + +| Recovery | Kinds | UI | +| --- | --- | --- | +| `manageUsage` | `usageLimitReached`, `notEligible` | "Usage limit reached" with "Manage usage" | +| `signInAgain` | `invalidUser`, `scopeNotAuthorized`, `invalidAuthorizationContext` | Sign in again; re-consent when ``ChatGPTPlanError/requiresPlanReconsent`` | +| `retryLater` | `usageUnavailable`, `userUnavailable` | Retry, honoring ``ChatGPTPlanError/retryAfter`` | +| `changeRequest` | `unsupportedCapability`, `routeNotSupported` | Remove the unsupported feature | + +Token errors are ``SignInWithChatGPTError`` values. A rejected refresh token (`invalid_grant`, +`refresh_token_expired`, `refresh_token_reused`, …) clears the account's tokens and throws +``SignInWithChatGPTError/reauthenticationRequired(subject:reason:)``; the account keeps its +client id so the next sign-in re-authenticates the same registration. `invalid_client` is a +configuration error (``SignInWithChatGPTError/invalidClient(_:)``). + +## Accounts, sign-out and storage + +- ``SignInWithChatGPTSession/accounts()`` lists every account on the host; + ``SignInWithChatGPTSession/setActiveAccount(subject:)`` picks the one used by default. + Each account has its own registration: client ids and tokens are never mixed. +- ``SignInWithChatGPTSession/signOut(subject:)`` revokes the refresh token (retrying network + errors and `5xx`), clears the tokens and keeps the client id and host identifier. + ``SignInWithChatGPTSession/removeAccount(subject:)`` forgets the account entirely. +- Credentials live in a ``CredentialStore``: use ``KeychainCredentialStore`` on Apple + platforms. ``FileCredentialStore`` writes `0600` files atomically (the file is created + with owner-only permissions before any secret is written) in `0700` directories. +- ``SignInWithChatGPTSession/exportCredentialRecord(subject:)`` and + ``SignInWithChatGPTSession/importCredentialRecord(_:)`` move a session in the documented + credential-record JSON (`client_id`, `access_token`, `refresh_token`, `id_token`, + `expires_in`, ISO 8601 `saved_at`, `ext_agent_host_id`, …), for example to a self-hosted + VM, which then refreshes the tokens itself. The record holds live tokens: transfer it only + over an encrypted channel. + +Refreshes are serialized within one session. Share a single session per credential store; +processes that share a store must coordinate refreshes themselves. + +## UI + +```swift +@State private var chatGPT = SignInWithChatGPTModel(session: session) { + SignInWithChatGPTWebAuthenticationBrowser(presentationAnchor: { window }) +} + +var body: some View { + VStack { + SignInWithChatGPTButton(.continueWithChatGPT, style: .black, logo: Image("ChatGPTLogo"), + isLoading: chatGPT.isSigningIn) { + Task { await chatGPT.signIn() } + } + if chatGPT.isUsingChatGPTPlan { + ChatGPTPlanUsageIndicator() + } + } + .chatGPTPlanWelcomeSheet(isPresented: $chatGPT.showsPlanWelcome) { + Task { await chatGPT.acknowledgePlanWelcome() } + } + .chatGPTPlanUsageLimitSheet(error: $chatGPT.planError) +} +``` + +The views follow OpenAI's [UI/UX guidelines](https://developers.openai.com/siwc/ui-ux-guidelines) +and use OpenAI's copy: "Continue with ChatGPT" / "Sign in with ChatGPT" on black or white, +"You're using your ChatGPT plan" (shown once per account), "Using ChatGPT plan · Manage +usage" near the composer, and "Usage limit reached" with "Manage usage" +(`https://chatgpt.com/settings/usage`) and an optional "Buy app credits". The SDK does not +ship OpenAI's logo: download it from OpenAI's brand assets and pass it as `logo`. + +## Testing + +The flow is covered offline with a scripted authorization server and RS256 fixtures +(`SignInWithChatGPTSessionTests`, `SignInWithChatGPTAuthorizationTests`, +`ChatGPTPlanModelProviderTests`), including a real loopback round trip, on macOS and on +Linux (where RS256 uses swift-crypto's `_CryptoExtras`). A live sign-in needs a person +in a browser, so there is no automated live test. diff --git a/Sources/OpenClawKit/OpenClawKit.docc/TestingAndValidation.md b/Sources/OpenClawKit/OpenClawKit.docc/TestingAndValidation.md index f786557..12d51c5 100644 --- a/Sources/OpenClawKit/OpenClawKit.docc/TestingAndValidation.md +++ b/Sources/OpenClawKit/OpenClawKit.docc/TestingAndValidation.md @@ -77,6 +77,11 @@ test process environment. A full provider pass is about 48 billed calls, well un $0.01 at catalog prices. Anthropic keys that are not scoped to a workspace also need `ANTHROPIC_WORKSPACE_ID`, which is sent as the `anthropic-workspace-id` header. +Sign in with ChatGPT has no live suite: signing in needs a person in a browser. Its +offline suites (`SignInWithChatGPTAuthorizationTests`, `SignInWithChatGPTSessionTests`, +`ChatGPTPlanModelProviderTests`) run on macOS and Linux against a scripted authorization +server, fixed RS256 fixtures and a real loopback round trip. + ## CI and Docs Publishing The CI workflow runs SwiftLint, the upstream drift checks, the Linux Swift 6.2 build and diff --git a/Sources/OpenClawKit/SignInWithChatGPT+Apple.swift b/Sources/OpenClawKit/SignInWithChatGPT+Apple.swift new file mode 100644 index 0000000..4226d41 --- /dev/null +++ b/Sources/OpenClawKit/SignInWithChatGPT+Apple.swift @@ -0,0 +1,30 @@ +import Foundation +#if os(macOS) +import AppKit +#endif + +public extension SignInWithChatGPTHostIdentifier { + /// RFC 9278 host identifier derived from a gateway device identity's Ed25519 public key. + /// + /// The device identity is already a stable per-install key, which makes it a good + /// `ext_agent_host_id` source; the thumbprint does not reveal the key or the user. + /// - Parameter identity: Device identity. + /// - Returns: The identifier, or `nil` when the stored public key is not a 32-byte Ed25519 key. + init?(deviceIdentity identity: DeviceIdentity) { + guard let key = Data(base64Encoded: identity.publicKey), let identifier = Self.jwkThumbprint(ed25519PublicKey: key) else { + return nil + } + self = identifier + } +} + +#if os(macOS) +public extension SignInWithChatGPTExternalBrowser { + /// Opens the authorization page in the user's default browser. + static var systemDefault: SignInWithChatGPTExternalBrowser { + SignInWithChatGPTExternalBrowser { url in + await MainActor.run { NSWorkspace.shared.open(url) } + } + } +} +#endif diff --git a/Sources/OpenClawModels/ChatGPTPlanModelProvider.swift b/Sources/OpenClawModels/ChatGPTPlanModelProvider.swift new file mode 100644 index 0000000..d4d91ee --- /dev/null +++ b/Sources/OpenClawModels/ChatGPTPlanModelProvider.swift @@ -0,0 +1,378 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import OpenClawCore +import OpenClawProtocol + +/// A model available to a Sign in with ChatGPT account (`GET /v1/models`). +public struct ChatGPTPlanModel: Sendable, Equatable, Codable, Identifiable { + /// Model id to send as `model`. + public let slug: String + /// Name to show in model pickers. + public let displayName: String + /// Server visibility (`"list"` models belong in pickers). + public let visibility: String? + + /// Creates a model entry. + /// - Parameters: + /// - slug: Model id. + /// - displayName: Display name. + /// - visibility: Visibility. + public init(slug: String, displayName: String, visibility: String?) { + self.slug = slug + self.displayName = displayName + self.visibility = visibility + } + + /// Identity (the slug). + public var id: String { + self.slug + } + + /// Whether the model should be shown in pickers (`visibility == "list"`). + public var isListed: Bool { + self.visibility == "list" + } + + enum CodingKeys: String, CodingKey { + case slug + case displayName = "display_name" + case visibility + } +} + +/// Model provider that runs inference on the user's ChatGPT plan with Sign in with ChatGPT tokens. +/// +/// Requests go to `POST https://api.openai.com/v1/responses` with the account's access token and are +/// shaped for plan usage: +/// - always `store: false` and `stream: true`; only `response.completed` counts as success; +/// - the system prompt goes to `instructions` and transcript system messages become `developer` +/// messages (system-role items are rejected); +/// - `temperature`, `top_p`, `max_output_tokens`, `service_tier`, `metadata`, `truncation`, `user`, +/// `previous_response_id` and the other unsupported fields are never sent; +/// - function tools are grouped in one `namespace` tool (``Options/toolNamespace``) and replayed +/// function calls carry that namespace; a named tool choice sends only that tool with +/// `tool_choice: "required"`. +/// +/// A `401` refreshes the token once through the `ChatGPTPlanAccessTokenProvider` and retries. +/// Plan errors (`subscription_sharing_*`, `chatpass_v2_*`, direct admission) are thrown as +/// `ChatGPTPlanError` — show the usage-limit UI when `ChatGPTPlanError.isUsageLimit` is set. +public struct ChatGPTPlanModelProvider: ModelProvider { + /// Default provider identifier. + public static let providerID = "chatgpt-plan" + /// Default namespace that holds function tools. + public static let defaultToolNamespace = "openclaw" + + /// Endpoint and tool-shaping options. + public struct Options: Sendable, Equatable { + /// Responses endpoint. + public var responsesURL: URL + /// Models endpoint. + public var modelsURL: URL + /// Namespace name that groups function tools. + public var toolNamespace: String + /// Namespace description shown to the model. + public var toolNamespaceDescription: String + + /// Creates options. + /// - Parameters: + /// - responsesURL: Responses endpoint. + /// - modelsURL: Models endpoint. + /// - toolNamespace: Namespace name for function tools. + /// - toolNamespaceDescription: Namespace description. + public init( + responsesURL: URL = SignInWithChatGPTConfiguration.responsesURL, + modelsURL: URL = SignInWithChatGPTConfiguration.modelsURL, + toolNamespace: String = ChatGPTPlanModelProvider.defaultToolNamespace, + toolNamespaceDescription: String = "Tools provided by the app." + ) { + self.responsesURL = responsesURL + self.modelsURL = modelsURL + self.toolNamespace = toolNamespace + self.toolNamespaceDescription = toolNamespaceDescription + } + } + + /// Provider identifier. + public let id: String + /// Model used when a request does not name one. + public let defaultModelID: String? + /// Options. + public let options: Options + private let tokenProvider: any ChatGPTPlanAccessTokenProvider + private let transport: any OpenAICompatibleHTTPTransport & ModelHTTPStreamingTransport + + /// Creates a provider. + /// - Parameters: + /// - id: Provider identifier. + /// - tokenProvider: Source of plan access tokens (for example a `SignInWithChatGPTSession`). + /// - defaultModelID: Model used when a request does not name one (pick from ``listModels(includeHidden:)``). + /// - options: Endpoint and tool options. + /// - transport: HTTP transport. + public init( + id: String = ChatGPTPlanModelProvider.providerID, + tokenProvider: any ChatGPTPlanAccessTokenProvider, + defaultModelID: String? = nil, + options: Options = Options(), + transport: any OpenAICompatibleHTTPTransport & ModelHTTPStreamingTransport = ModelStreamingHTTPClient() + ) { + self.id = id + self.tokenProvider = tokenProvider + self.defaultModelID = defaultModelID + self.options = options + self.transport = transport + } + + /// Streaming, namespaced tools, JSON schema, images, reasoning and transcripts. + public var capabilities: ModelProviderCapabilities { + ModelProviderCapabilities( + supportsStreaming: true, + supportsTools: true, + supportsParallelToolCalls: true, + supportsJSONSchema: true, + supportsImages: true, + supportsReasoning: true, + supportsTranscript: true + ) + } + + /// Generates a response (collected from the stream). + /// - Parameter request: Generation request. + /// - Returns: Generated response. + public func generate(_ request: ModelGenerationRequest) async throws -> ModelGenerationResponse { + let modelID = try self.modelID(for: request) + let response = try await ProviderStreamSupport.collect(self.stream(request, modelID: modelID), providerID: self.id, modelID: modelID) + guard !response.text.isEmpty || !response.toolCalls.isEmpty || response.stopReason.permitsEmptyOutput else { + throw OpenClawCoreError.unavailable("\(self.id) response did not include text output") + } + return response + } + + /// Streams a response. + /// - Parameter request: Generation request. + /// - Returns: Chunk stream ending with a `.final` chunk. + public func generateStream(_ request: ModelGenerationRequest) async -> AsyncThrowingStream { + do { + return self.stream(request, modelID: try self.modelID(for: request)) + } catch { + return AsyncThrowingStream { $0.finish(throwing: error) } + } + } + + /// Models available to the signed-in account, in server order. + /// - Parameter includeHidden: Include models whose visibility is not `"list"`. + /// - Returns: Models. + public func listModels(includeHidden: Bool = false) async throws -> [ChatGPTPlanModel] { + let response = try await self.authorizedData { token in + var request = URLRequest(url: self.options.modelsURL) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + guard let object = (try? JSONSerialization.jsonObject(with: response.body)) as? [String: Any] else { + throw OpenClawCoreError.unavailable("\(self.id) model list is not valid JSON") + } + let entries = (object["models"] as? [[String: Any]]) ?? (object["data"] as? [[String: Any]]) ?? [] + let models = entries.compactMap { entry -> ChatGPTPlanModel? in + guard let slug = (entry["slug"] as? String) ?? (entry["id"] as? String), !slug.isEmpty else { return nil } + return ChatGPTPlanModel(slug: slug, displayName: (entry["display_name"] as? String) ?? slug, visibility: entry["visibility"] as? String) + } + return includeHidden ? models : models.filter(\.isListed) + } + + // MARK: - Request + + private func modelID(for request: ModelGenerationRequest) throws -> String { + guard let modelID = ModelGenerationRequest.normalized(request.modelID) ?? ModelGenerationRequest.normalized(self.defaultModelID) else { + throw OpenClawCoreError.invalidConfiguration("\(self.id) needs a model id; pick one from listModels()") + } + return modelID + } + + func makeURLRequest(body: Data, token: String, request: ModelGenerationRequest) -> URLRequest { + var urlRequest = URLRequest(url: self.options.responsesURL) + urlRequest.httpMethod = "POST" + for (name, value) in request.headers where !Self.reservedHeaders.contains(name.lowercased()) { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept") + urlRequest.httpBody = body + return urlRequest + } + + private static let reservedHeaders: Set = ["authorization", "content-type", "accept", "content-length", "host"] + + private func stream(_ request: ModelGenerationRequest, modelID: String) -> AsyncThrowingStream { + let providerID = self.id + let body: Data + do { + body = try ProviderWireJSON.encode( + ChatGPTPlanResponsesWire.buildPayload(request: request, modelID: modelID, providerID: providerID, options: self.options) + ) + } catch { + return AsyncThrowingStream { $0.finish(throwing: error) } + } + let transport = self.transport + let tokenProvider = self.tokenProvider + return ProviderStreamSupport.makeStream { continuation in + var token = try await tokenProvider.chatGPTPlanAccessToken(rejectedAccessToken: nil) + var response = try await transport.lineStream(for: self.makeURLRequest(body: body, token: token, request: request)) + if response.statusCode == 401 { + let errorBody = try await Self.collect(response.lines) + if let planError = ChatGPTPlanError.classify(statusCode: 401, body: errorBody, headers: response.headers), planError.kind != .admissionDenied { + throw planError + } + token = try await tokenProvider.chatGPTPlanAccessToken(rejectedAccessToken: token) + response = try await transport.lineStream(for: self.makeURLRequest(body: body, token: token, request: request)) + } + guard (200..<300).contains(response.statusCode) else { + let errorBody = try await Self.collect(response.lines) + throw ChatGPTPlanError.classify(statusCode: response.statusCode, body: errorBody, headers: response.headers) + ?? ProviderHTTPExchange.statusError(providerID: providerID, statusCode: response.statusCode, body: errorBody) + } + var assembler = ProviderStreamAssembler(providerID: providerID, modelID: modelID) + var parser = ServerSentEventParser() + var state = OpenAIResponsesWire.StreamState() + for try await line in response.lines { + guard let event = parser.consume(line) else { continue } + OpenAIResponsesWire.handleStreamEvent(event, state: &state, assembler: &assembler).forEach { continuation.yield($0) } + } + if let event = parser.finish() { + OpenAIResponsesWire.handleStreamEvent(event, state: &state, assembler: &assembler).forEach { continuation.yield($0) } + } + if let failure = state.failure { + throw ChatGPTPlanError.classify(code: state.failureCode, message: failure) + ?? OpenClawCoreError.unavailable("\(providerID) response failed: \(failure)") + } + guard state.sawEvent else { + throw OpenClawCoreError.unavailable("\(providerID) stream ended before a terminal response event") + } + if let incomplete = state.incompleteReason() { + throw OpenClawCoreError.unavailable("\(providerID) \(incomplete)") + } + continuation.yield(.completed(response: assembler.response())) + } + } + + private func authorizedData(_ makeRequest: (String) -> URLRequest) async throws -> HTTPResponseData { + var token = try await self.tokenProvider.chatGPTPlanAccessToken(rejectedAccessToken: nil) + var response = try await self.send(makeRequest(token)) + if response.statusCode == 401, ChatGPTPlanError.classify(statusCode: 401, body: response.body).map({ $0.kind == .admissionDenied }) ?? true { + token = try await self.tokenProvider.chatGPTPlanAccessToken(rejectedAccessToken: token) + response = try await self.send(makeRequest(token)) + } + guard (200..<300).contains(response.statusCode) else { + throw ChatGPTPlanError.classify(statusCode: response.statusCode, body: response.body, headers: response.headers) + ?? ProviderHTTPExchange.statusError(providerID: self.id, statusCode: response.statusCode, body: response.body) + } + return response + } + + private func send(_ request: URLRequest) async throws -> HTTPResponseData { + do { + return try await self.transport.data(for: request) + } catch { + throw ProviderErrorRedaction.sanitize(error) + } + } + + private static func collect(_ lines: AsyncThrowingStream) async throws -> Data { + var text = "" + for try await line in lines where text.utf8.count < 65_536 { + text += line + "\n" + } + return Data(text.utf8) + } +} + +/// Request shaping for ChatGPT plan inference. +enum ChatGPTPlanResponsesWire { + static let platformBaseURL = "https://api.openai.com/v1" + + static func buildPayload( + request: ModelGenerationRequest, + modelID: String, + providerID: String, + options: ChatGPTPlanModelProvider.Options + ) -> [String: Any] { + let context = OpenAIResponsesWire.BuildContext( + providerID: providerID, + modelID: modelID, + model: nil, + api: .openAIResponses, + baseURL: self.platformBaseURL, + policy: OpenAIResponsesPayloadPolicy.resolve(providerID: "openai", api: .openAIResponses, baseURL: self.platformBaseURL, compat: nil), + stream: true, + serviceTier: nil, + maxTokens: nil, + isChatGPTRoute: false, + supportsDeveloperRole: true + ) + var tools = request.tools + var toolChoice: Any? + switch request.toolChoice { + case .auto: + toolChoice = nil + case .none: + toolChoice = "none" + case .required: + toolChoice = "required" + case .named(let name): + // A namespaced tool cannot be named in `tool_choice`; offer only that tool and require a call. + tools = tools.filter { $0.name == name } + toolChoice = tools.isEmpty ? nil : "required" + } + let namespace = options.toolNamespace + let input = OpenAIResponsesWire.buildInput(request: request, context: context).map { item -> [String: Any] in + guard item["type"] as? String == "function_call" else { return item } + var item = item + item["namespace"] = namespace + return item + } + var payload: [String: Any] = [ + "model": modelID, + "input": input, + "store": false, + "stream": true, + "prompt_cache_key": request.promptCacheKey, + ] + if let instructions = ModelGenerationRequest.normalized(request.systemPrompt) { + payload["instructions"] = instructions + } + let functions = OpenAIResponsesWire.buildTools(tools, strictDefault: nil) + if !functions.isEmpty { + payload["tools"] = [[ + "type": "namespace", + "name": namespace, + "description": options.toolNamespaceDescription, + "tools": functions, + ] as [String: Any]] + if let toolChoice { + payload["tool_choice"] = toolChoice + } + } + if let format = OpenAIResponsesWire.textFormat(request.responseFormat) { + payload["text"] = ["format": format] + } + if let effort = OpenAIResponsesWire.reasoningEffort(request: request, context: context, tools: tools) { + var reasoning: [String: Any] = ["effort": effort] + if effort != "none" { + reasoning["summary"] = "auto" + } + payload["reasoning"] = reasoning + } + return payload + } + + /// Top-level fields ChatGPT plan inference rejects; ``buildPayload(request:modelID:providerID:options:)`` + /// never sets them. + static let forbiddenFields: Set = [ + "background", "conversation", "max_output_tokens", "max_tool_calls", "metadata", "moderation", + "multi_agent", "prompt", "prompt_cache_retention", "safety_identifier", "temperature", "top_logprobs", + "top_p", "truncation", "user", "previous_response_id", "service_tier", + ] +} diff --git a/Sources/OpenClawModels/OpenAIResponsesWire.swift b/Sources/OpenClawModels/OpenAIResponsesWire.swift index 4b3e155..89d4ac2 100644 --- a/Sources/OpenClawModels/OpenAIResponsesWire.swift +++ b/Sources/OpenClawModels/OpenAIResponsesWire.swift @@ -484,6 +484,9 @@ enum OpenAIResponsesWire { var toolIndexByItemID: [String: Int] = [:] var sawEvent = false var failure: String? + /// Structured error code of a `response.failed` / `error` event (for example + /// `subscription_sharing_usage_limit_exceeded`). + var failureCode: String? /// Set by `response.completed` / `incomplete` / `failed` / `done`. var terminalEvent: String? /// Function-call tool indexes whose arguments have not been finalized yet. @@ -582,10 +585,12 @@ enum OpenAIResponsesWire { } if type == "response.failed" { state.failure = response[wireKey: "error"]?.wireString("message") ?? "response failed" + state.failureCode = response[wireKey: "error"]?.wireString("code") } } case "error": state.failure = payload.wireString("message") ?? payload[wireKey: "error"]?.wireString("message") ?? "stream error" + state.failureCode = payload.wireString("code") ?? payload[wireKey: "error"]?.wireString("code") default: break } diff --git a/Tests/OpenClawKitTests/SignInWithChatGPTAppleTests.swift b/Tests/OpenClawKitTests/SignInWithChatGPTAppleTests.swift new file mode 100644 index 0000000..fb373f3 --- /dev/null +++ b/Tests/OpenClawKitTests/SignInWithChatGPTAppleTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import OpenClawChatUI +@testable import OpenClawCore +@testable import OpenClawKit + +private actor SIWCMemoryStore: CredentialStore { + private var values: [String: String] = [:] + + func saveSecret(_ value: String, for key: String) async throws { + self.values[key] = value + } + + func loadSecret(for key: String) async throws -> String? { + self.values[key] + } + + func deleteSecret(for key: String) async throws { + self.values[key] = nil + } +} + +@Suite("Sign in with ChatGPT on Apple platforms") +struct SignInWithChatGPTAppleTests { + @Test("File credential stores write 0600 files in 0700 directories without temp leftovers") + func privateCredentialFile() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("openclawkit-tests", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appendingPathComponent("state", isDirectory: true) + let fileURL = directory.appendingPathComponent("credentials.json") + let store = FileCredentialStore(fileURL: fileURL) + try await store.saveSecret("refresh-token", for: "openclaw.siwc.credential.x") + try await store.saveSecret("refresh-token-2", for: "openclaw.siwc.credential.x") + + let fileMode = try #require(FileManager.default.attributesOfItem(atPath: fileURL.path)[.posixPermissions] as? NSNumber) + #expect(fileMode.intValue & 0o777 == 0o600) + let directoryMode = try #require(FileManager.default.attributesOfItem(atPath: directory.path)[.posixPermissions] as? NSNumber) + #expect(directoryMode.intValue & 0o777 == 0o700) + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path) == ["credentials.json"]) + #expect(try await store.loadSecret(for: "openclaw.siwc.credential.x") == "refresh-token-2") + } + + @Test("A device identity yields a stable RFC 9278 host identifier") + func deviceIdentityHostIdentifier() throws { + let key = Data((0..<32).map { UInt8($0) }) + let identity = DeviceIdentity(deviceId: "device", publicKey: key.base64EncodedString(), privateKey: "", createdAtMs: 0) + let identifier = try #require(SignInWithChatGPTHostIdentifier(deviceIdentity: identity)) + #expect(identifier == SignInWithChatGPTHostIdentifier.jwkThumbprint(ed25519PublicKey: key)) + #expect(identifier.rawValue.hasPrefix("urn:ietf:params:oauth:jwk-thumbprint:sha-256:")) + #expect(!identifier.rawValue.contains("device")) + let broken = DeviceIdentity(deviceId: "device", publicKey: "bm90LWEta2V5", privateKey: "", createdAtMs: 0) + #expect(SignInWithChatGPTHostIdentifier(deviceIdentity: broken) == nil) + } + + #if os(iOS) || os(macOS) || os(visionOS) + @MainActor + @Test("The SwiftUI model routes usage limits to the usage-limit sheet") + func modelRoutesErrors() async throws { + let session = SignInWithChatGPTSession( + configuration: SignInWithChatGPTClientConfiguration(agentName: "TestAgent"), + credentialStore: SIWCMemoryStore() + ) + let model = SignInWithChatGPTModel(session: session) { SignInWithChatGPTExternalBrowser { _ in false } } + await model.reload() + #expect(model.accounts.isEmpty) + #expect(model.activeAccount == nil) + #expect(!model.isUsingChatGPTPlan) + + model.handle(ChatGPTPlanError(kind: .usageLimitReached, statusCode: 429)) + #expect(model.planError?.isUsageLimit == true) + #expect(model.errorMessage == nil) + + model.handle(ChatGPTPlanError(kind: .usageUnavailable, statusCode: 503)) + #expect(model.errorMessage?.contains("temporarily unavailable") == true) + + model.handle(SignInWithChatGPTError.notSignedIn) + #expect(model.errorMessage == SignInWithChatGPTError.notSignedIn.localizedDescription) + + await model.signIn() + #expect(model.errorMessage?.contains("Could not open the browser") == true) + #expect(!model.isSigningIn) + #expect(!model.showsPlanWelcome) + + #expect(SignInWithChatGPTButton.Label.continueWithChatGPT.title == "Continue with ChatGPT") + #expect(SignInWithChatGPTButton.Label.signInWithChatGPT.title == "Sign in with ChatGPT") + #expect(ChatGPTPlanLinks.manageUsage.absoluteString == "https://chatgpt.com/settings/usage") + _ = SignInWithChatGPTButton(style: .white) {}.body + _ = ChatGPTPlanWelcomeView {}.body + _ = ChatGPTPlanUsageIndicator().body + _ = ChatGPTPlanUsageLimitView(layout: .compact, onBuyAppCredits: {}).body + } + #endif +} diff --git a/Tests/OpenClawLinuxRuntimeTests/ChatGPTPlanModelProviderTests.swift b/Tests/OpenClawLinuxRuntimeTests/ChatGPTPlanModelProviderTests.swift new file mode 100644 index 0000000..7b4af14 --- /dev/null +++ b/Tests/OpenClawLinuxRuntimeTests/ChatGPTPlanModelProviderTests.swift @@ -0,0 +1,374 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import OpenClawCore +@testable import OpenClawModels +import OpenClawProtocol + +@Suite("ChatGPT plan errors") +struct ChatGPTPlanErrorTests { + @Test("Structured subscription-sharing codes map to recoveries") + func structuredCodes() throws { + let limit = try #require(ChatGPTPlanError.classify( + statusCode: 429, + body: Data(#"{"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"Limit reached"}}"#.utf8), + headers: ["Retry-After": "120"] + )) + #expect(limit.kind == .usageLimitReached) + #expect(limit.isUsageLimit) + #expect(limit.recovery == .manageUsage) + #expect(limit.retryAfter == 120) + #expect(limit.message == "Limit reached") + + let expectations: [(String, Int, ChatGPTPlanError.Recovery)] = [ + ("subscription_sharing_user_not_eligible", 403, .manageUsage), + ("subscription_sharing_usage_unavailable", 503, .retryLater), + ("subscription_sharing_unsupported_capability", 400, .changeRequest), + ("subscription_sharing_route_not_supported", 403, .changeRequest), + ("subscription_sharing_invalid_user", 401, .signInAgain), + ("chatpass_v2_scope_not_authorized", 403, .signInAgain), + ("chatpass_v2_invalid_authorization_context", 403, .signInAgain), + ("subscription_sharing_user_unavailable", 503, .retryLater), + ] + for (code, status, recovery) in expectations { + let error = try #require(ChatGPTPlanError.classify(statusCode: status, body: Data(#"{"code":"\#(code)"}"#.utf8))) + #expect(error.code == code) + #expect(error.recovery == recovery, "\(code)") + } + #expect(ChatGPTPlanError.classify(code: "chatpass_v2_scope_not_authorized")?.requiresPlanReconsent == true) + } + + @Test("Direct-admission detail bodies and unrelated errors") + func directAdmission() throws { + let denied = try #require(ChatGPTPlanError.classify(statusCode: 403, body: Data(#"{"detail":"Not admitted"}"#.utf8))) + #expect(denied.kind == .admissionDenied) + #expect(denied.code == nil) + #expect(denied.recovery == .manageUsage) + #expect(ChatGPTPlanError.classify(statusCode: 401, body: Data(#"{"detail":{"message":"expired"}}"#.utf8))?.recovery == .signInAgain) + #expect(ChatGPTPlanError.classify(statusCode: 503, body: Data(#"{"detail":"busy"}"#.utf8))?.recovery == .retryLater) + #expect(ChatGPTPlanError.classify(statusCode: 500, body: Data(#"{"detail":"boom"}"#.utf8)) == nil) + #expect(ChatGPTPlanError.classify(statusCode: 400, body: Data(#"{"error":{"code":"invalid_request_error"}}"#.utf8)) == nil) + #expect(ChatGPTPlanError.classify(statusCode: 200, body: Data()) == nil) + #expect(ChatGPTPlanError.classify(code: "direct_admission") == nil) + let long = String(repeating: "x", count: 900) + #expect((ChatGPTPlanError.classify(code: "subscription_sharing_usage_unavailable", message: long)?.message?.count ?? 0) <= 501) + } +} + +/// Scripted Responses endpoint for the plan provider. +actor PlanResponsesStub: OpenAICompatibleHTTPTransport, ModelHTTPStreamingTransport { + struct Reply { + var status: Int + var lines: [String] + var headers: [String: String] = [:] + } + + private var replies: [Reply] + private(set) var requests: [URLRequest] = [] + private var dataReplies: [HTTPResponseData] + + init(replies: [Reply] = [], dataReplies: [HTTPResponseData] = []) { + self.replies = replies + self.dataReplies = dataReplies + } + + func data(for request: URLRequest) async throws -> HTTPResponseData { + self.requests.append(request) + return self.dataReplies.isEmpty ? HTTPResponseData(statusCode: 500, headers: [:], body: Data()) : self.dataReplies.removeFirst() + } + + func lineStream(for request: URLRequest) async throws -> ModelHTTPLineStream { + self.requests.append(request) + let reply = self.replies.isEmpty ? Reply(status: 500, lines: []) : self.replies.removeFirst() + return ModelHTTPLineStream(statusCode: reply.status, headers: reply.headers, lines: AsyncThrowingStream { continuation in + for line in reply.lines { + continuation.yield(line) + } + continuation.finish() + }) + } + + func bodies() -> [Data] { + self.requests.compactMap(\.httpBody) + } +} + +/// Token provider that hands out `token-1`, then `token-2` after a rejection. +actor PlanTokenStub: ChatGPTPlanAccessTokenProvider { + private(set) var rejected: [String] = [] + private var current = 1 + + func chatGPTPlanAccessToken(rejectedAccessToken: String?) async throws -> String { + if let rejectedAccessToken { + self.rejected.append(rejectedAccessToken) + self.current += 1 + } + return "token-\(self.current)" + } +} + +@Suite("ChatGPT plan model provider") +struct ChatGPTPlanModelProviderTests { + static func payloads(_ bodies: [Data]) -> [[String: Any]] { + bodies.compactMap { (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any] } + } + + static func sse(_ events: [[String: Any]]) -> [String] { + events.flatMap { event -> [String] in + let data = (try? JSONSerialization.data(withJSONObject: event)).map { String(decoding: $0, as: UTF8.self) } ?? "{}" + return ["event: \(event["type"] as? String ?? "")", "data: \(data)", ""] + } + } + + static let completedText = sse([ + ["type": "response.created", "response": ["model": "gpt-plan"]], + ["type": "response.output_text.delta", "delta": "Hello"], + ["type": "response.output_text.delta", "delta": " there"], + [ + "type": "response.completed", + "response": ["model": "gpt-plan", "status": "completed", "usage": ["input_tokens": 5, "output_tokens": 2, "total_tokens": 7]], + ], + ]) + + private func tools() -> [ModelToolDefinition] { + [ + ModelToolDefinition(name: "add", description: "Add numbers", parameters: ["type": AnyCodable("object")]), + ModelToolDefinition(name: "lookup", description: "Look up", parameters: ["type": AnyCodable("object")]), + ] + } + + @Test("Requests are shaped for plan usage") + func requestShape() async throws { + let transport = PlanResponsesStub(replies: [.init(status: 200, lines: Self.completedText)]) + let tokens = PlanTokenStub() + let provider = ChatGPTPlanModelProvider(tokenProvider: tokens, defaultModelID: "gpt-plan", transport: transport) + let request = ModelGenerationRequest( + sessionKey: "agent:main:direct:alice", + prompt: "", + systemPrompt: "Be brief.", + headers: ["X-Trace": "1", "Authorization": "Bearer spoofed"], + policy: ModelGenerationPolicy(maxTokens: 64, temperature: 0.2, topP: 0.9, storeResponse: true), + messages: [ + .system(content: [.text("Extra system rule")]), + .user(content: [.text("What is 2+3?")]), + .assistant(content: [.toolCall(ModelToolCall(id: "call_1", name: "add", argumentsJSON: #"{"a":2,"b":3}"#))]), + .toolResult(ModelToolResult(toolCallID: "call_1", toolName: "add", content: [.text("5")])), + ], + tools: self.tools() + ) + let response = try await provider.generate(request) + #expect(response.text == "Hello there") + #expect(response.modelID == "gpt-plan") + #expect(response.usage?.totalTokens == 7) + + let urlRequest = try #require(await transport.requests.first) + #expect(urlRequest.url?.absoluteString == "https://api.openai.com/v1/responses") + #expect(urlRequest.value(forHTTPHeaderField: "Authorization") == "Bearer token-1") + #expect(urlRequest.value(forHTTPHeaderField: "Accept") == "text/event-stream") + #expect(urlRequest.value(forHTTPHeaderField: "X-Trace") == "1") + + let payload = try #require(Self.payloads(await transport.bodies()).first) + #expect(payload["model"] as? String == "gpt-plan") + #expect(payload["store"] as? Bool == false) + #expect(payload["stream"] as? Bool == true) + #expect(payload["instructions"] as? String == "Be brief.") + #expect(Set(payload.keys).isDisjoint(with: ChatGPTPlanResponsesWire.forbiddenFields)) + let promptCacheKey = try #require(payload["prompt_cache_key"] as? String) + #expect(!promptCacheKey.contains("alice")) + + let input = try #require(payload["input"] as? [[String: Any]]) + #expect(!input.contains { $0["role"] as? String == "system" }) + #expect(input.first?["role"] as? String == "developer") + let call = try #require(input.first { $0["type"] as? String == "function_call" }) + #expect(call["namespace"] as? String == "openclaw") + #expect(call["name"] as? String == "add") + #expect(input.contains { $0["type"] as? String == "function_call_output" && $0["call_id"] as? String == "call_1" }) + + let tools = try #require(payload["tools"] as? [[String: Any]]) + #expect(tools.count == 1) + #expect(tools[0]["type"] as? String == "namespace") + #expect(tools[0]["name"] as? String == "openclaw") + let functions = try #require(tools[0]["tools"] as? [[String: Any]]) + #expect(functions.map { $0["name"] as? String } == ["add", "lookup"]) + #expect(functions.allSatisfy { $0["type"] as? String == "function" }) + #expect(payload["tool_choice"] == nil) + } + + @Test("Named tool choice sends only that tool with tool_choice required") + func namedToolChoice() async throws { + let transport = PlanResponsesStub(replies: [.init(status: 200, lines: Self.sse([ + [ + "type": "response.output_item.added", + "output_index": 0, + "item": ["type": "function_call", "id": "fc_1", "call_id": "call_9", "namespace": "openclaw", "name": "lookup", "arguments": ""], + ], + ["type": "response.function_call_arguments.delta", "output_index": 0, "delta": #"{"q":"x"}"#], + [ + "type": "response.output_item.done", + "output_index": 0, + "item": [ + "type": "function_call", "id": "fc_1", "call_id": "call_9", "namespace": "openclaw", "name": "lookup", + "arguments": #"{"q":"x"}"#, "status": "completed", + ], + ], + ["type": "response.completed", "response": ["status": "completed"]], + ]))]) + let options = ChatGPTPlanModelProvider.Options(toolNamespace: "agent_tools", toolNamespaceDescription: "Agent tools") + let provider = ChatGPTPlanModelProvider(tokenProvider: PlanTokenStub(), defaultModelID: "gpt-plan", options: options, transport: transport) + let response = try await provider.generate(ModelGenerationRequest(sessionKey: "s", prompt: "Find x", tools: self.tools(), toolChoice: .named("lookup"))) + #expect(response.toolCalls.map(\.name) == ["lookup"]) + #expect(response.toolCalls.first?.id == "call_9") + #expect(response.toolCalls.first?.argumentsJSON == #"{"q":"x"}"#) + let payload = try #require(Self.payloads(await transport.bodies()).first) + #expect(payload["tool_choice"] as? String == "required") + let namespace = try #require((payload["tools"] as? [[String: Any]])?.first) + #expect(namespace["name"] as? String == "agent_tools") + #expect(namespace["description"] as? String == "Agent tools") + #expect((namespace["tools"] as? [[String: Any]])?.map { $0["name"] as? String } == ["lookup"]) + } + + @Test("A 401 refreshes the token once and retries") + func unauthorizedRetry() async throws { + let transport = PlanResponsesStub(replies: [ + .init(status: 401, lines: [#"{"detail":"token expired"}"#]), + .init(status: 200, lines: Self.completedText), + ]) + let tokens = PlanTokenStub() + let provider = ChatGPTPlanModelProvider(tokenProvider: tokens, defaultModelID: "gpt-plan", transport: transport) + let response = try await provider.generate(ModelGenerationRequest(sessionKey: "s", prompt: "Hi")) + #expect(response.text == "Hello there") + #expect(await tokens.rejected == ["token-1"]) + #expect(await transport.requests.last?.value(forHTTPHeaderField: "Authorization") == "Bearer token-2") + + let structured = PlanResponsesStub(replies: [.init(status: 401, lines: [#"{"error":{"code":"subscription_sharing_invalid_user"}}"#])]) + let structuredProvider = ChatGPTPlanModelProvider(tokenProvider: PlanTokenStub(), defaultModelID: "gpt-plan", transport: structured) + await #expect(throws: ChatGPTPlanError(kind: .invalidUser, statusCode: 401)) { + try await structuredProvider.generate(ModelGenerationRequest(sessionKey: "s", prompt: "Hi")) + } + #expect(await structured.requests.count == 1) + } + + @Test("Usage-limit responses and response.failed events throw ChatGPTPlanError") + func planErrors() async throws { + let limited = PlanResponsesStub(replies: [.init( + status: 429, + lines: [#"{"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"Plan limit reached"}}"#], + headers: ["retry-after": "30"] + )]) + let provider = ChatGPTPlanModelProvider(tokenProvider: PlanTokenStub(), defaultModelID: "gpt-plan", transport: limited) + do { + _ = try await provider.generate(ModelGenerationRequest(sessionKey: "s", prompt: "Hi")) + Issue.record("expected a usage-limit error") + } catch let error as ChatGPTPlanError { + #expect(error.isUsageLimit) + #expect(error.statusCode == 429) + #expect(error.retryAfter == 30) + } + + let failed = PlanResponsesStub(replies: [.init(status: 200, lines: Self.sse([ + ["type": "response.output_text.delta", "delta": "Partial"], + ["type": "response.failed", "response": ["status": "failed", "error": ["code": "subscription_sharing_usage_limit_exceeded", "message": "limit"]]], + ]))]) + let streaming = ChatGPTPlanModelProvider(tokenProvider: PlanTokenStub(), defaultModelID: "gpt-plan", transport: failed) + var chunks: [ModelStreamChunk] = [] + do { + for try await chunk in await streaming.generateStream(ModelGenerationRequest(sessionKey: "s", prompt: "Hi")) { + chunks.append(chunk) + } + Issue.record("expected response.failed to throw") + } catch let error as ChatGPTPlanError { + #expect(error.kind == .usageLimitReached) + } + #expect(chunks.map(\.text) == ["Partial"]) + + let truncated = PlanResponsesStub(replies: [.init(status: 200, lines: Self.sse([["type": "response.output_text.delta", "delta": "Hi"]]))]) + let incomplete = ChatGPTPlanModelProvider(tokenProvider: PlanTokenStub(), defaultModelID: "gpt-plan", transport: truncated) + await #expect(throws: OpenClawCoreError.self) { + try await incomplete.generate(ModelGenerationRequest(sessionKey: "s", prompt: "Hi")) + } + + let noModel = ChatGPTPlanModelProvider(tokenProvider: PlanTokenStub(), transport: PlanResponsesStub()) + await #expect(throws: OpenClawCoreError.self) { + try await noModel.generate(ModelGenerationRequest(sessionKey: "s", prompt: "Hi")) + } + } + + @Test("Model lists keep server order and hide non-list models") + func listModels() async throws { + let body = #"{"models":[{"slug":"gpt-a","display_name":"GPT A","visibility":"list"},"# + + #"{"slug":"gpt-hidden","display_name":"Hidden","visibility":"hide"},{"slug":"gpt-b","display_name":"GPT B","visibility":"list"}]}"# + let transport = PlanResponsesStub(dataReplies: [ + HTTPResponseData(statusCode: 401, headers: [:], body: Data(#"{"detail":"expired"}"#.utf8)), + HTTPResponseData(statusCode: 200, headers: [:], body: Data(body.utf8)), + HTTPResponseData(statusCode: 200, headers: [:], body: Data(body.utf8)), + ]) + let tokens = PlanTokenStub() + let provider = ChatGPTPlanModelProvider(tokenProvider: tokens, transport: transport) + let models = try await provider.listModels() + #expect(models.map(\.slug) == ["gpt-a", "gpt-b"]) + #expect(models.map(\.displayName) == ["GPT A", "GPT B"]) + #expect(await tokens.rejected == ["token-1"]) + #expect(await transport.requests.first?.url?.absoluteString == "https://api.openai.com/v1/models") + #expect(try await provider.listModels(includeHidden: true).count == 3) + } +} + +#if !os(tvOS) && !os(watchOS) +@Suite("Sign in with ChatGPT loopback listener") +struct SignInWithChatGPTLoopbackListenerTests { + @Test("The listener binds 127.0.0.1, falls back when the port is busy and ignores other paths") + func listener() async throws { + let first = try SignInWithChatGPTLoopbackListener.start(port: 0) + defer { first.stop() } + #expect(first.port > 0) + #expect(first.redirectURI.absoluteString == "http://127.0.0.1:\(first.port)/auth/callback") + + let fallback = try SignInWithChatGPTLoopbackListener.start(port: first.port, allowsFallback: true) + defer { fallback.stop() } + #expect(fallback.port != first.port) + #expect(throws: SignInWithChatGPTError.self) { + try SignInWithChatGPTLoopbackListener.start(port: first.port, allowsFallback: false) + } + + first.expect(state: "s1") + let (_, notFound) = try await URLSession.shared.data(from: URL(string: "http://127.0.0.1:\(first.port)/favicon.ico")!) + #expect((notFound as? HTTPURLResponse)?.statusCode == 404) + let (_, wrongState) = try await URLSession.shared.data(from: URL(string: "http://127.0.0.1:\(first.port)/auth/callback?state=s2&code=c")!) + #expect((wrongState as? HTTPURLResponse)?.statusCode == 400) + + async let callback = first.waitForCallback() + let (page, ok) = try await URLSession.shared.data(from: URL(string: "http://127.0.0.1:\(first.port)/auth/callback?state=s1&error=access_denied")!) + #expect((ok as? HTTPURLResponse)?.statusCode == 200) + #expect(String(decoding: page, as: UTF8.self).contains("didn")) + let url = try await callback + #expect(url.query?.contains("error=access_denied") == true) + // A delivered callback is returned again to later waiters. + #expect(try await first.waitForCallback() == url) + } + + @Test("Stopping or cancelling ends waiters") + func stop() async throws { + let listener = try SignInWithChatGPTLoopbackListener.start(port: 0) + let waiter = Task { try await listener.waitForCallback() } + waiter.cancel() + await #expect(throws: CancellationError.self) { + try await waiter.value + } + await #expect(throws: CancellationError.self) { + try await listener.waitForCallback() + } + } + + @Test("Request lines and HTML escaping") + func parsing() { + #expect(SignInWithChatGPTLoopbackListener.parseRequestLine("GET /auth/callback?x=1 HTTP/1.1\r\nHost: a\r\n\r\n") + == .init(method: "GET", path: "/auth/callback?x=1")) + #expect(SignInWithChatGPTLoopbackListener.parseRequestLine("GET http://evil/ HTTP/1.1\r\n") == nil) + #expect(SignInWithChatGPTLoopbackListener.parseRequestLine("garbage") == nil) + #expect(SignInWithChatGPTLoopbackListener.escapeHTML("\"A&B\"") == "<b>"A&B"</b>") + #expect(SignInWithChatGPTLoopbackListener.html(title: "", message: "m").contains("<t>")) + } +} +#endif diff --git a/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTAuthorizationTests.swift b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTAuthorizationTests.swift new file mode 100644 index 0000000..79d8f6c --- /dev/null +++ b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTAuthorizationTests.swift @@ -0,0 +1,305 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import OpenClawCore + +@Suite("Sign in with ChatGPT authorization") +struct SignInWithChatGPTAuthorizationTests { + private let host = SignInWithChatGPTHostIdentifier(rawValue: "urn:uuid:3f2c8a4e-7b1d-4c9e-9a51-2d6f0b8e4c17")! + + @Test("Host identifiers accept only the documented formats") + func hostIdentifierFormats() throws { + #expect(SignInWithChatGPTHostIdentifier(rawValue: "urn:uuid:3f2c8a4e-7b1d-4c9e-9a51-2d6f0b8e4c17") != nil) + #expect(SignInWithChatGPTHostIdentifier(rawValue: "urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs") != nil) + #expect(SignInWithChatGPTHostIdentifier(rawValue: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") != nil) + #expect(SignInWithChatGPTHostIdentifier(rawValue: "urn:uuid:not-a-uuid") == nil) + #expect(SignInWithChatGPTHostIdentifier(rawValue: "person@example.com") == nil) + #expect(SignInWithChatGPTHostIdentifier(rawValue: "did:key:") == nil) + #expect(SignInWithChatGPTHostIdentifier(rawValue: "urn:uuid:3f2c8a4e 7b1d") == nil) + + let random = SignInWithChatGPTHostIdentifier.randomUUID() + #expect(random.rawValue.hasPrefix("urn:uuid:")) + #expect(random.rawValue == random.rawValue.lowercased()) + #expect(random != SignInWithChatGPTHostIdentifier.randomUUID()) + + let data = try JSONEncoder().encode(random) + #expect(try JSONDecoder().decode(SignInWithChatGPTHostIdentifier.self, from: data) == random) + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(SignInWithChatGPTHostIdentifier.self, from: Data(#""laptop.local""#.utf8)) + } + } + + @Test("JWK thumbprints follow RFC 7638 and RFC 9278") + func jwkThumbprint() throws { + // RFC 7638 §3.1 example key and thumbprint. + let modulus = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-" + + "5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08q" + + "NLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw" + let identifier = try #require(SignInWithChatGPTHostIdentifier.jwkThumbprint(members: ["kty": "RSA", "n": modulus, "e": "AQAB"])) + #expect(identifier.rawValue == "urn:ietf:params:oauth:jwk-thumbprint:sha-256:NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs") + + let ed25519 = try #require(SignInWithChatGPTHostIdentifier.jwkThumbprint(ed25519PublicKey: Data(repeating: 7, count: 32))) + #expect(ed25519.rawValue.hasPrefix(SignInWithChatGPTHostIdentifier.jwkThumbprintPrefix)) + #expect(SignInWithChatGPTHostIdentifier.jwkThumbprint(ed25519PublicKey: Data(repeating: 7, count: 31)) == nil) + + let point = Data(repeating: 1, count: 32) + Data(repeating: 2, count: 32) + let raw = try #require(SignInWithChatGPTHostIdentifier.jwkThumbprint(p256PublicKey: point)) + let uncompressed = try #require(SignInWithChatGPTHostIdentifier.jwkThumbprint(p256PublicKey: Data([0x04]) + point)) + #expect(raw == uncompressed) + #expect(SignInWithChatGPTHostIdentifier.jwkThumbprint(p256PublicKey: Data(repeating: 1, count: 33)) == nil) + } + + @Test("PKCE uses S256 without padding and base64url round-trips") + func pkce() { + #expect(SignInWithChatGPTPKCE.challenge(for: "dBjftJeZ4CVP-mJ92K9dmtAkoUTL3VAUFWEPa9o-xgk") == "Ntr6zmdEd2QSHsYD5DZjLc27dCOy_MS9CCAx5jhmYWI") + let generated = SignInWithChatGPTPKCE.generate() + #expect(generated.verifier.count == 43) + #expect(!generated.challenge.contains("=")) + #expect(generated.challenge == SignInWithChatGPTPKCE.challenge(for: generated.verifier)) + let bytes = Data((0..<255).map { UInt8($0) }) + #expect(SignInWithChatGPTBase64URL.decode(SignInWithChatGPTBase64URL.encode(bytes)) == bytes) + #expect(SignInWithChatGPTBase64URL.decode("a+b/") == nil) + } + + @Test("The auth-flow catalog lists Sign in with ChatGPT") + func catalogDescriptor() throws { + let descriptor = try #require(InteractiveAuthFlowCatalog.descriptor(for: "chatgpt-plan")) + #expect(descriptor.displayName == "Sign in with ChatGPT") + #expect(descriptor.kind == .browserOAuth) + #expect(descriptor.clientID == "dynamic_agent_client") + #expect(descriptor.callbackURL?.absoluteString == "http://127.0.0.1:1455/auth/callback") + #expect(descriptor.scopes.contains("chatgpt.tokens.use.direct")) + #expect(InteractiveAuthFlowCatalog.descriptor(for: "openai")?.displayName == "ChatGPT Login") + } + + @Test("Redirect URIs must be the 127.0.0.1 loopback callback") + func callbackURLValidation() { + #expect(SignInWithChatGPTConfiguration.callbackURL().absoluteString == "http://127.0.0.1:1455/auth/callback") + #expect(SignInWithChatGPTConfiguration.isValidCallbackURL(URL(string: "http://127.0.0.1:61000/auth/callback")!)) + #expect(!SignInWithChatGPTConfiguration.isValidCallbackURL(URL(string: "http://localhost:1455/auth/callback")!)) + #expect(!SignInWithChatGPTConfiguration.isValidCallbackURL(URL(string: "https://127.0.0.1:1455/auth/callback")!)) + #expect(!SignInWithChatGPTConfiguration.isValidCallbackURL(URL(string: "http://127.0.0.1/auth/callback")!)) + #expect(!SignInWithChatGPTConfiguration.isValidCallbackURL(URL(string: "http://127.0.0.1:1455/oauth/callback")!)) + #expect(!SignInWithChatGPTConfiguration.isValidCallbackURL(URL(string: "http://127.0.0.1:1455/auth/callback?x=1")!)) + } + + @Test("Registration requests use dynamic_agent_client, the host id and the plan scopes") + func registrationRequest() throws { + let pending = try SignInWithChatGPTAuthorizationRequest.make( + redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), + hostIdentifier: self.host, + scopes: SignInWithChatGPTClientConfiguration(agentName: "OpenClaw").scopes, + agentName: "OpenClaw" + ) + #expect(pending.isRegistration) + #expect(pending.authorizationURL.absoluteString.hasPrefix("https://auth.openai.com/api/accounts/authorize?")) + let query = SIWCTest.queryItems(pending.authorizationURL) + #expect(query["response_type"] == "code") + #expect(query["client_id"] == "dynamic_agent_client") + #expect(query["agent_name_hint"] == "OpenClaw") + #expect(query["ext_agent_host_id"] == self.host.rawValue) + #expect(query["redirect_uri"] == "http://127.0.0.1:1455/auth/callback") + #expect(query["scope"] == "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct") + #expect(query["resource"] == "https://api.openai.com/v1") + #expect(query["code_challenge_method"] == "S256") + #expect(query["code_challenge"] == SignInWithChatGPTPKCE.challenge(for: pending.codeVerifier)) + #expect(query["state"] == pending.state) + #expect(query["nonce"] == pending.nonce) + #expect(query["id_token_hint"] == nil) + #expect(query["prompt"] == nil) + #expect(query["force_reconsent"] == nil) + // Spaces are percent-encoded, never `+`. + #expect(pending.authorizationURL.absoluteString.contains("scope=openid%20profile")) + } + + @Test("Re-authentication reuses the issued client id with hints and consent parameters") + func reauthenticationRequest() throws { + let reauth = SignInWithChatGPTAuthorizationRequest.Reauthentication( + clientID: "oaiapp_test", + subject: "user-abc", + idTokenHint: "expired.id.token", + loginHint: "person@example.com" + ) + let pending = try SignInWithChatGPTAuthorizationRequest.make( + redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), + hostIdentifier: self.host, + scopes: SignInWithChatGPTConfiguration.identityScopes, + agentName: "OpenClaw", + reauthentication: reauth, + consent: .forceReconsent + ) + #expect(!pending.isRegistration) + #expect(pending.accountSubject == "user-abc") + let query = SIWCTest.queryItems(pending.authorizationURL) + #expect(query["client_id"] == "oaiapp_test") + #expect(query["agent_name_hint"] == nil) + #expect(query["id_token_hint"] == "expired.id.token") + #expect(query["login_hint"] == "person@example.com") + #expect(query["force_reconsent"] == "true") + #expect(query["scope"] == "openid profile email") + + let consent = try SignInWithChatGPTAuthorizationRequest.make( + redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), + hostIdentifier: self.host, + scopes: SignInWithChatGPTConfiguration.identityScopes, + agentName: "OpenClaw", + reauthentication: reauth, + consent: .consent + ) + #expect(SIWCTest.queryItems(consent.authorizationURL)["prompt"] == "consent") + + #expect(throws: SignInWithChatGPTError.self) { + try SignInWithChatGPTAuthorizationRequest.make( + redirectURI: URL(string: "http://localhost:1455/auth/callback")!, + hostIdentifier: self.host, + scopes: [], + agentName: "OpenClaw" + ) + } + } + + @Test("Callbacks are validated against the pending authorization") + func callbackValidation() throws { + let registration = try self.pending(reauth: nil) + let callback = try SignInWithChatGPTAuthorizationCallback.parse(SIWCTest.callbackURL(), pending: registration) + #expect(callback.code == "code-1") + #expect(callback.clientID == "oaiapp_test") + #expect(callback.grantedScopes?.contains("chatgpt.tokens.use.direct") == true) + + #expect(throws: SignInWithChatGPTError.stateMismatch) { + try SignInWithChatGPTAuthorizationCallback.parse(SIWCTest.callbackURL(state: "forged"), pending: registration) + } + #expect(throws: SignInWithChatGPTError.missingIssuedClientID) { + try SignInWithChatGPTAuthorizationCallback.parse(SIWCTest.callbackURL(clientID: nil), pending: registration) + } + #expect(throws: SignInWithChatGPTError.missingIssuedClientID) { + try SignInWithChatGPTAuthorizationCallback.parse(SIWCTest.callbackURL(clientID: "dynamic_agent_client"), pending: registration) + } + #expect(throws: SignInWithChatGPTError.missingAuthorizationCode) { + try SignInWithChatGPTAuthorizationCallback.parse(SIWCTest.callbackURL(code: ""), pending: registration) + } + let denied = URL(string: "http://127.0.0.1:1455/auth/callback?error=access_denied&state=state-1")! + #expect(throws: SignInWithChatGPTError.accessDenied) { + try SignInWithChatGPTAuthorizationCallback.parse(denied, pending: registration) + } + let failed = URL(string: "http://127.0.0.1:1455/auth/callback?error=server_error&error_description=down&state=state-1")! + #expect(throws: SignInWithChatGPTError.authorizationFailed(code: "server_error", description: "down")) { + try SignInWithChatGPTAuthorizationCallback.parse(failed, pending: registration) + } + let wrongPath = URL(string: "http://127.0.0.1:1455/other?code=code-1&state=state-1&client_id=oaiapp_test")! + #expect(throws: SignInWithChatGPTError.self) { + try SignInWithChatGPTAuthorizationCallback.parse(wrongPath, pending: registration) + } + + let reauth = try self.pending(reauth: .init(clientID: "oaiapp_test", subject: "user-abc")) + #expect(try SignInWithChatGPTAuthorizationCallback.parse(SIWCTest.callbackURL(clientID: nil), pending: reauth).clientID == "oaiapp_test") + #expect(throws: SignInWithChatGPTError.clientMismatch(expected: "oaiapp_test", received: "oaiapp_other")) { + try SignInWithChatGPTAuthorizationCallback.parse(SIWCTest.callbackURL(clientID: "oaiapp_other"), pending: reauth) + } + } + + private func pending(reauth: SignInWithChatGPTAuthorizationRequest.Reauthentication?) throws -> SignInWithChatGPTPendingAuthorization { + try SignInWithChatGPTAuthorizationRequest.make( + endpoints: .production, + redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), + hostIdentifier: self.host, + scopes: SignInWithChatGPTClientConfiguration(agentName: "OpenClaw").scopes, + agentName: "OpenClaw", + reauthentication: reauth, + consent: .automatic, + now: Date(), + secrets: SIWCTest.secrets + ) + } +} + +@Suite("Sign in with ChatGPT ID tokens") +struct SignInWithChatGPTIDTokenTests { + private func validator(jwks: String = SignInWithChatGPTFixtures.jwks) -> (SignInWithChatGPTIDTokenValidator, SIWCFakeServer) { + let server = SIWCFakeServer(jwks: jwks) + let cache = SignInWithChatGPTJWKSCache(transport: server.transport) + return (SignInWithChatGPTIDTokenValidator(jwks: cache, clockSkew: 60), server) + } + + @Test("A valid RS256 ID token yields its claims") + func validToken() async throws { + let (validator, _) = self.validator() + let claims = try await validator.validate(SignInWithChatGPTFixtures.Token.valid, clientID: "oaiapp_test", nonce: "nonce-1") + #expect(claims.subject == "user-abc") + #expect(claims.email == "person@example.com") + #expect(claims.emailVerified == true) + #expect(claims.name == "Test Person") + #expect(claims.issuer == "https://auth.openai.com") + #expect(claims.audience == ["oaiapp_test"]) + #expect(claims.nonce == "nonce-1") + + let array = try await validator.validate(SignInWithChatGPTFixtures.Token.audienceArray, clientID: "oaiapp_test", nonce: "nonce-1") + #expect(array.audience == ["oaiapp_test", "other"]) + let refresh = try await validator.validate(SignInWithChatGPTFixtures.Token.refresh, clientID: "oaiapp_test", nonce: nil) + #expect(refresh.nonce == nil) + } + + @Test("Invalid ID tokens are rejected", arguments: [ + ("tampered", SignInWithChatGPTFixtures.Token.tampered, "oaiapp_test", "nonce-1"), + ("expired", SignInWithChatGPTFixtures.Token.expired, "oaiapp_test", "nonce-1"), + ("wrong issuer", SignInWithChatGPTFixtures.Token.wrongIssuer, "oaiapp_test", "nonce-1"), + ("wrong audience", SignInWithChatGPTFixtures.Token.valid, "oaiapp_other", "nonce-1"), + ("wrong azp", SignInWithChatGPTFixtures.Token.wrongAuthorizedParty, "oaiapp_test", "nonce-1"), + ("wrong nonce", SignInWithChatGPTFixtures.Token.valid, "oaiapp_test", "nonce-2"), + ("missing nonce", SignInWithChatGPTFixtures.Token.refresh, "oaiapp_test", "nonce-1"), + ("alg none", SignInWithChatGPTFixtures.Token.algNone, "oaiapp_test", "nonce-1"), + ("HS256", SignInWithChatGPTFixtures.Token.hs256, "oaiapp_test", "nonce-1"), + ("not yet valid", SignInWithChatGPTFixtures.Token.notYetValid, "oaiapp_test", "nonce-1"), + ("issued in the future", SignInWithChatGPTFixtures.Token.issuedInFuture, "oaiapp_test", "nonce-1"), + ("1024-bit key", SignInWithChatGPTFixtures.Token.weakKey, "oaiapp_test", "nonce-1"), + ("no kid with several keys", SignInWithChatGPTFixtures.Token.noKeyID, "oaiapp_test", "nonce-1"), + ("malformed", "not-a-jwt", "oaiapp_test", "nonce-1"), + ]) + func rejectsInvalidTokens(name: String, token: String, clientID: String, nonce: String) async throws { + let (validator, _) = self.validator() + await #expect(throws: SignInWithChatGPTError.self, "\(name)") { + try await validator.validate(token, clientID: clientID, nonce: nonce) + } + } + + @Test("Unknown key ids refresh the JWKS once, and a single key needs no kid") + func keyRotation() async throws { + let (validator, server) = self.validator() + await #expect(throws: SignInWithChatGPTError.self) { + try await validator.validate(SignInWithChatGPTFixtures.Token.rotatedKey, clientID: "oaiapp_test", nonce: "nonce-1") + } + #expect(server.recordedRequests.count == 1) + + let rotated = SIWCFakeServer(jwks: SignInWithChatGPTFixtures.jwksRotated) + let cache = SignInWithChatGPTJWKSCache(transport: rotated.transport, minimumRefreshInterval: 0) + let fresh = SignInWithChatGPTIDTokenValidator(jwks: cache) + _ = try await fresh.validate(SignInWithChatGPTFixtures.Token.valid, clientID: "oaiapp_test", nonce: "nonce-1") + _ = try await fresh.validate(SignInWithChatGPTFixtures.Token.rotatedKey, clientID: "oaiapp_test", nonce: "nonce-1") + #expect(rotated.recordedRequests.count == 1) + + let single = SIWCFakeServer(jwks: SignInWithChatGPTFixtures.jwksSingleKey) + let singleValidator = SignInWithChatGPTIDTokenValidator(jwks: SignInWithChatGPTJWKSCache(transport: single.transport)) + _ = try await singleValidator.validate(SignInWithChatGPTFixtures.Token.noKeyID, clientID: "oaiapp_test", nonce: "nonce-1") + } + + @Test("JWKS parsing keeps RSA signing keys and PKCS #1 DER is well formed") + func jwksParsing() throws { + let keys = try SignInWithChatGPTRSAPublicKey.keys(fromJWKS: Data(SignInWithChatGPTFixtures.jwks.utf8)) + #expect(keys.map(\.keyID) == ["test-key-1", "weak-key"]) + #expect(throws: SignInWithChatGPTError.self) { + try SignInWithChatGPTRSAPublicKey.keys(fromJWKS: Data("[]".utf8)) + } + let der = SignInWithChatGPTRSAPublicKey.pkcs1PublicKeyDER(modulus: Data([0x80, 0x01]), exponent: Data([0x01, 0x00, 0x01])) + #expect([UInt8](der) == [0x30, 0x0A, 0x02, 0x03, 0x00, 0x80, 0x01, 0x02, 0x03, 0x01, 0x00, 0x01]) + let long = SignInWithChatGPTRSAPublicKey.pkcs1PublicKeyDER(modulus: Data(repeating: 0x7F, count: 256), exponent: Data([3])) + #expect([UInt8](long.prefix(8)) == [0x30, 0x82, 0x01, 0x07, 0x02, 0x82, 0x01, 0x00]) + } + + @Test("Unverified claims decode for display") + func unverifiedClaims() { + #expect(SignInWithChatGPTIDTokenValidator.unverifiedClaims(SignInWithChatGPTFixtures.Token.valid)?.email == "person@example.com") + #expect(SignInWithChatGPTIDTokenValidator.unverifiedClaims("x.y.z") == nil) + } +} diff --git a/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTFixtures.swift b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTFixtures.swift new file mode 100644 index 0000000..0219c3e --- /dev/null +++ b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTFixtures.swift @@ -0,0 +1,25 @@ +// swiftlint:disable line_length +// Generated test vectors: RSA keys created only for these tests (private keys discarded). +// Tokens are RS256 JWTs for issuer https://auth.openai.com and client oaiapp_test. +enum SignInWithChatGPTFixtures { + static let jwks = #"{"keys":[{"kty":"RSA","kid":"test-key-1","use":"sig","alg":"RS256","n":"uUhsXgYyh6mZuIQKP4UGBF4xF5vEWuiJTPU4sGqOgLp1EXwdG4ulYyzxdD-MGgchlGBjyvgNMBBDHOyf7nwvD0nDGD269kF7QmnMihb66w98YKAQ6Krvad2O0X1OZ21dAvbGvQNsLmWrexaPa4l8Z-w3iw88ONNQz_kuKgTtwS7gwv-r2XMUAWvuQSqALZSGOZjIMYxsbG0voOpjDciUGFtmSEyVfIbYOh6Jtt0PkKtpvnHdMAvLgJbb7VVYgSw-ndtX0ypepZdf3s7IC1KZEvtuQn_T199QHidudttO50gVmltZCwzVtRDeMT49ow0eljJlv4aafeFbpUnDmNuplQ","e":"AQAB"},{"kty":"RSA","kid":"weak-key","use":"sig","alg":"RS256","n":"naeYmge3epxVhp2kZLqjKwNYAO5ymRll-uXLBYpMVh1HuGE4Sa_bKR8Est-4CjFMxSNaYHoknmTv8WyQgrkSPvPSDbKbwBJiK4Tkz6XvWt8B8Q7U7s9nlQf3tPfQVAhJGxhH5575libXdIJluQ13H6hb8-HqIr5gt3zpAGi6eV0","e":"AQAB"},{"kty":"EC","kid":"ec","crv":"P-256","x":"AA","y":"AA"}]}"# + static let jwksRotated = #"{"keys":[{"kty":"RSA","kid":"test-key-1","use":"sig","alg":"RS256","n":"uUhsXgYyh6mZuIQKP4UGBF4xF5vEWuiJTPU4sGqOgLp1EXwdG4ulYyzxdD-MGgchlGBjyvgNMBBDHOyf7nwvD0nDGD269kF7QmnMihb66w98YKAQ6Krvad2O0X1OZ21dAvbGvQNsLmWrexaPa4l8Z-w3iw88ONNQz_kuKgTtwS7gwv-r2XMUAWvuQSqALZSGOZjIMYxsbG0voOpjDciUGFtmSEyVfIbYOh6Jtt0PkKtpvnHdMAvLgJbb7VVYgSw-ndtX0ypepZdf3s7IC1KZEvtuQn_T199QHidudttO50gVmltZCwzVtRDeMT49ow0eljJlv4aafeFbpUnDmNuplQ","e":"AQAB"},{"kty":"RSA","kid":"test-key-2","use":"sig","alg":"RS256","n":"heTwpdBZzgBv_kNzwOJb7clbpmZHwpqBSF4OWSSmbzr0MQXyooRYRrvaRTOJH-P8FV7WxABgIA7Dy_S8lkiCIHqznDc7IDbvOcK4BFioNwbR59udSGZaco2VD8SRuriSlqzvx3Sbnt24qngfe_f87uBQkjKF6gnvWurnz2Y2N8Jxo4gyIASzStjBNoQU4ZyNWsx1TfU_xL3xs7aGuB6lDpI9ADmi6lcN8EpUtlCbVPFhu-acqGy0m6z2SCw9dc_ML7h4U2FE5wiFpEvXbqIFdiu3bZSFDnFR6deJ5PHbWvs07AeEGJ72oV1_4bQwCJQgrtOxd_XdBjqzXFoLSYOpww","e":"AQAB"}]}"# + static let jwksSingleKey = #"{"keys":[{"kty":"RSA","kid":"only","use":"sig","alg":"RS256","n":"uUhsXgYyh6mZuIQKP4UGBF4xF5vEWuiJTPU4sGqOgLp1EXwdG4ulYyzxdD-MGgchlGBjyvgNMBBDHOyf7nwvD0nDGD269kF7QmnMihb66w98YKAQ6Krvad2O0X1OZ21dAvbGvQNsLmWrexaPa4l8Z-w3iw88ONNQz_kuKgTtwS7gwv-r2XMUAWvuQSqALZSGOZjIMYxsbG0voOpjDciUGFtmSEyVfIbYOh6Jtt0PkKtpvnHdMAvLgJbb7VVYgSw-ndtX0ypepZdf3s7IC1KZEvtuQn_T199QHidudttO50gVmltZCwzVtRDeMT49ow0eljJlv4aafeFbpUnDmNuplQ","e":"AQAB"}]}"# + enum Token { + static let valid = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.pQCZdE0PGJ1s4h5PxBlJa5a5OUoazxvRMAl6_x5MDfgUXGzyyftzm78SiItsl_0AzG4dI2EtSBgGZCk_SFJs5nsLl4oxwHSL2FMqeb5mdgtvRUIn5mxUgBNxgtGoorsdwoDS373n0Ei6rPV3oMUXub1Ys-FPwjhp3df_eG6yafgHXvAeV_p-DoVy1rIezX-h89GsF24LUCuYn72hQMQHFrpzTZYKqWv8aB6GIOZ8DjWOPPIySNYuyV5KM2IW9dM_omrTwcRgkq3ir7PvYpry0rlv5ZsnVBATWlpnT_nMj1GNUKgRn43aeToS-AYowe2FtzKuFyJxZAkDDmCP2JAusw" + static let refresh = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.Qf4NqZ1HAa3j15rBwt8AyUm_93895L7H4pEkgMBcEL9Bf_qEjxivE6-61cmUtuA9tA5q6LhA3DVPe2rzjMbFGfs4iY5x7BQPhIUiOJ9QMYZgDz_rhMLaokphnNG0CdWVzjhp7R9Z9sq77mFi3XsIQ5xkaB5MxcTJF-ni9q89BTrDm_H95xazCiaxnaWJ3B7C0a9HA1aWhvETft56QwID0gDy3C3S76-qSyb3c79Z2gzRaN3UWKy_rqkhFBfwigNSV2-groCcoDaLhTVBfoShKr8DrIQzoxDzqY_ccFu0DdA3xb28FQB2jGsyWzLTozfq_N1pBhDWRvle-FHHoLnhJA" + static let otherSubject = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItb3RoZXIiLCJhdWQiOiJvYWlhcHBfdGVzdCIsImV4cCI6NDEwMjQ0NDgwMCwiaWF0IjoxNzAwMDAwMDAwLCJub25jZSI6Im5vbmNlLTEiLCJlbWFpbCI6InBlcnNvbkBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJuYW1lIjoiVGVzdCBQZXJzb24ifQ.aPiZflr1F2O8mVWAVXbKmlhXLYfPmro1Gl2M21HvfVpmdoCCkO11gX8mU243HxZ70aZ-vbeqqvWWEiPrnmZedDhdaEAOHreUaO4zyFS0DPxu3zdo6Enr_XOGLwRnekpfiNi8A9VLCwTIDEFn2DXihrJBctOXjNz9NRuANcXHty2C6XA2lrrTjI2mFm9m3JpImSmyNTHXtP6YV3PNbUVPAZ0pXqhd_wulIucuZ7xlediGlx-KR4Z6zm4cNZmninR5Qcw6_q_qS3Ng0ASrjP4gtuLRhxX3z_AIRVo3fcg6PBPqLw8hwvQcd3mV8mcPPRi0aZDss7INX1ZMLrULDUEh9A" + static let expired = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjE3MDAwMDM2MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.op9MllN05F0vGm-_BB0pR2-mUHRMyjaH9C7hhcGXYrCqq2WFmlEXKSOrjhzfa4OhM0ipkP65gYNAExV8lq90aVFrjHViQjrMdOjQAe5fdUklD58rdmlQy_mvYBXlv558Zf2WA9qVhyNL2AYwT5rfPHpdE9FYGXskaZlRj5FOt_CYA7gNCsNdCTvrkWapfgabLMVRUaM4pox4H-uWtpWKznU5_rvj4q876DdKYJ6RS0PllwIg2OQlN1hydkJvKImAz4Ey7cp4FElES2xfDjxbeMgZEgb2ywyledirD7Fqnm4qcXs8iNYdkhTEp3OMPsLmNzjXaAInhXLQQm2v5mqW6Q" + static let wrongIssuer = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2V2aWwuZXhhbXBsZSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.UofUqszbgxzWQ_4wCjRLiY9CjjMz2VHgBBGDDYwte4AB_yg6DQvomwuOCaQSo2eogVBhv21cBOHXLlr4q1JydpqK-fGxMSCAr_6m6VUWzpiELkTSGosBF0b0TVOzrSkq79BxeYoGt2U7vSbbAR2P6CiMUVuztPRLxV8P1xr0C0mXpo5E9lc3ocGlA10iIlDSMRBU8RKgq4NQqeQM1R8w2EwHD7uRq0hr3H4et-YPsxqFpgMNiYRZDcvZxkST4O7Kwz1qWrrx08Lcb3-CofeKXKwpu5iM48haswwpaOwo85EwyKEchi7EKf18fO1FXqjYKfmdm-XssPo05aiGNoaxMQ" + static let audienceArray = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjpbIm9haWFwcF90ZXN0Iiwib3RoZXIiXSwiZXhwIjo0MTAyNDQ0ODAwLCJpYXQiOjE3MDAwMDAwMDAsIm5vbmNlIjoibm9uY2UtMSIsImVtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJUZXN0IFBlcnNvbiIsImF6cCI6Im9haWFwcF90ZXN0In0.h-JWcGRtNrGJ9kPW4Pk3ST35fc2VBEjUZG5ZNXHokjUXvKfy_hz9XpEhMhenPfcneHrtuYapj3V0-wyVwpWA17D9PY9C2SuwBlvoTU3TZ5_pP6_z03phiXJK_hvbsbbirAiEpIEZ0p9fDQ8x8N-hC_NRkeRiRHXVPIMsNRo8MqhhbxhcuNw3WH_etEJIY77vpVadA_FosPr0xM_ZKvyZdxxyywdyYlxrqjARX0wL_qgRTnnuuYrWZ08Rd9F1t-MkiAKM3bRcbIjKiKcig8i5WjDxNvW2oBZy3xQ3yBuRGv4y_Zyd_JQGWj5dV_TQMEjI4jv1oQBioWCCIQSO7M3nhg" + static let wrongAuthorizedParty = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjpbIm9haWFwcF90ZXN0Iiwib3RoZXIiXSwiZXhwIjo0MTAyNDQ0ODAwLCJpYXQiOjE3MDAwMDAwMDAsIm5vbmNlIjoibm9uY2UtMSIsImVtYWlsIjoicGVyc29uQGV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJUZXN0IFBlcnNvbiIsImF6cCI6Im90aGVyIn0.t2aRqEFdY1PzQx8HaDZNIZoOCGrK9L8hzcXZVG3z-cC_y3i8A_tJJj9PLC6ZaL1AK5NCJ_vRZKw82jSkRogHsI0GoN7EnR2wYXwebU2UpKN-jfeFLxw6rjG1NXiZTL9dD5elZOqs5pkvNyXIgzosBkO_qwD6Wk-igaWVfTy9BptBAL7NyeWj3ImQ7uQWlAxFGpSg-zP4Czi0lhjEJZlWyBeYDI6V8kR7DG5Bn2Q6MSk0DlcHC2USHDxS8D1gKx6jVVM1iwvhlmN58TxeOsvxRsl7Oh95h9epC3wv6UvTDOm6itJhoh9uRucZuGE-T03GBXCnYIwt_Lg7nm2M5shjmg" + static let algNone = "eyJhbGciOiJub25lIiwia2lkIjoidGVzdC1rZXktMSJ9.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0." + static let hs256 = "eyJhbGciOiJIUzI1NiIsImtpZCI6InRlc3Qta2V5LTEifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.en78CGUTeS1vRosQwYHpWf8EDBdUsxW1m4qt0ILJqih3lzBn2BK3VKzHxQSJlSl5Er1wTlX8lsgiyF9WQfMXtRSqVJoH_O2SjWuK6DdIIqumO19XKXbCIcpXZ6-jVmyYuKD6q0zkcI4Btx4VvqjJN1lyL4MvNhqcN_nMpWcpPnbVY84wJnBPLujddSHhStc3qOxjJlAIlxeKAellcJT9-fL2l4Z297bT02W3Zl7X7dYJIdNPwayNrU5liWzD2opWtaquBIehBbvpHM5V0EhqoT_3L0JhTX6KtANudzZN7_aR8VyxHIrphKMgqxSRPN00svvSRo3MJb7ugjvMYojlvQ" + static let rotatedKey = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTIifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.Ia0VSGSR77exmNmxyq1iURXve34hX8XA6lwihEV3VwUNHHwjtpczJ8-p0X22ytQwXwCqqvHXEY2fYpuyS2q17m7c2C7ElmmUKVt4FLFYRufrx008WX8tWintYHuatCpWeg7tx3RVgwQlgYBLGvnL4brozcrArsCV7gmKUsB3MlGJ6DQyGOaF5481aw4Oh9rvuJc6qjRyjNu7ZX2ymkisgu6_j0ZxkvMqLNyGVqw0FwrRWmwVJWsLmDJoPpOR8cKu4d4f4o9PiPBLCbw3cIRFF9fK0JYk3B1IoHVqMNdt1xVgosU2e6maZDLqjeR9spEGEC7_g2nlRG8Es7cIkeChsw" + static let notYetValid = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIiwibmJmIjo0MTAyNDQ0ODAwfQ.MlRRlb9raFof24tittqy3ed_fWkRxjWQYUBYZmyqnpBwNVHSyIfQH1gdFQ0-AY9ZT49BANzgAnMltjWTZAnt10AP2qCWe7fS2dwh30TyAjORcsiJRxPNouVodV2yvzZMVIOpG-W517y0PAu9YGhk8VMHs4MVbYnWc0FLxtk3TZwTFmqj0B9d5m37gRh7xI2BpfhuuA7Q5LPGf_MLL8g6TX3Zkis2jg_XN-9ZQ40y0ntbxBZWzXTyHrSFNWo3cFJ7Q48ZdtKOPBovu2iohhCoXWr57vL2aLzTsaJaOdRtsriyZvwuiqQy2aPDIl0huzoenF75pybztkztuWk5E1dQoA" + static let issuedInFuture = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6NDEwMjQ0NDgwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.HnPEPPtFx8u1BafAM1lG6NCpPyI02CY-oP10sjOHYD1CYQiS9wdy8xJwHhaIrKttt5bWsk_pi_dHW7sgSbbKpucban1Z4Sxe4kaXZ5MLZu_Na1j4d7S2rQkjXO_p6KPYzcjC9kardICnAw3FBJ28JJOzMxzOPvAInwsAg39qiOR7l_-ru0UMK2Hxg9fexkpkgHEBw3mHPJopPmMOal8-ezAu4pw2pgGwtC20dFh_CKIX2MfXK2rKUQqsXlluENn-mq3zHBNNQYW7FIRpAz3ipoNhs84SdwgM7-fag2qVOq2VwvUOQCTwRRj0mJwj8X--PCZsTxFxBgyfN7HetGrQSQ" + static let weakKey = "eyJhbGciOiJSUzI1NiIsImtpZCI6IndlYWsta2V5In0.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.U-ynT3xU2l2scMsXtMqlM4iuNMMm1CbZVIgjBXBBvqZNlLgTXMmHcsHlhn_-P5je0UZ3HPOq9f5YuagP_6IGYqAJiR0CK09RbxjsUXX9M5zk-MjSY6hKsfEhwg7NJGuXP95rT0f9rAK-ZJuDmSYxx_E9NCxXVjWJbF2F6wYFDEA" + static let noKeyID = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6InVzZXItYWJjIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.V8SUMhgxJaJh3XmaLyOrpG8qefbXraJC4ZsYeBlIs-sjpKCoY3Qya4TLMd1gbqmVttAMeO8Ql_phYdMcoxT1SvHvX3pqIymfbzA5U55-yx0qxwalGoj9mWQs3kL6YBSDkpBVUH6--NRfvUbr1r1moVE2j8Ir0jrn2phMzuJNsaLF7Tr9jJROhx-0l2KFNm_604SAyez_PpAv_bveX_dVdsI252sz4jIubR0btHHGsn6gPLCgVPisFx4QbalcBab3jYddf9uj3RhFchvpODgqVuisAweiSWSsWtDkUS_z34yv1MvM5dLdVWKRXAF6vMOcIXXXZdt8ByWeZ8-_QbASJg" + static let tampered = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2V5LTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2F1dGgub3BlbmFpLmNvbSIsInN1YiI6ImF0dGFja2VyIiwiYXVkIjoib2FpYXBwX3Rlc3QiLCJleHAiOjQxMDI0NDQ4MDAsImlhdCI6MTcwMDAwMDAwMCwibm9uY2UiOiJub25jZS0xIiwiZW1haWwiOiJwZXJzb25AZXhhbXBsZS5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlRlc3QgUGVyc29uIn0.pQCZdE0PGJ1s4h5PxBlJa5a5OUoazxvRMAl6_x5MDfgUXGzyyftzm78SiItsl_0AzG4dI2EtSBgGZCk_SFJs5nsLl4oxwHSL2FMqeb5mdgtvRUIn5mxUgBNxgtGoorsdwoDS373n0Ei6rPV3oMUXub1Ys-FPwjhp3df_eG6yafgHXvAeV_p-DoVy1rIezX-h89GsF24LUCuYn72hQMQHFrpzTZYKqWv8aB6GIOZ8DjWOPPIySNYuyV5KM2IW9dM_omrTwcRgkq3ir7PvYpry0rlv5ZsnVBATWlpnT_nMj1GNUKgRn43aeToS-AYowe2FtzKuFyJxZAkDDmCP2JAusw" + } +} diff --git a/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift new file mode 100644 index 0000000..5697a2a --- /dev/null +++ b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTSessionTests.swift @@ -0,0 +1,506 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +import Testing +@testable import OpenClawCore + +@Suite("Sign in with ChatGPT tokens and credentials") +struct SignInWithChatGPTTokenTests { + @Test("Token responses decode earliest_refresh_at as seconds or ISO 8601") + func tokenResponseDecoding() throws { + let seconds = try JSONDecoder().decode( + SignInWithChatGPTTokenResponse.self, + from: Data((#"{"access_token":"a","refresh_token":"r","id_token":"i","token_type":"Bearer","expires_in":3600,"# + + #""scope":"openid email","earliest_refresh_at":1800001800}"#).utf8) + ) + #expect(seconds.expiresIn == 3600) + #expect(seconds.grantedScopes == ["openid", "email"]) + #expect(seconds.earliestRefreshAt == Date(timeIntervalSince1970: 1_800_001_800)) + let iso = try JSONDecoder().decode( + SignInWithChatGPTTokenResponse.self, + from: Data(#"{"access_token":"a","expires_in":"3600","earliest_refresh_at":"2027-01-15T08:00:00Z"}"#.utf8) + ) + #expect(iso.tokenType == "Bearer") + #expect(iso.expiresIn == 3600) + #expect(iso.earliestRefreshAt == SignInWithChatGPTDates.parseISO8601("2027-01-15T08:00:00Z")) + } + + @Test("Credential records use the documented field names and ISO 8601 saved_at") + func credentialRecord() throws { + let credential = SignInWithChatGPTCredential( + email: "person@example.com", + issuer: "https://auth.openai.com", + subject: "user-abc", + clientID: "oaiapp_test", + hostIdentifier: SignInWithChatGPTHostIdentifier(rawValue: "urn:uuid:3f2c8a4e-7b1d-4c9e-9a51-2d6f0b8e4c17")!, + idToken: "id", + accessToken: "access", + refreshToken: "refresh", + expiresIn: 3600, + scopes: ["openid", "chatgpt.tokens.use.direct"], + savedAt: Date(timeIntervalSince1970: 1_800_000_000) + ) + let json = try #require(JSONSerialization.jsonObject(with: credential.recordJSON()) as? [String: Any]) + #expect(Set(json.keys) == [ + "email", "issuer", "subject", "client_id", "ext_agent_host_id", "id_token", "access_token", + "refresh_token", "token_type", "expires_in", "scopes", "saved_at", + ]) + #expect(json["saved_at"] as? String == "2027-01-15T08:00:00.000Z") + #expect(json["scopes"] as? [String] == ["openid", "chatgpt.tokens.use.direct"]) + let decoded = try SignInWithChatGPTCredential.decodeRecord(credential.recordJSON()) + #expect(decoded == credential) + #expect(decoded.allowsPlanUsage) + #expect(decoded.expiresAt == Date(timeIntervalSince1970: 1_800_003_600)) + + let minimal = try SignInWithChatGPTCredential.decodeRecord(Data( + (#"{"client_id":"oaiapp_test","access_token":"a","refresh_token":"r","expires_in":3600,"saved_at":"2027-01-15T08:00:00Z","# + + #""ext_agent_host_id":"urn:uuid:3f2c8a4e-7b1d-4c9e-9a51-2d6f0b8e4c17","scopes":"openid email"}"#).utf8 + )) + #expect(minimal.subject.isEmpty) + #expect(minimal.scopes == ["openid", "email"]) + #expect(minimal.savedAt == SignInWithChatGPTDates.parseISO8601("2027-01-15T08:00:00Z")) + } + + @Test("Refresh timing honours the leeway and earliest_refresh_at") + func needsRefresh() { + let saved = Date(timeIntervalSince1970: 1_800_000_000) + var credential = SignInWithChatGPTCredential( + email: nil, + issuer: "https://auth.openai.com", + subject: "user-abc", + clientID: "oaiapp_test", + hostIdentifier: .randomUUID(), + idToken: nil, + accessToken: "a", + refreshToken: "r", + expiresIn: 3600, + scopes: [], + savedAt: saved + ) + #expect(!credential.needsRefresh(now: saved.addingTimeInterval(3000), leeway: 300)) + #expect(credential.needsRefresh(now: saved.addingTimeInterval(3400), leeway: 300)) + credential.earliestRefreshAt = saved.addingTimeInterval(3500) + #expect(!credential.needsRefresh(now: saved.addingTimeInterval(3400), leeway: 300)) + #expect(credential.needsRefresh(now: saved.addingTimeInterval(3600), leeway: 300)) + credential.refreshToken = nil + #expect(!credential.needsRefresh(now: saved.addingTimeInterval(7200), leeway: 300)) + } + + @Test("Code exchange and refresh send the documented form fields and map OAuth errors") + func tokenClient() async throws { + let server = SIWCFakeServer() + server.useRotatingRefresh() + let client = SignInWithChatGPTTokenClient(endpoints: .production, transport: server.transport) + let pending = try SignInWithChatGPTAuthorizationRequest.make( + endpoints: .production, + redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), + hostIdentifier: .randomUUID(), + scopes: [], + agentName: "TestAgent", + reauthentication: nil, + consent: .automatic, + now: Date(), + secrets: SIWCTest.secrets + ) + let callback = try SignInWithChatGPTAuthorizationCallback.parse(SIWCTest.callbackURL(), pending: pending) + let exchanged = try await client.exchange(callback, pending: pending) + #expect(exchanged.accessToken == "access-1") + let exchange = try #require(server.forms(path: "/api/accounts/oauth/token").first) + #expect(exchange == [ + "grant_type": "authorization_code", + "client_id": "oaiapp_test", + "code": "code-1", + "code_verifier": SIWCTest.secrets.verifier, + "redirect_uri": "http://127.0.0.1:1455/auth/callback", + "resource": "https://api.openai.com/v1", + ]) + let request = try #require(server.recordedRequests.first) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/x-www-form-urlencoded") + #expect(request.value(forHTTPHeaderField: "Authorization") == nil) + + _ = try await client.refresh(refreshToken: "refresh-1", clientID: "oaiapp_test", subject: "user-abc") + #expect(server.forms(path: "/api/accounts/oauth/token").last == [ + "grant_type": "refresh_token", + "client_id": "oaiapp_test", + "refresh_token": "refresh-1", + "resource": "https://api.openai.com/v1", + ]) + + server.setTokenHandler { _, _ in SIWCFakeServer.json(["error": "invalid_grant", "error_description": "code used"], status: 400) } + await #expect(throws: SignInWithChatGPTError.authorizationCodeRejected("code used")) { + try await client.exchange(callback, pending: pending) + } + for code in SignInWithChatGPTTokenClient.reauthenticationCodes { + server.setTokenHandler { _, _ in SIWCFakeServer.json(["error": code], status: 400) } + await #expect(throws: SignInWithChatGPTError.reauthenticationRequired(subject: "user-abc", reason: code)) { + try await client.refresh(refreshToken: "r", clientID: "oaiapp_test", subject: "user-abc") + } + } + server.setTokenHandler { _, _ in SIWCFakeServer.json(["error": ["code": "invalid_client", "message": "unknown client"]], status: 401) } + await #expect(throws: SignInWithChatGPTError.invalidClient("unknown client")) { + try await client.refresh(refreshToken: "r", clientID: "oaiapp_test", subject: "user-abc") + } + server.setTokenHandler { _, _ in SIWCFakeServer.json(["error": "temporarily_unavailable"], status: 503) } + await #expect(throws: SignInWithChatGPTError.tokenRequestFailed(statusCode: 503, code: "temporarily_unavailable", description: nil)) { + try await client.refresh(refreshToken: "r", clientID: "oaiapp_test", subject: "user-abc") + } + } + + @Test("Revocation retries 5xx responses and stops on 4xx") + func revocation() async throws { + let server = SIWCFakeServer() + let attempts = SIWCCounter() + server.setRevokeHandler { _, _ in + HTTPResponseData(statusCode: attempts.increment() < 2 ? 503 : 200, headers: [:], body: Data()) + } + let client = SignInWithChatGPTTokenClient(endpoints: .production, transport: server.transport, revocationRetryDelays: [0.01, 0.01, 0.01]) + try await client.revoke(refreshToken: "refresh-1", clientID: "oaiapp_test") + #expect(attempts.value == 3) + #expect(server.forms(path: "/api/accounts/oauth/revoke").last == [ + "token": "refresh-1", + "token_type_hint": "refresh_token", + "client_id": "oaiapp_test", + ]) + + let rejected = SIWCFakeServer() + let rejectedAttempts = SIWCCounter() + rejected.setRevokeHandler { _, _ in + rejectedAttempts.increment() + return SIWCFakeServer.json(["error": "invalid_request"], status: 400) + } + let rejectingClient = SignInWithChatGPTTokenClient(endpoints: .production, transport: rejected.transport, revocationRetryDelays: [0.01, 0.01]) + await #expect(throws: SignInWithChatGPTError.self) { + try await rejectingClient.revoke(refreshToken: "refresh-1", clientID: "oaiapp_test") + } + #expect(rejectedAttempts.value == 1) + } +} + +final class SIWCCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + @discardableResult + func increment() -> Int { + self.lock.withLock { + let previous = self.count + self.count += 1 + return previous + } + } + + var value: Int { + self.lock.withLock { self.count } + } +} + +@Suite("Sign in with ChatGPT session") +struct SignInWithChatGPTSessionTests { + @Test("A first sign-in registers the account and persists the host id first") + func registration() async throws { + let server = SIWCFakeServer() + let clock = SIWCTestClock() + let store = InMemoryTestCredentialStore() + let session = SIWCTest.makeSession(server: server, clock: clock, store: store) + let pending = try await session.beginAuthorization( + redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), + reauthenticating: nil, + consent: .automatic, + secrets: SIWCTest.secrets + ) + let hostID = try await session.hostIdentifier() + #expect(try await store.loadSecret(for: "openclaw.siwc.host-id") == hostID.rawValue) + let query = SIWCTest.queryItems(pending.authorizationURL) + #expect(query["client_id"] == "dynamic_agent_client") + #expect(query["agent_name_hint"] == "TestAgent") + #expect(query["ext_agent_host_id"] == hostID.rawValue) + + let result = try await session.completeAuthorization(callbackURL: SIWCTest.callbackURL(), pending: pending) + #expect(result.isNewAccount) + #expect(result.shouldShowPlanWelcome) + #expect(result.account.subject == "user-abc") + #expect(result.account.email == "person@example.com") + #expect(result.account.clientID == "oaiapp_test") + #expect(result.account.usesChatGPTPlan) + #expect(try await session.activeAccount()?.subject == "user-abc") + #expect(try await session.accessToken() == "access-1") + + let credential = try #require(try await session.store.credential(subject: "user-abc")) + #expect(credential.hostIdentifier == hostID) + #expect(credential.idToken == SignInWithChatGPTFixtures.Token.valid) + #expect(credential.savedAt == clock.now) + + try await session.markPlanWelcomeSeen(subject: "user-abc") + let again = try await SIWCTest.signIn(session) + #expect(!again.isNewAccount) + #expect(!again.shouldShowPlanWelcome) + #expect(try await session.hostIdentifier() == hostID) + } + + @Test("Access tokens refresh before expiry, once, with rotated refresh tokens") + func refresh() async throws { + let server = SIWCFakeServer() + server.useRotatingRefresh() + let clock = SIWCTestClock() + let session = SIWCTest.makeSession(server: server, clock: clock) + try await SIWCTest.signIn(session) + + clock.advance(3_400) + #expect(try await session.accessToken() == "access-2") + let refresh = try #require(server.forms(path: "/api/accounts/oauth/token").last) + #expect(refresh["grant_type"] == "refresh_token") + #expect(refresh["refresh_token"] == "refresh-1") + #expect(refresh["client_id"] == "oaiapp_test") + #expect(refresh["scope"] == nil) + #expect(try await session.store.credential(subject: "user-abc")?.refreshToken == "refresh-2") + + clock.advance(3_600) + let tokens = try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0..<6 { + group.addTask { try await session.accessToken() } + } + return try await group.reduce(into: Set()) { $0.insert($1) } + } + #expect(tokens == ["access-3"]) + #expect(server.forms(path: "/api/accounts/oauth/token").filter { $0["grant_type"] == "refresh_token" }.count == 2) + + // A 401 forces a refresh even though the token has not expired. + #expect(try await session.chatGPTPlanAccessToken(rejectedAccessToken: "access-3") == "access-4") + // A stale rejected token does not refresh again. + #expect(try await session.chatGPTPlanAccessToken(rejectedAccessToken: "access-3") == "access-4") + let pinned = session.tokenProvider(for: "user-abc") + #expect(try await pinned.chatGPTPlanAccessToken(rejectedAccessToken: nil) == "access-4") + } + + @Test("A rejected refresh token clears tokens but keeps the account's client id") + func refreshRejected() async throws { + let server = SIWCFakeServer() + let clock = SIWCTestClock() + let session = SIWCTest.makeSession(server: server, clock: clock) + try await SIWCTest.signIn(session) + server.setTokenHandler { _, _ in SIWCFakeServer.json(["error": "refresh_token_reused"], status: 400) } + clock.advance(4_000) + await #expect(throws: SignInWithChatGPTError.reauthenticationRequired(subject: "user-abc", reason: "refresh_token_reused")) { + try await session.accessToken() + } + #expect(try await session.store.credential(subject: "user-abc") == nil) + let account = try #require(try await session.accounts().first) + #expect(!account.isSignedIn) + #expect(account.clientID == "oaiapp_test") + await #expect(throws: SignInWithChatGPTError.notSignedIn) { + try await session.accessToken() + } + + // Signing in again re-authenticates with the saved client id and hints. + let pending = try await session.beginAuthorization(redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), reauthenticating: "user-abc") + let query = SIWCTest.queryItems(pending.authorizationURL) + #expect(query["client_id"] == "oaiapp_test") + #expect(query["agent_name_hint"] == nil) + #expect(query["login_hint"] == "person@example.com") + } + + @Test("Plan inference requires chatgpt.tokens.use.direct") + func planScopeRequired() async throws { + let server = SIWCFakeServer() + server.useRotatingRefresh(scope: "openid profile email offline_access") + let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock()) + let result = try await SIWCTest.signIn(session) + #expect(!result.account.usesChatGPTPlan) + #expect(!result.shouldShowPlanWelcome) + await #expect(throws: SignInWithChatGPTError.planUsageNotGranted) { + try await session.chatGPTPlanAccessToken(rejectedAccessToken: nil) + } + #expect(try await session.accessToken(requirePlanUsage: false) == "access-1") + } + + @Test("Re-authenticating must return the same account") + func reauthenticationSubjectMismatch() async throws { + let server = SIWCFakeServer() + let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock()) + try await SIWCTest.signIn(session) + server.setTokenHandler { _, _ in + SIWCFakeServer.json(SIWCFakeServer.tokenBody(access: "a", refresh: "r", idToken: SignInWithChatGPTFixtures.Token.otherSubject)) + } + let pending = try await session.beginAuthorization( + redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), + reauthenticating: "user-abc", + consent: .automatic, + secrets: SIWCTest.secrets + ) + await #expect(throws: SignInWithChatGPTError.self) { + try await session.completeAuthorization(callbackURL: SIWCTest.callbackURL(clientID: nil), pending: pending) + } + #expect(try await session.store.credential(subject: "user-abc")?.accessToken == "access-1") + await #expect(throws: SignInWithChatGPTError.unknownAccount("user-nobody")) { + try await session.beginAuthorization(redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), reauthenticating: "user-nobody") + } + } + + @Test("Sign-out revokes the refresh token and keeps the client mapping and host id") + func signOut() async throws { + let server = SIWCFakeServer() + let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock()) + try await SIWCTest.signIn(session) + let hostID = try await session.hostIdentifier() + #expect(try await session.signOut() == .revoked) + #expect(server.forms(path: "/api/accounts/oauth/revoke").last?["token"] == "refresh-1") + #expect(try await session.store.credential(subject: "user-abc") == nil) + #expect(try await session.accounts().first?.clientID == "oaiapp_test") + #expect(try await session.hostIdentifier() == hostID) + await #expect(throws: SignInWithChatGPTError.notSignedIn) { + try await session.signOut() + } + + try await SIWCTest.signIn(session) + server.setRevokeHandler { _, _ in SIWCFakeServer.json(["error": "invalid_request"], status: 400) } + let result = try await session.signOut(subject: "user-abc") + guard case .revocationFailed = result else { + Issue.record("expected revocationFailed, got \(result)") + return + } + #expect(try await session.store.credential(subject: "user-abc") == nil) + + try await session.removeAccount(subject: "user-abc") + #expect(try await session.accounts().isEmpty) + } + + @Test("Credential records export and import between hosts") + func exportImport() async throws { + let server = SIWCFakeServer() + let source = SIWCTest.makeSession(server: server, clock: SIWCTestClock()) + try await SIWCTest.signIn(source) + let record = try await source.exportCredentialRecord() + #expect(String(decoding: record, as: UTF8.self).contains("\"client_id\" : \"oaiapp_test\"")) + + let destinationStore = InMemoryTestCredentialStore() + let destination = SIWCTest.makeSession(server: server, clock: SIWCTestClock(), store: destinationStore) + let account = try await destination.importCredentialRecord(record) + #expect(account.subject == "user-abc") + #expect(account.clientID == "oaiapp_test") + #expect(try await destination.accessToken() == "access-1") + #expect(try await destination.hostIdentifier() != source.hostIdentifier()) + + await #expect(throws: SignInWithChatGPTError.self) { + try await destination.importCredentialRecord(Data("{}".utf8)) + } + } + + #if !os(tvOS) && !os(watchOS) + @Test("Browser sign-in completes through the loopback listener") + func browserSignIn() async throws { + let server = SIWCFakeServer() + let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock(), callbackPort: 0) + let browser = LoopbackCallingBrowser() + let result = try await session.signIn(using: browser, reauthenticating: nil, consent: .automatic, timeout: 20, secrets: SIWCTest.secrets) + #expect(result.account.subject == "user-abc") + #expect(browser.presentedURL.map { SIWCTest.queryItems($0)["client_id"] } == "dynamic_agent_client") + #expect(browser.dismissCount >= 1) + let deadline = Date().addingTimeInterval(10) + while browser.responseBody == nil, Date() < deadline { + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(browser.responseStatuses == [400, 200]) + let page = try #require(browser.responseBody) + #expect(page.contains("Signed in with ChatGPT")) + #expect(page.contains("TestAgent")) + } + + @Test("Closing the browser after the redirect still completes the sign-in") + func browserClosedAfterRedirect() async throws { + let server = SIWCFakeServer() + let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock(), callbackPort: 0) + let browser = LoopbackCallingBrowser(closesAfterCallback: true) + let result = try await session.signIn(using: browser, reauthenticating: nil, consent: .automatic, timeout: 20, secrets: SIWCTest.secrets) + #expect(result.account.subject == "user-abc") + } + + @Test("Closing the browser cancels the sign-in and a silent browser times out") + func browserCancelAndTimeout() async throws { + let server = SIWCFakeServer() + let session = SIWCTest.makeSession(server: server, clock: SIWCTestClock(), callbackPort: 0) + await #expect(throws: SignInWithChatGPTError.cancelled) { + try await session.signIn(using: CancellingBrowser(), timeout: 20) + } + await #expect(throws: SignInWithChatGPTError.timedOut) { + try await session.signIn(using: SignInWithChatGPTExternalBrowser { _ in true }, timeout: 1) + } + await #expect(throws: OpenClawCoreError.self) { + try await session.signIn(using: SignInWithChatGPTExternalBrowser { _ in false }, timeout: 20) + } + #expect(try await session.accounts().isEmpty) + } + #endif +} + +#if !os(tvOS) && !os(watchOS) +/// Browser that plays the authorization server: it calls the loopback redirect with a code. +final class LoopbackCallingBrowser: SignInWithChatGPTBrowser, @unchecked Sendable { + private let closesAfterCallback: Bool + private let lock = NSLock() + private var url: URL? + private var body: String? + private var statuses: [Int] = [] + private var dismissals = 0 + + var presentedURL: URL? { self.lock.withLock { self.url } } + var responseBody: String? { self.lock.withLock { self.body } } + var responseStatuses: [Int] { self.lock.withLock { self.statuses } } + var dismissCount: Int { self.lock.withLock { self.dismissals } } + + /// - Parameter closesAfterCallback: Throw `cancelled` after the redirect, like a user closing the sheet. + init(closesAfterCallback: Bool = false) { + self.closesAfterCallback = closesAfterCallback + } + + func present(_ authorizationURL: URL) async throws { + self.lock.withLock { self.url = authorizationURL } + let query = SIWCTest.queryItems(authorizationURL) + guard let redirect = query["redirect_uri"].flatMap(URL.init(string:)) else { + throw SignInWithChatGPTError.invalidCallback("missing redirect_uri") + } + // A forged callback with the wrong state is ignored by the listener. + var forged = URLComponents(url: redirect, resolvingAgainstBaseURL: false)! + forged.queryItems = [URLQueryItem(name: "code", value: "evil"), URLQueryItem(name: "state", value: "forged")] + var components = URLComponents(url: redirect, resolvingAgainstBaseURL: false)! + components.queryItems = [ + URLQueryItem(name: "code", value: "code-1"), + URLQueryItem(name: "state", value: query["state"]), + URLQueryItem(name: "client_id", value: "oaiapp_test"), + ] + let forgedURL = forged.url! + let callbackURL = components.url! + // The session cancels `present` as soon as the callback lands, so the requests run detached + // (like a real browser) and finish recording their responses regardless. + let recorded = Task.detached { () -> ([Int], String) in + let (_, forgedResponse) = try await URLSession.shared.data(from: forgedURL) + let (data, response) = try await URLSession.shared.data(from: callbackURL) + return ( + [(forgedResponse as? HTTPURLResponse)?.statusCode ?? 0, (response as? HTTPURLResponse)?.statusCode ?? 0], + String(decoding: data, as: UTF8.self) + ) + } + let (statuses, body) = try await recorded.value + self.lock.withLock { + self.statuses = statuses + self.body = body + } + if self.closesAfterCallback { + throw SignInWithChatGPTError.cancelled + } + } + + func dismiss() async { + self.lock.withLock { self.dismissals += 1 } + } +} + +/// Browser that the user closes right away. +struct CancellingBrowser: SignInWithChatGPTBrowser { + func present(_: URL) async throws { + throw SignInWithChatGPTError.cancelled + } + + func dismiss() async {} +} +#endif diff --git a/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTTestSupport.swift b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTTestSupport.swift new file mode 100644 index 0000000..eef73d7 --- /dev/null +++ b/Tests/OpenClawLinuxRuntimeTests/SignInWithChatGPTTestSupport.swift @@ -0,0 +1,198 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@testable import OpenClawCore + +/// Scripted SIWC authorization server: JWKS, token and revocation endpoints. +final class SIWCFakeServer: @unchecked Sendable { + typealias Handler = @Sendable (_ request: URLRequest, _ form: [String: String]) -> HTTPResponseData + + private let lock = NSLock() + private var requests: [URLRequest] = [] + private var jwks: String + private var tokenHandler: Handler + private var revokeHandler: Handler + private var refreshCount = 0 + + init(jwks: String = SignInWithChatGPTFixtures.jwks) { + self.jwks = jwks + self.tokenHandler = { _, form in + if form["grant_type"] == "authorization_code" { + return SIWCFakeServer.json(SIWCFakeServer.tokenBody(access: "access-1", refresh: "refresh-1", idToken: SignInWithChatGPTFixtures.Token.valid)) + } + return SIWCFakeServer.json(["error": "unsupported_grant_type"], status: 400) + } + self.revokeHandler = { _, _ in HTTPResponseData(statusCode: 200, headers: [:], body: Data()) } + } + + var transport: SignInWithChatGPTHTTPTransport { + SignInWithChatGPTHTTPTransport { request in self.handle(request) } + } + + var recordedRequests: [URLRequest] { + self.lock.withLock { self.requests } + } + + func forms(path: String) -> [[String: String]] { + self.recordedRequests.filter { $0.url?.path == path }.map(Self.form) + } + + func setTokenHandler(_ handler: @escaping Handler) { + self.lock.withLock { self.tokenHandler = handler } + } + + func setRevokeHandler(_ handler: @escaping Handler) { + self.lock.withLock { self.revokeHandler = handler } + } + + func setJWKS(_ jwks: String) { + self.lock.withLock { self.jwks = jwks } + } + + /// Token handler that answers code exchanges with `access-1`/`refresh-1` and refreshes with + /// `access-N`/`refresh-N` (N = 2, 3, …). + func useRotatingRefresh(scope: String = SIWCFakeServer.planScope) { + self.setTokenHandler { [weak self] _, form in + guard let self else { return SIWCFakeServer.json([:], status: 500) } + switch form["grant_type"] { + case "authorization_code": + let body = SIWCFakeServer.tokenBody(access: "access-1", refresh: "refresh-1", idToken: SignInWithChatGPTFixtures.Token.valid, scope: scope) + return SIWCFakeServer.json(body) + case "refresh_token": + let count = self.lock.withLock { () -> Int in + self.refreshCount += 1 + return self.refreshCount + } + return SIWCFakeServer.json(SIWCFakeServer.tokenBody( + access: "access-\(count + 1)", + refresh: "refresh-\(count + 1)", + idToken: SignInWithChatGPTFixtures.Token.refresh, + scope: scope + )) + default: + return SIWCFakeServer.json(["error": "unsupported_grant_type"], status: 400) + } + } + } + + private func handle(_ request: URLRequest) -> HTTPResponseData { + let (tokenHandler, revokeHandler, jwks) = self.lock.withLock { () -> (Handler, Handler, String) in + self.requests.append(request) + return (self.tokenHandler, self.revokeHandler, self.jwks) + } + let form = Self.form(request) + switch request.url?.path { + case "/.well-known/jwks.json": + return HTTPResponseData(statusCode: 200, headers: ["Content-Type": "application/json"], body: Data(jwks.utf8)) + case "/api/accounts/oauth/token": + return tokenHandler(request, form) + case "/api/accounts/oauth/revoke": + return revokeHandler(request, form) + default: + return HTTPResponseData(statusCode: 404, headers: [:], body: Data()) + } + } + + static let planScope = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct" + + static func tokenBody(access: String, refresh: String?, idToken: String?, scope: String = planScope, expiresIn: Int = 3600) -> [String: Any] { + var body: [String: Any] = ["access_token": access, "token_type": "Bearer", "expires_in": expiresIn, "scope": scope] + body["refresh_token"] = refresh + body["id_token"] = idToken + return body + } + + static func json(_ object: [String: Any], status: Int = 200) -> HTTPResponseData { + let body = (try? JSONSerialization.data(withJSONObject: object)) ?? Data() + return HTTPResponseData(statusCode: status, headers: ["Content-Type": "application/json"], body: body) + } + + static func form(_ request: URLRequest) -> [String: String] { + guard let body = request.httpBody, let text = String(data: body, encoding: .utf8) else { return [:] } + var fields: [String: String] = [:] + for pair in text.split(separator: "&") { + let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + let name = String(parts[0]).removingPercentEncoding ?? String(parts[0]) + let value = parts.count > 1 ? (String(parts[1]).removingPercentEncoding ?? String(parts[1])) : "" + fields[name] = value + } + return fields + } +} + +/// Mutable test clock. +final class SIWCTestClock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + + init(_ start: Date = Date(timeIntervalSince1970: 1_800_000_000)) { + self.current = start + } + + var now: Date { + self.lock.withLock { self.current } + } + + func advance(_ seconds: TimeInterval) { + self.lock.withLock { self.current = self.current.addingTimeInterval(seconds) } + } + + var closure: @Sendable () -> Date { + { self.now } + } +} + +enum SIWCTest { + static let secrets = SignInWithChatGPTAuthorizationRequest.Secrets( + state: "state-1", + nonce: "nonce-1", + verifier: "verifier-0123456789-0123456789-0123456789-abc" + ) + + static func queryItems(_ url: URL) -> [String: String] { + var items: [String: String] = [:] + for item in URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] { + items[item.name] = item.value ?? "" + } + return items + } + + static func callbackURL(port: UInt16 = 1455, code: String = "code-1", state: String = "state-1", clientID: String? = "oaiapp_test") -> URL { + var components = URLComponents(url: SignInWithChatGPTConfiguration.callbackURL(port: port), resolvingAgainstBaseURL: false)! + var items = [URLQueryItem(name: "code", value: code), URLQueryItem(name: "state", value: state)] + if let clientID { + items.append(URLQueryItem(name: "client_id", value: clientID)) + } + items.append(URLQueryItem(name: "scope", value: SIWCFakeServer.planScope)) + components.queryItems = items + return components.url! + } + + static func makeSession( + server: SIWCFakeServer, + clock: SIWCTestClock, + store: InMemoryTestCredentialStore = InMemoryTestCredentialStore(), + requestsPlanUsage: Bool = true, + callbackPort: UInt16 = 1455 + ) -> SignInWithChatGPTSession { + SignInWithChatGPTSession( + configuration: SignInWithChatGPTClientConfiguration(agentName: "TestAgent", requestsPlanUsage: requestsPlanUsage, callbackPort: callbackPort), + credentialStore: store, + transport: server.transport, + now: clock.closure + ) + } + + /// Signs in `user-abc` through begin/complete with the fixed secrets. + @discardableResult + static func signIn(_ session: SignInWithChatGPTSession) async throws -> SignInWithChatGPTSignInResult { + let pending = try await session.beginAuthorization( + redirectURI: SignInWithChatGPTConfiguration.callbackURL(port: 1455), + reauthenticating: nil, + consent: .automatic, + secrets: self.secrets + ) + return try await session.completeAuthorization(callbackURL: self.callbackURL(), pending: pending) + } +}