openai

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 13, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package openai implements the OAuth 2.0 Authorization Code + PKCE flow that OpenAI's Codex CLI uses to authenticate to ChatGPT subscriber backends. This is the "Sign in with ChatGPT" flow — distinct from API-key auth, and intentionally separate from internal/adapter so it can be reused by a future cobra subcommand, the wizard, and a `/login` slash command without circular imports.

The package is raw net/http only, no vendor SDKs — it follows the same template internal/adapter/gemini.go uses.

Index

Constants

View Source
const (
	DefaultClientID    = "app_EMoamEEZ73f0CkXaXp7hrann"
	DefaultIssuerURL   = "https://auth.openai.com"
	DefaultRedirectURI = "http://localhost:1455/auth/callback"
	DefaultOriginator  = "codex_cli_rs"
)

Codex CLI public client constants. Embedded directly because they are public (anyone can inspect a compiled Codex binary) and pulling them from config would just defer the same discoverability.

The redirect host MUST be "localhost" — not 127.0.0.1. OpenAI's auth server has the literal string allow-listed; using the IP surfaces as a generic "unknown_error" page mid-flow.

View Source
const DefaultCodexEndpoint = "https://chatgpt.com/backend-api/codex/responses"

DefaultCodexEndpoint is the ChatGPT-account Responses-API URL the scanner probes against. Distinct from api.openai.com/v1/responses — see the comment on the adapter's OpenAIAuthEndpoint for the per-request contract differences.

Variables

View Source
var DefaultCandidates = []string{
	"gpt-5.5",
	"gpt-5.4",
	"gpt-5.4-mini",
	"gpt-5.3-codex",
	"gpt-5.2",
}

DefaultCandidates is the maintainer-curated list of model names the scanner probes after each successful login. Refresh by editing this slice when OpenAI broadens or narrows the ChatGPT-account allow-list, then rebuild — `yottacode openai-auth login --candidates ...` lets you test new names ad-hoc without rebuilding.

View Source
var DefaultScopes = []string{
	"openid",
	"profile",
	"email",
	"offline_access",
	"api.connectors.read",
	"api.connectors.invoke",
}

DefaultScopes mirrors the Codex CLI flow exactly. offline_access gets us a refresh_token; api.connectors.read/invoke gate the downstream ChatGPT-backend API calls. OpenAI's auth server does not return granted scopes in a stable way, so any discrepancy here surfaces as opaque downstream 401s rather than a clean scope-denied error — match Codex byte-for-byte to avoid drift.

View Source
var ErrModelsNotFound = errors.New("openai-auth: no models file")

ErrModelsNotFound is returned by LoadModels when no models file exists at the resolved path. Callers branch on this to distinguish "user has not scanned yet" from "I/O error".

View Source
var ErrNoModelsAvailable = errors.New("openai-auth: scan returned zero usable models")

ErrNoModelsAvailable is returned by Scan / ScanAndPersist when the scan ran end-to-end but every candidate was rejected. Distinct from network errors so callers can render a more specific message ("your account may not have access to any of the candidates").

View Source
var ErrNoRefreshToken = errors.New("openai-auth: no refresh token; re-login required")

ErrNoRefreshToken is returned by Refresh when the stored token set has none — the offline_access scope was not granted at login, so the caller must re-run the full flow.

View Source
var ErrNotFound = errors.New("openai-auth: no token file")

ErrNotFound is returned by Load when no token file exists at the resolved path. Callers branch on this to distinguish "user has not logged in yet" from "I/O error".

Functions

func AuthURL

func AuthURL(issuer, clientID, redirectURI, state, codeChallenge, originator string, scopes []string) string

AuthURL builds the authorize-endpoint URL for one PKCE flow. The caller is expected to open this URL in the user's browser.

originator and the codex_cli_simplified_flow / id_token_add_organizations flags are not standard OAuth — they are Codex-specific knobs the auth server requires when the request comes from the public Codex client. Omitting any of them yields "unknown_error".

func DefaultModelsPath

func DefaultModelsPath() (string, error)

DefaultModelsPath returns ~/.yottacode/auth/openai-auth-models.json. Sibling of DefaultStorePath — both files live in the auth/ directory already covered by the agent's deny-paths.

func DefaultStorePath

func DefaultStorePath() (string, error)

DefaultStorePath returns ~/.yottacode/auth/openai-auth.json. The auth/ subdirectory is yottacode-managed: the wizard does not write here, only the OAuth flow does. Mode 0700 on the dir, 0600 on the file.

func NewState

func NewState() (string, error)

NewState returns a random URL-safe state value used to defend against CSRF on the loopback redirect. 32 bytes is more entropy than the OAuth spec requires, but cheap.

func OKNames

func OKNames(results []ScanResult) []string

