Auth & OAuth
Provider-owned credentials & OAuth flows
“Stored credential beats env; failed refresh never silently falls back”
Deep Dive
Stored credential beats env; failed refresh never silently falls back
Learning Objectives
After this chapter you will be able to:
- Explain the design rationale behind the "stored > env > OAuth" priority chain
- Describe how CredentialStore.modify prevents double-refresh
- Explain the shared infrastructure between PKCE and device-code flows
- Understand why "failed refresh never silently falls back" is a security iron law
Key Source Files
| File | Lines | Responsibility |
|---|---|---|
packages/ai/src/auth/types.ts | ~120 | AuthResult / CredentialStore interfaces |
packages/ai/src/auth/credential-store.ts | ~200 | Persistent credential store: read/modify/delete |
packages/ai/src/auth/resolve.ts | ~150 | resolveProviderAuth: priority chain |
packages/ai/src/auth/oauth/anthropic.ts | 350 | Anthropic Pro/Max OAuth |
packages/ai/src/auth/oauth/openai-codex.ts | 538 | OpenAI Codex OAuth |
packages/ai/src/auth/oauth/pkce.ts | ~180 | PKCE + device-code shared infrastructure |
Design Motivation: Why Is Credential Management So Complex?
Problem: pi users may authenticate through 7 different methods:
- Environment variable
ANTHROPIC_API_KEY(simplest) pi loginvia OAuth (Anthropic Pro/Max subscribers)- Copilot token (VS Code users)
- OpenRouter / xAI / Kimi / Radius each with their own OAuth
- Enterprise SSO proxies
If env-only: Subscribers must manually copy tokens every time, redoing it after expiry. If OAuth-only: CI/CD environments have no browser, cannot do OAuth.
pi's solution: Layered priority + unified storage — every scenario gets an optimal path.
Priority Chain: Stored > Env > OAuth
resolveProviderAuth(model):
① store.read(provider)
├── valid token → use directly ✓
└── expired token → store.modify(provider, refresh())
├── refresh succeeds → use new token ✓
└── refresh fails → throw (NEVER silently use stale token)
② envApiKeyAuth(provider)
└── read ANTHROPIC_API_KEY / OPENAI_API_KEY / ... → use ✓
③ neither → error "authentication required"
Why stored before env?
- After
pi login, the token goes into CredentialStore - If env took priority, a stale env var would override the fresh OAuth token
- Stored credentials represent "the user's explicit latest intent"
CredentialStore.modify: The Only Write Path
class CredentialStore {
async read(provider: string): Promise<Credential | null>
async modify(provider: string, fn: (c: Credential) => Promise<Credential>): Promise<Credential>
async delete(provider: string): Promise<void>
}
Why is modify the only write path?
Concurrent scenario: 3 parallel tool calls all discover the token is expired. Without serialization:
- Call A refreshes → gets token T2
- Call B refreshes → gets token T3 (invalidates T2!)
- Call C refreshes → gets token T4 (invalidates T3!)
With modify's internal mutex:
- Call A enters modify → refreshes → writes T2
- Call B enters modify → reads T2 (valid) → skips refresh
- Call C enters modify → reads T2 (valid) → skips refresh
Analogy: Like a bank teller window — no matter how many people want to update the same account, they queue up and process one at a time.
The "Never Silently Fall Back" Iron Law
const refreshed = await store.modify(provider, async (credential) => {
const fresh = await pkceRefresh(credential.refreshToken)
return fresh // failure throws here — propagates up
})
return { apiKey: refreshed.token }
// If refresh fails, the error propagates — we NEVER do:
// catch { return { apiKey: credential.accessToken } } ← FORBIDDEN
Why is this an iron law?
- A stale token may have been revoked (user changed password, admin disabled access)
- Silently using it = performing operations with revoked permissions = security incident
- Failing loudly forces the user to re-authenticate — correct and safe
Shared PKCE & Device-Code Infrastructure
pkce.ts provides:
├── generateCodeVerifier() → random 128-char string
├── deriveCodeChallenge(v) → SHA-256(verifier) → base64url
├── buildAuthUrl(endpoint, params) → authorization URL
├── exchangeCode(endpoint, code, verifier) → token pair
└── deviceCodeFlow(endpoint, scope) → poll until user confirms
Each provider implements only:
- token endpoint URL
- required scopes
- client_id
Result: Adding a new OAuth provider = one file (~100 lines) that wires up endpoints and scopes. PKCE handshake, callback server, token storage — all shared.
Engineering Insights
-
credential-store.ts ~200 lines: Most complexity is file locking (cross-process safety) and atomic writes (write temp file → rename). pi stores credentials in
~/.pi/credentials.jsonwith 0600 permissions. -
openai-codex.ts is 538 lines: Because OpenAI's OAuth has non-standard quirks — custom redirect scheme, split token endpoint, aggressive expiry. Shared infrastructure handles 80%; the remaining 20% is vendor-specific glue.
-
Auth is resolved before HTTP: resolve.ts never makes network calls. This means Models.stream() (→ Chapter 6) can assemble the full request synchronously — latency only at actual streaming.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | Ch06 Models & Providers | provider.auth calls resolveProviderAuth |
| Prerequisite | Ch07 Wire Protocols | lazyStream's setup() resolves auth internally |
| Follow-up | Ch18 Settings & Trust | Trust model controls which credentials are accessible |
| Follow-up | Ch19 RPC Server | Remote sessions need credential forwarding |
Check Your Understanding
- If env took priority over stored credentials, what user experience problem would arise after
pi login? - Why does modify serialize concurrent refreshes rather than letting all callers refresh in parallel?
- In what scenario would "silently fall back to stale token" create a security vulnerability rather than just a UX annoyance?
Takeaways
- Stored first, env last resort — respects user's latest explicit intent
- modify is the only write path; serializes refresh, prevents double-refresh
- Failed refresh throws — never silently reuses a potentially revoked token
- PKCE + device-code shared infra: new OAuth provider = one ~100-line file
This chapter is based on the main-branch source of earendil-works/pi.