Pixir.Auth.CodexOAuth (pixir v0.1.0)

Copy Markdown View Source

The "Sign in with ChatGPT (Codex)" OAuth flow (ADR 0002), implemented natively over Finch. Supports browser PKCE (localhost callback on 127.0.0.1:1455) and the device-code fallback for headless environments. Constants and request shapes mirror the Pi reference implementation.

Browser flow:

  1. generate_pkce/0 + generate_state/0 + build_authorize_url/2
  2. start_callback_server/1 + wait_for_callback/2 — capture the authorization code at http://localhost:1455/auth/callback
  3. exchange_for_credential/3 — exchange the code at /oauth/token

Device-code flow:

  1. start_device_auth/0 — POST the user-code endpoint → {device_auth_id, user_code, interval}. The user enters user_code at verification_uri.
  2. poll_for_authorization/2 — poll the device-token endpoint until the user approves; the server returns an authorization_code and the PKCE code_verifier it generated.
  3. exchange_for_credential/3 — exchange that code at /oauth/token

refresh/1 swaps a refresh token for a fresh credential. All functions return structured errors (ADR 0005).

Summary

Functions

Extract chatgpt_account_id from an access token's JWT claims.

Registered redirect URI for the browser OAuth flow.

Build the browser authorize URL for the user to open.

Close the callback listen socket.

Exchange an authorization code (+ verifier) for a stored-shape credential.

Generate PKCE verifier/challenge pair for the browser flow.

Generate an OAuth state value for CSRF protection.

Poll until the user approves (or the flow times out). Honors slow_down by widening the interval (RFC 8628). Returns {authorization_code, code_verifier}.

Refresh an expired/expiring subscription credential.

How long before expiry we proactively refresh (ms).

Start the localhost callback listener. Accepts :host, :port (testing).

Begin device authorization. Returns device info to display to the user.

Wait for one browser callback. Requires :state; accepts :timeout_ms.

Types

credential()

@type credential() :: %{
  kind: :subscription,
  access_token: String.t(),
  refresh_token: String.t(),
  expires_at: integer(),
  account_id: String.t(),
  obtained_at: String.t()
}

device()

@type device() :: %{
  device_auth_id: String.t(),
  user_code: String.t(),
  interval: pos_integer(),
  verification_uri: String.t(),
  expires_in: pos_integer()
}

Functions

account_id_from_token(access)

@spec account_id_from_token(String.t()) ::
  {:ok, String.t()} | {:error, :no_account_id}

Extract chatgpt_account_id from an access token's JWT claims.

browser_redirect_uri()

@spec browser_redirect_uri() :: String.t()

Registered redirect URI for the browser OAuth flow.

build_authorize_url(pkce, state, opts \\ [])

@spec build_authorize_url(map(), String.t(), keyword()) :: String.t()

Build the browser authorize URL for the user to open.

close_callback_server(socket)

@spec close_callback_server(port()) :: :ok

Close the callback listen socket.

exchange_for_credential(code, verifier, opts \\ [])

@spec exchange_for_credential(String.t(), String.t(), keyword()) ::
  {:ok, credential()} | {:error, map()}

Exchange an authorization code (+ verifier) for a stored-shape credential.

Pass redirect_uri: for the browser flow; device-code uses the device callback URI by default.

generate_pkce()

@spec generate_pkce() :: %{code_verifier: String.t(), code_challenge: String.t()}

Generate PKCE verifier/challenge pair for the browser flow.

generate_state()

@spec generate_state() :: String.t()

Generate an OAuth state value for CSRF protection.

poll_for_authorization(device, opts \\ [])

@spec poll_for_authorization(
  device(),
  keyword()
) ::
  {:ok, %{authorization_code: String.t(), code_verifier: String.t()}}
  | {:error, map()}

Poll until the user approves (or the flow times out). Honors slow_down by widening the interval (RFC 8628). Returns {authorization_code, code_verifier}.

refresh(refresh_token)

@spec refresh(String.t()) :: {:ok, credential()} | {:error, map()}

Refresh an expired/expiring subscription credential.

refresh_skew_ms()

How long before expiry we proactively refresh (ms).

start_callback_server(opts \\ [])

@spec start_callback_server(keyword()) :: {:ok, port()} | {:error, map()}

Start the localhost callback listener. Accepts :host, :port (testing).

start_device_auth()

@spec start_device_auth() :: {:ok, device()} | {:error, map()}

Begin device authorization. Returns device info to display to the user.

wait_for_callback(socket, opts)

@spec wait_for_callback(
  port(),
  keyword()
) :: {:ok, String.t()} | {:error, map()}

Wait for one browser callback. Requires :state; accepts :timeout_ms.