OKNames returns the names of results where OK==true, preserving candidate order. Convenience for the persist call site.

func OpenBrowser

func OpenBrowser(url string) error

OpenBrowser launches the user's default browser at url. A failure here just means we couldn't auto-open it; the caller has already printed the URL via PreLogin so the user can paste it manually.

func Save

func Save(path string, ts TokenSet) error

Save writes ts to path with mode 0600 atomically (tempfile + rename), creating parent dirs (0700) as needed. Atomicity matters because a partial write during refresh could leave an empty file shadowing a previously valid token, forcing a re-login.

func SaveModels

func SaveModels(path string, mf ModelsFile) error

SaveModels writes mf to path with mode 0600 atomically (tempfile + rename), creating parent dirs (0700) as needed. Mirrors Save() in store.go — same atomicity story, same permission story.

func ScanAndPersist

func ScanAndPersist(ctx context.Context, accessToken string) (models []string, err error)

ScanAndPersist runs the scan with default options and on success writes the models file at DefaultModelsPath. On any failure (network, refresh, ErrNoModelsAvailable) the models file is left untouched — callers surface the error to the user.

This is the helper called by the wizard, TUI inline, and CLI login flows; centralising it keeps the post-login UX consistent.

func ScanAndPersistWithOptions

func ScanAndPersistWithOptions(ctx context.Context, accessToken string, opts ScanOptions) (models []string, err error)

ScanAndPersistWithOptions is the configurable form of ScanAndPersist. Tests inject an httptest endpoint here; production callers use the thin wrapper above.

Types

type CallbackResult

type CallbackResult struct {
	Code  string
	State string
	Err   error
}

CallbackResult is what the loopback handler delivered: either the authorization code (success) or the OAuth error pair (failure).

type CallbackServer

type CallbackServer struct {
	Addr string
	Path string
	// contains filtered or unexported fields
}

CallbackServer runs an HTTP server on the loopback port baked into the redirect URI, waits for exactly one /auth/callback hit, and returns the parsed result. The server is single-shot — once the first matching request lands, subsequent ones get the same rendered response but their queries are discarded.

func NewCallbackServer

func NewCallbackServer(redirectURI string) (*CallbackServer, error)

NewCallbackServer parses redirectURI to derive the listen address + path. Only http://127.0.0.1:<port>/<path> (or localhost) is accepted: a non-loopback redirect would expose the OAuth code on the wire, which the OAuth spec forbids for native apps.

func (*CallbackServer) Close

func (c *CallbackServer) Close()

Close shuts the server down. Safe to call multiple times.

func (*CallbackServer) Start

func (c *CallbackServer) Start() error

Start binds the listener and begins serving. Bind is synchronous so callers know whether the port was free before they open the browser — racing the bind would surface as a confusing "redirect_uri unreachable" message in the user's tab.

func (*CallbackServer) Wait

Wait blocks until the callback fires or ctx is canceled. The server is shut down before returning, so the listener port is released even on cancel paths.

type Claims

type Claims struct {
	Subject    string
	Email      string
	AuthMethod string
	Audience   []string
	ExpiresAt  int64
}

Claims is the subset of an OpenAI access-token JWT payload we care about — we surface identity (sub/email) for human-friendly output, not for authorization. The token also carries iat/auth_time/etc. which we ignore.

func DecodeClaims

func DecodeClaims(token string) (Claims, error)

DecodeClaims parses a JWT's payload segment without verifying its signature. The bearer token's authority comes from being presented to OpenAI's resource server, not from local verification, so a signature check here would just add a JWKS-fetch dependency for no benefit.

type LoginOptions

type LoginOptions struct {
	Issuer      string
	ClientID    string
	RedirectURI string
	Scopes      []string

	// Originator goes on the authorize URL as a Codex-specific query
	// parameter the auth server requires. Default "codex_cli_rs" — we
	// reuse the Codex public OAuth client, so we identify as it.
	Originator string

	// HTTPClient overrides the *http.Client used for the token
	// endpoint. Tests inject httptest-backed clients here; production
	// callers leave it nil to use http.DefaultClient.
	HTTPClient *http.Client

	// OpenBrowser, when non-nil, replaces the platform launcher.
	// Tests pass a no-op so the flow can be exercised without
	// spawning a real browser.
	OpenBrowser func(url string) error

	// PreLogin runs once with the authorize URL right before the
	// orchestrator blocks on the callback. Use it to print the URL
	// to stderr so the user can paste it manually if browser launch
	// fails.
	PreLogin func(url string)

	// Timeout caps the wait for the user to complete the browser
	// flow. Zero means no timeout (rely on ctx alone).
	Timeout time.Duration
}

LoginOptions tunes the orchestrator. Zero values pick the Codex CLI defaults so callers can `Login(ctx, LoginOptions{})` with no boilerplate.

type ModelsFile

