ππ Agent
Chapter 8 / 21

Auth & OAuth

Provider-owned credentials & OAuth flows

Stored credential beats env; failed refresh never silently falls back
No simulation scenario available for this chapter

Deep Dive

~7 min read·6529 chars

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

FileLinesResponsibility
packages/ai/src/auth/types.ts~120AuthResult / CredentialStore interfaces
packages/ai/src/auth/credential-store.ts~200Persistent credential store: read/modify/delete
packages/ai/src/auth/resolve.ts~150resolveProviderAuth: priority chain
packages/ai/src/auth/oauth/anthropic.ts350Anthropic Pro/Max OAuth
packages/ai/src/auth/oauth/openai-codex.ts538OpenAI Codex OAuth
packages/ai/src/auth/oauth/pkce.ts~180PKCE + 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 login via 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

  1. 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.json with 0600 permissions.

  2. 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.

  3. 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

DirectionChapterRelationship
PrerequisiteCh06 Models & Providersprovider.auth calls resolveProviderAuth
PrerequisiteCh07 Wire ProtocolslazyStream's setup() resolves auth internally
Follow-upCh18 Settings & TrustTrust model controls which credentials are accessible
Follow-upCh19 RPC ServerRemote sessions need credential forwarding

Check Your Understanding

  1. If env took priority over stored credentials, what user experience problem would arise after pi login?
  2. Why does modify serialize concurrent refreshes rather than letting all callers refresh in parallel?
  3. 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.