Auth, Preemptive Refresh, and Link-Session
Self-contained OAuth PKCE, token rotation with preemptive refresh (codex-rs semantics), link-session exchange, and cross-surface Bearer
Auth, Preemptive Refresh, and Link-Session
Part of openai-research. Sources: ChatGPT desktop (main + renderer), codex-rs (login/src/auth/manager.rs), openai-oauth (reference).
Self-contained OAuth PKCE
- Public client:
app_EMoamEEZ73f0CkXaXp7hrann— the same one used by the Codex CLI, the desktop app, and codex-rs - Issuer:
https://auth.openai.com—/oauth/authorize+/oauth/token - Scope:
openid profile email offline_access api.connectors.read api.connectors.invoke(the app-server login —codex-rs/login/src/server.rsbuild_authorize_url; the 4-scope variant is a reduced credential with 403 on/f/conversation) - PKCE S256; extra params:
id_token_add_organizations=true,codex_cli_simplified_flow=true - Callback:
http://localhost:1455/auth/callback(dual loopback127.0.0.1+::1; the desktop app uses deep linkscom.openai.chat://and has an additional remote-control flow with scopecodex.remote_control.enroll+allowed_workspace_id) - Alternative without callback: device-code (
/api/accounts/deviceauth/usercode, verificationauth.openai.com/codex/device) - Exchange:
POST /oauth/tokenform-urlencoded{grant_type: authorization_code, code, redirect_uri, client_id, code_verifier} - The
chatgpt_account_idandchatgpt_plan_typecome from the JWT claimhttps://api.openai.com/auth
Preemptive refresh (official codex-rs semantics)
should_refresh_proactively() (login/src/auth/manager.rs:2894):
- Parse
expof the access-token JWT → refresh ifexp <= now + 5 minutes(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES = 5) - Fallback (unparseable exp):
last_refresh < now - 8 days(TOKEN_REFRESH_INTERVAL = 8)
Refresh request: POST /oauth/token JSON {client_id, grant_type: "refresh_token", refresh_token} — response with partial rotation (id_token/access_token/refresh_token all Option; persist the ones returned + last_refresh).
Failure classification: 400 + invalid_grant, 401, and codes refresh_token_expired/refresh_token_reused/refresh_token_invalidated = permanent (re-login); everything else = transient. (openai-oauth uses 55min proactive / 5min margin — same family.)
Implemented in probe v2 (scripts/probes/openai-codex-authorization.ts) as a pre-flight: refresh before any request when the window closes.
Cross-surface Bearer
The same Codex OAuth Bearer authenticates directly on the chat submode backend (chatgpt.com/backend-api/*) — that is how the chat submode of the desktop works (renderer → IPC → main injects the app-server token). Quota is separated by endpoint, not by token:
- Bearer →
/backend-api/codex/*= Codex quota (work submode + Codex mode) - Bearer →
/backend-api/*(chat submode) = chat quota (non-Codex) - Direction is one-way: Codex Bearer → chat submode ✓ (
/me200); chat cookies → codex ✗ (401)
Link-session (token → web session)
POST https://chatgpt.com/api/auth/link-session
headers: Content-Type: application/json
x-i-am-a-browser: true
cookie: <anonymous cookies from a prior GET /api/auth/session>
body: {"auth_token": "<codex access token>", "expires_in": <seconds until exp>}
→ HTTP 200 "OK" + Set-Cookie __oailb=<JWT> (HttpOnly, Secure, SameSite=Lax, Max-Age=3600)
The __oailb is the derived web session (1h, renewable by re-linking) — it authenticates /api/auth/session (payload with the sensitive-session WARNING_BANNER) and /backend-api/me without Bearer. Real usage in the app: only webviews that render site pages (checkout /purchase/*, workspace settings, Sites) — the chat does not use it. Observed limitation: the derived session authenticated /me but saw 0 conversations in history — scope possibly restricted (open).
Official persistence (codex-rs)
auth.json at CODEX_HOME: {tokens: {id_token, access_token, refresh_token, account_id}, last_refresh} — the same format openai-oauth writes. The desktop app keeps it in the native app-server (keychain).
Token exchange inventory (note on "phantom tokens")
No phantom/derived-exchange token exists in the ecosystem (checked the desktop asar, app-server strings and codex-rs). The full token family: OAuth {id_token, access_token, refresh_token} (partial rotation), the __oailb derived web session from link-session (1h, renewable by re-linking), the resume_conversation_token (topic-resume JWT, ES256, stream-scoped) and the one-time attestation_challenge; the only token exchange endpoint is /api/auth/link-session.
TypeScript types (OAuth + refresh + link-session)
// ── OAuth ────────────────────────────────────────────────────────────────
export const OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
export const OAUTH_ISSUER = 'https://auth.openai.com'
export const OAUTH_CALLBACK = 'http://localhost:1455/auth/callback'
/** The app-server login scope (codex-rs build_authorize_url) — reduced variants
* (without the connectors) produce 403-authorization on /f/conversation. */
export const LOGIN_SCOPE =
'openid profile email offline_access api.connectors.read api.connectors.invoke'
export const LOGIN_EXTRA_PARAMS = {
id_token_add_organizations: 'true',
codex_cli_simplified_flow: 'true',
} as const
export interface AuthorizeParams {
response_type: 'code'
client_id: typeof OAUTH_CLIENT_ID
redirect_uri: typeof OAUTH_CALLBACK
scope: typeof LOGIN_SCOPE
code_challenge: string // S256 of the verifier
code_challenge_method: 'S256'
state: string
originator: string // config originator ('Codex Desktop' on the desktop)
} & typeof LOGIN_EXTRA_PARAMS
// form-urlencoded exchange (authorization_code)
export interface TokenExchangeParams {
grant_type: 'authorization_code'
code: string
redirect_uri: typeof OAUTH_CALLBACK
client_id: typeof OAUTH_CLIENT_ID
code_verifier: string
}
export interface RefreshResponse {
access_token?: string
id_token?: string
refresh_token?: string // partial rotation — persist only the returned ones
token_type?: string
expires_in?: number // 864000 observed (10 days)
scope?: string
earliest_refresh_at?: number // observed field — the OAuth-side minimum refresh time
oai_is?: string // observed field — integrity marker for the OAuth token path
}
export interface RefreshParams {
client_id: typeof OAUTH_CLIENT_ID
grant_type: 'refresh_token'
refresh_token: string
}
// ── persistence ──────────────────────────────────────────────────────────
export interface AuthTokens {
id_token?: string
access_token: string
refresh_token?: string
account_id?: string
}
export interface AuthFile {
tokens: AuthTokens
last_refresh: string // ISO — the proactive-refresh fallback (8-day window)
}
// ── link-session ─────────────────────────────────────────────────────────
export interface LinkSessionBody {
auth_token: string // the Codex access token
expires_in: number // seconds until exp
}
export interface LinkSessionHeaders {
'Content-Type': 'application/json'
'x-i-am-a-browser': 'true'
Cookie: string // anonymous cookies from a prior GET /api/auth/session
}
export interface OailbCookie {
name: '__oailb'
value: string // JWT
HttpOnly: true
Secure: true
SameSite: 'Lax'
MaxAge: 3600 // 1 hour — renewable by re-linking
}
// ── token exchange inventory ─────────────────────────────────────────────
export interface TokenFamily {
oauth: AuthTokens // partial rotation
webSession: Pick<OailbCookie, 'name' | 'value'> // __oailb (derived, 1h)
resumeToken: string // topic-resume JWT (ES256, stream-scoped)
attestationChallenge: string // one-time 'gAAAA…'
// NO phantom tokens exist in the ecosystem (verified asar + binary + codex-rs)
}
Cross-references
- Where the Bearer enters each flow: chat-submode-protocol and codex-flow-protocol
- Validation probes: probes
- Flow A placement: flow-definitions