type ModelsFile struct {
	ScannedAt  time.Time    `json:"scanned_at"`
	Candidates []string     `json:"candidates"`
	Models     []string     `json:"models"`
	Results    []ScanResult `json:"results,omitempty"`
}

ModelsFile is the on-disk shape of a successful model scan. Sibling of TokenSet — same auth/ directory, same 0600 mode, same atomic write template. Wrapping the slice (rather than emitting bare `[]string`) leaves room for future fields like context windows or capability flags without breaking older binaries.

Presence of this file on disk implies "a real scan succeeded at ScannedAt"; failed scans must NOT write the file (the previous file, if any, stays as the last-known-good list).

Models is the OK-only filtered subset that the adapter and catalog consult. Results is the full per-candidate data — same shape the CLI table renders — kept around so users can audit a verdict after the fact ("why is gpt-5.4 in the OK list? — oh, the codex backend returned 429, not 200"). Readers that just want "what can the user pick" should use Models; tooling that wants to debug or re-render the table should use Results.

func LoadModels

func LoadModels(path string) (ModelsFile, error)

LoadModels reads the models file at path. ErrModelsNotFound when missing; any other I/O or decode error propagates verbatim.

type PKCE

type PKCE struct {
	Verifier  string
	Challenge string
}

PKCE holds an RFC 7636 code_verifier + S256 code_challenge pair.

func NewPKCE

func NewPKCE() (PKCE, error)

NewPKCE returns a fresh PKCE pair: 32 random bytes encoded as URL-safe base64 (no padding) for the verifier — 43 chars, well inside RFC 7636's 43–128 range — and SHA-256(verifier) likewise URL-safe-base64-encoded for the challenge.

type PendingLogin

type PendingLogin struct {
	// AuthURL is the URL the browser was launched with. Surface it
	// to the user as a fallback in case the launcher failed.
	AuthURL string
	// contains filtered or unexported fields
}

PendingLogin is an OAuth flow with the synchronous prep done — PKCE generated, callback server listening, browser launched. The caller blocks on Wait to capture the user's redirect.

Lets a tea.Cmd-based caller (wizard, /provider add) surface the auth URL to the user before blocking, so a failed browser launch doesn't strand the user with no way to copy the URL.

func StartLogin

func StartLogin(ctx context.Context, opts LoginOptions) (*PendingLogin, error)

StartLogin runs the synchronous prep phase: generate PKCE+state, start the loopback callback server, build the auth URL, launch the browser (best effort). Returns a PendingLogin the caller blocks on via Wait. Caller MUST call Wait or Close to release the listener.

The two-phase API is for tea.Cmd consumers that want to render the auth URL while the user is signing in. Synchronous CLI callers use Login (the thin wrapper below).

func (*PendingLogin) Close

func (p *PendingLogin) Close()

Close releases the callback server listener without waiting for the user. Used by callers that abort before Wait runs (user cancel, context error). Safe to call after Wait — the server's own Close is idempotent.

func (*PendingLogin) Wait

func (p *PendingLogin) Wait(ctx context.Context) (TokenSet, error)

Wait blocks until the user completes the browser flow and the callback server has the auth code, then exchanges it for tokens. The server is closed when this returns.

type ScanOptions

type ScanOptions struct {
	HTTPClient *http.Client
	Endpoint   string
	Originator string
	Candidates []string
}

ScanOptions tunes Scan / ScanWithToken. Zero values pick the production defaults so callers can `Scan(ctx, path, ScanOptions{})` with no boilerplate.

type ScanResult

type ScanResult struct {
	Name   string `json:"name"`
	OK     bool   `json:"ok"`
	Status string `json:"status"`
	Detail string `json:"detail,omitempty"`
}

ScanResult captures one candidate's outcome after the retry loop. Status is "200" / "OK" on success, the HTTP status code as a string for hard-rejected candidates, or "ERR" when the request never completed (network, marshal, etc.). Detail carries a short server message or the network error text — useful for the CLI table and for the persisted ModelsFile.Results field, which exposes the full per-candidate breakdown so users can audit a verdict (e.g. why gpt-5.4 ended up in the OK set: did we get a 200 or a 429 the heuristic accepted?).

func Scan

func Scan(ctx context.Context, path string, opts ScanOptions) ([]ScanResult, error)

Scan loads the token at path (refreshing if expired), then probes each candidate against the codex backend. Returns one ScanResult per candidate in input order. Network/refresh failures abort the scan — per-candidate transient failures are absorbed by the retry loop and end up in the corresponding ScanResult.

func ScanWithToken

func ScanWithToken(ctx context.Context, accessToken string, opts ScanOptions) []ScanResult

ScanWithToken is the inner form for callers that already hold a fresh access token (the three login flows do, having just exchanged or refreshed it). Skips the Load+Refresh dance.

