ivan-magda/swift-chatgpt-auth
ChatGPTAuth signs a Swift app in to ChatGPT and Codex with the device-authorization OAuth flow, then keeps a fresh bearer token in hand. You show a short code, and once the person approves it in a browser, you get back a credential that renews on its own before it expires.
Table of Contents
Background
ChatGPTAuth began as the sign-in layer of a production personal assistant that talks to Codex on a subscription token. That assistant runs as a background daemon, so it cannot lean on a browser redirect or the Keychain: it needs the device flow, and it needs a credential that renews without a human in the loop. This package lifts that layer out and adds seams so any Apple or Linux app can reuse it.
OpenAI publishes no discovery document for this flow, so ChatGPTAuth pins the endpoints and the Codex client id as constants derived from studying the official clients. They ship as ChatGPTOAuthConfiguration.codex and stay fixed unless you supply your own, so a subscription token only reaches the host you name.
Features
- Device-authorization login.
ChatGPTDeviceLoginruns the whole flow, from the first request to a stored credential, and reports the user code before it starts polling. - A credential that refreshes itself.
ChatGPTCredentialSourceis an actor that hands out a fresh bearer per request and commits each rotation before it uses it, collapsing concurrent callers onto one in-flight refresh. - Bounded vendor values. A token that carries whitespace, a control byte, or non-ASCII text never becomes a header, and the library strips terminal escapes and secrets from remote diagnostics before you see them.
- Your transport, your storage. The HTTP layer and the token store are protocol seams. A bundled
URLSessionHTTPClientworks out of the box; a test injects a scripted double instead. - Typed outcomes. Login and refresh report a small closed set of errors that tell you what to do next: retry, wait, or log in again.
- No third-party runtime dependencies. Foundation and Swift Concurrency only.
Requirements
- iOS 16.0+, macOS 13.0+, tvOS 16.0+, watchOS 9.0+, visionOS 1.0+, or Linux
- Swift 6.0+ / Xcode 16+
Installation
Xcode
In Xcode, open File -> Add Package Dependencies…, enter the repository URL, and add the ChatGPTAuth library to your target:
https://github.com/ivan-magda/swift-chatgpt-authPackage.swift
Add the package to your dependencies:
dependencies: [
.package(url: "https://github.com/ivan-magda/swift-chatgpt-auth", from: "1.0.0")
]Then add ChatGPTAuth to your target:
.target(
name: "YourTarget",
dependencies: [
.product(name: "ChatGPTAuth", package: "swift-chatgpt-auth")
]
)Usage
Log in with the device flow
import ChatGPTAuth
let client = ChatGPTOAuthClient(http: URLSessionHTTPClient())
let login = ChatGPTDeviceLogin(client: client)
let credential = try await login.run { device in
// Show this to the person and send them to the verification page.
print("Go to \(ChatGPTOAuthConfiguration.codex.verificationURL)")
print("Enter code: \(device.userCode)")
}
try myStore.save(credential)run returns once the person approves the code, or throws ChatGPTOAuthFailure.deadlineExceeded once the fifteen-minute window closes.
Keep a fresh bearer for every request
Wrap the stored credential in a ChatGPTCredentialSource and let it authorize each call. It refreshes the token when it is close to expiring and hands the same result to every caller waiting on that one refresh:
let source = ChatGPTCredentialSource(
initialCredential: try myStore.load(),
store: myStore,
oauth: client
)
var request = URLRequest(url: apiURL)
try await source.authorizeRequest(&request)To let the source own the 401 loop as well — attach, send, and on a clean 401 refresh and retry once — hand it the send step. A second 401, spent against the freshly rotated token, latches the source and throws ChatGPTCredentialError.authenticationRequired:
let data = try await source.withAuthorization { authorization in
var request = URLRequest(url: apiURL)
authorization.apply(to: &request)
let (data, response) = try await URLSession.shared.data(for: request)
return (data, (response as? HTTPURLResponse)?.statusCode ?? 200)
}A transport that does not speak URLRequest — a WebSocket handshake, gRPC metadata — reads the bearer and expiry directly instead of parsing headers:
let authorization = try await source.authorization()
connect(bearer: authorization.accessToken) // expiry at authorization.expiresAt, informationalDriving the loop yourself? Report a 401 back with the generation the authorization carried, so a late failure from an older request can never invalidate a newer token:
await source.reject(generation: authorization.generation, disposition: .refresh)To sign out, discard the credential through its owner — memory and store together:
try await source.logout()Provide a token store
The library never touches disk. Conform your own storage to ChatGPTTokenStore:
struct KeychainTokenStore: ChatGPTTokenStore {
func load() throws(ChatGPTTokenStoreError) -> ChatGPTCredential? { /* ... */ }
func save(_ credential: ChatGPTCredential) throws(ChatGPTTokenStoreError) { /* ... */ }
func delete() throws(ChatGPTTokenStoreError) { /* ... */ }
}Test without the network
Every network call runs through HTTPExecuting, so a test injects scripted responses instead of a live transport:
struct StubHTTP: HTTPExecuting {
let result: HTTPResult
func execute(_ request: HTTPRequest) async throws -> HTTPResult { result }
}
let client = ChatGPTOAuthClient(http: StubHTTP(result: cannedTokenResponse))How It Works
- Start.
requestDeviceCodeasks the vendor to begin an authorization and returns a user code plus the interval to poll at. - Approve. The person opens the verification page and enters the code.
- Poll.
ChatGPTDeviceAuthorizationpolls on its own clock, honoring the vendor's interval and any throttle, until the code is approved or the window closes. - Exchange.
ChatGPTDeviceLoginspends the approved grant once at the token endpoint for an access and refresh token. - Refresh.
ChatGPTCredentialSourcemeasures each token against its expiry, redeems the refresh token before the access token lapses, and persists the rotation before spending it.
The wire client never verifies a token signature. It reads the exp and account claims to schedule a refresh and address the right account, and leaves the server to decide whether a token is valid.
Project Structure
Sources/ChatGPTAuth/
├── OAuth/ # device flow, wire client, login facade
├── Credential/ # refreshing credential source, token store seam
├── Token/ # JWT claim reader
├── Metadata/ # pinned endpoints, timing, header builder
├── Wire/ # bounds and sanitizers for vendor values
├── Transport/ # HTTP seam and the URLSession default
└── Support/ # JSON, canonical encoding, redactionContributing
Issues and pull requests are welcome.
swift build
swift test # deterministic suite
CHATGPTAUTH_LIVE_TESTS=1 swift test # opt-in: starts a real device authorization
swiftlint --strict # run before opening a PRTests use Apple's Testing framework and follow Given-When-Then.
License
Released under the MIT License. See LICENSE for details.
Package Metadata
Repository: ivan-magda/swift-chatgpt-auth
Default branch: main
README: README.md