WikifitaGitHub live67e8de5
pesquisa · openai-research/auth-and-link-session

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

Baixar raw

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.rs build_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 loopback 127.0.0.1 + ::1; the desktop app uses deep links com.openai.chat:// and has an additional remote-control flow with scope codex.remote_control.enroll + allowed_workspace_id)
  • Alternative without callback: device-code (/api/accounts/deviceauth/usercode, verification auth.openai.com/codex/device)
  • Exchange: POST /oauth/token form-urlencoded {grant_type: authorization_code, code, redirect_uri, client_id, code_verifier}
  • The chatgpt_account_id and chatgpt_plan_type come from the JWT claim https://api.openai.com/auth

Preemptive refresh (official codex-rs semantics)

should_refresh_proactively() (login/src/auth/manager.rs:2894):

  1. Parse exp of the access-token JWT → refresh if exp <= now + 5 minutes (CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES = 5)
  2. 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 ✓ (/me 200); 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