type TokenSet

type TokenSet struct {
	AccessToken  string    `json:"access_token"`
	RefreshToken string    `json:"refresh_token"`
	IDToken      string    `json:"id_token,omitempty"`
	ExpiresAt    time.Time `json:"expires_at"`
	AccountID    string    `json:"account_id,omitempty"`
	Email        string    `json:"email,omitempty"`
}

TokenSet is the on-disk shape of a successful login. Refresh writes a new TokenSet over the same file. JSON (not .env) because tokens rotate on refresh and rewriting .env in place would clobber user comments and race with shell-loaded values.

func ExchangeCode

func ExchangeCode(ctx context.Context, httpClient *http.Client, issuer, clientID, redirectURI, code, codeVerifier string) (TokenSet, error)

ExchangeCode trades an authorization code for an access + refresh token pair via /oauth/token.

func InlineLogin

func InlineLogin(ctx context.Context, opts LoginOptions) (TokenSet, error)

InlineLogin is the convenience helper for callers that want the full OAuth flow to run + persist tokens to the default store with no extra plumbing. Used by surfaces that block synchronously (typical for a single tea.Cmd that wraps the whole flow).

For interactive surfaces that need to render a "click here" URL while the user is signing in (wizard, /provider add), use StartLogin and PendingLogin.Wait directly so the auth URL is available for fallback rendering.

Returns the persisted TokenSet (also written to disk). The path can be inspected via DefaultStorePath if the caller wants to surface it.

func Load

func Load(path string) (TokenSet, error)

Load reads the token file at path. ErrNotFound when missing; any other I/O or decode error propagates verbatim so the caller can log / surface it.

func Login

func Login(ctx context.Context, opts LoginOptions) (TokenSet, error)

Login runs the full PKCE Authorization Code flow synchronously: StartLogin → Wait. The thin wrapper preserves the original blocking API for cobra commands and the maintainer probe.

func Refresh

func Refresh(ctx context.Context, ts TokenSet, opts LoginOptions) (TokenSet, error)

Refresh trades the stored refresh token for a fresh access token. Returns ErrNoRefreshToken when the stored set has none — caller should re-run Login.

func RefreshToken

func RefreshToken(ctx context.Context, httpClient *http.Client, issuer, clientID, refreshToken string) (TokenSet, error)

RefreshToken exchanges a refresh token for a fresh access token. The auth server may rotate the refresh token; if the response omits one, we echo the input back so the caller's stored value stays coherent.

func (TokenSet) IsExpired

func (ts TokenSet) IsExpired(leeway time.Duration) bool

IsExpired reports whether the access token is at or past its expiry, accounting for a leeway window. Zero ExpiresAt is treated as "not expired" — we don't know, so don't force a refresh.

type TokenSource

type TokenSource struct {
	// contains filtered or unexported fields
}

TokenSource is a thread-safe accessor for the persisted OAuth token. It lazy-loads on first call, refreshes on expiry, and single-flights concurrent refreshes via a mutex so multiple goroutines making simultaneous adapter requests don't all try to refresh at once.

func NewTokenSource

func NewTokenSource(path string) *TokenSource

NewTokenSource returns a TokenSource backed by the file at path. Defaults to the production issuer + Codex client_id; tests override via setters.

func (*TokenSource) Current

func (s *TokenSource) Current() (TokenSet, error)

Current returns the cached TokenSet without doing any I/O. Used for status / diagnostic surfaces (e.g. `auth status`) where we want to display claims even on an expired token.

func (*TokenSource) ForceRefresh

func (s *TokenSource) ForceRefresh(ctx context.Context) error

ForceRefresh bypasses the expiry check and refreshes unconditionally. Adapter callers use this on a 401 — the access token's clock claimed it was valid but the server disagreed, so fall back to the refresh path before giving up.

func (*TokenSource) SetClientID

func (s *TokenSource) SetClientID(id string)

func (*TokenSource) SetHTTPClient

func (s *TokenSource) SetHTTPClient(c *http.Client)

SetHTTPClient overrides the http.Client used for refresh requests. Tests inject httptest-backed clients here; production callers leave it unset (refresh path uses http.DefaultClient).

func (*TokenSource) SetIssuer

func (s *TokenSource) SetIssuer(url string)

SetIssuer / SetClientID let tests point the refresh path at an httptest server with arbitrary fixtures.

func (*TokenSource) Token

func (s *TokenSource) Token(ctx context.Context) (string, error)

Token returns a valid (non-expired) access token. If the cached token is past its expiry minus the leeway window, Token refreshes it (saving the new TokenSet to disk) before returning. ErrNotFound surfaces verbatim when no token file exists at the configured path — the caller's job to translate that into the user-facing "log in" hint (see openAIAuthLoginHint in internal/adapter).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL