Scope: Complete OAuth implementation in /src/services/oauth/ (5 files, ~1,051 lines)
Date: 2026-04-02
Focus: How Anthropic engineered OAuth; token lifecycle, storage, refresh, revocation, and security vectors
Claude Code implements OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange) for secure CLI authentication to Anthropic services. The implementation follows modern OAuth security practices with explicit attention to:
- PKCE Protection: Prevents authorization code interception attacks
- State Parameter CSRF Protection: Validates authorization code origin
- Secure Token Storage: macOS Keychain + file-based secure storage with encryption
- Token Refresh: Automatic expiration detection with 5-minute buffer; distributed lock-based refresh coordination
- Distributed Lock Mechanism: Cross-process token refresh synchronization prevents concurrent refreshes
- Scope-Based Access Control: Multiple OAuth scopes enable fine-grained permissions (inference, profile, MCP servers, file upload)
- Fallback Support: Manual auth code flow for non-interactive environments
Purpose: Generate PKCE parameters and state token for OAuth security.
Functions:
-
generateCodeVerifier(): string- Creates a random 32-byte code verifier
- Base64-URL encodes the randomBytes(32) buffer
- Removes padding characters (
=), replaces+with-,/with_ - Output: 43-44 character string (RFC 7636 compliant)
- Used once per OAuth flow; stored in OAuthService instance memory
-
generateCodeChallenge(verifier: string): string- SHA256 hash of the code verifier
- Base64-URL encodes the hash digest
- Implements S256 method (only supported method, not plain)
- Sent in authorization request as
code_challengeparam - Server stores this; later validates that
code_verifierhashes to it
-
generateState(): string- Creates a random 32-byte state token
- Base64-URL encodes the randomBytes(32) buffer
- Used for CSRF protection: sent to auth server, returned in callback
- Validated in AuthCodeListener.validateAndRespond()
Security Model:
- Uses
crypto.randomBytes()for cryptographic randomness - PKCE S256 (SHA256) is hardcoded; no fallback to plain
- All values are Base64-URL encoded to be URL-safe
Purpose: Temporary localhost HTTP server that captures OAuth authorization code redirects.
Architecture:
The server listens on localhost:PORT/callback for the OAuth provider's redirect:
http://localhost:PORT/callback?code=AUTH_CODE&state=STATE
Key Classes & Methods:
-
AuthCodeListenerClass- Lifecycle: Created in OAuthService.startOAuthFlow()
- Destroyed: After auth code exchange or error
-
start(port?: number): Promise<number>- Calls
http.createServer()with no request handler yet - Listens on OS-assigned port (port=0) or specified port
- Returns the actual port number allocated by OS
- Resolves before request handlers are attached
- Calls
-
waitForAuthorization(state: string, onReady: () => Promise<void>): Promise<string>- Stores expected state for CSRF validation
- Calls
onReady()(triggers browser open + displays manual URL) - Returns promise that resolves when auth code is received
- Attaches request handlers on first call
-
handleRedirect(req, res): void- Routes all requests; checks if pathname ===
/callback - Parses URL search params:
codeandstate - Delegates to
validateAndRespond()
- Routes all requests; checks if pathname ===
-
validateAndRespond(authCode, state, res): void- CSRF Check: Validates
state === expectedState; rejects if mismatch - Code Validation: Checks
authCodeis present; returns 400 if missing - Stores
resobject for later success/error redirect - Resolves the promise with the auth code
- Does NOT close the response yet — response held open for success redirect
- CSRF Check: Validates
-
handleSuccessRedirect(scopes: string[], customHandler?): void- Called after token exchange succeeds (in OAuthService)
- Routes based on scopes: if has
user:inferencescope → claude.ai success page; else → console success page - Sends 302 redirect to success page
- Only executes if
pendingResponseis set (automatic flow only)
-
handleErrorRedirect(): void- Called if token exchange fails
- Sends 302 redirect to success page (TODO: should be error page)
- Cleans up pending response
-
close(): void- Removes all event listeners (prevents memory leaks)
- Closes the HTTP server
- If pending response exists, calls handleErrorRedirect() first
Security Properties:
- State Validation: Line 164-169 in handleRedirect: Rejects if
state !== expectedState - No Authentication Required: This is not an OAuth server, just a redirect receiver
- Localhost Binding: Binds to
localhost(not0.0.0.0); prevents network-based attacks - Callback Path: Hardcoded to
/callback(configurable in constructor but immutable after) - No HTTPS: Runs on HTTP because it's localhost-only; acceptable per OAuth spec
Token Leakage Vector:
- Authorization code is visible in browser history (code is short-lived, used once)
- Response object held in memory after redirect; could theoretically leak if process crashes before redirect
Purpose: Build authorization URLs, exchange codes for tokens, refresh tokens, fetch profile info.
Constants & Scopes:
CLAUDE_AI_INFERENCE_SCOPE = 'user:inference'
CLAUDE_AI_PROFILE_SCOPE = 'user:profile'
CONSOLE_SCOPE = 'org:create_api_key'
CONSOLE_OAUTH_SCOPES = ['org:create_api_key', 'user:profile']
CLAUDE_AI_OAUTH_SCOPES = ['user:profile', 'user:inference', 'user:sessions:claude_code', 'user:mcp_servers', 'user:file_upload']
ALL_OAUTH_SCOPES = union of all aboveOAuth Server Endpoints:
Production:
CONSOLE_AUTHORIZE_URL: https://platform.claude.com/oauth/authorize
CLAUDE_AI_AUTHORIZE_URL: https://claude.com/cai/oauth/authorize (redirects to claude.ai)
TOKEN_URL: https://platform.claude.com/v1/oauth/token
ROLES_URL: https://api.anthropic.com/api/oauth/claude_cli/roles
API_KEY_URL: https://api.anthropic.com/api/oauth/claude_cli/create_api_key
MANUAL_REDIRECT_URL: https://platform.claude.com/oauth/code/callback
CLIENT_ID: 9d1c250a-e61b-44d9-88ed-5944d1962f5e
Staging/Local:
Different CLIENT_ID and base URLs (environment-configurable)
Functions:
-
buildAuthUrl({codeChallenge, state, port, isManual, ...options}): string- Builds URL to
CONSOLE_AUTHORIZE_URLorCLAUDE_AI_AUTHORIZE_URL - Query parameters:
code=true(tells login page to show Claude Max upsell)client_id(hardcoded or env override)response_type=code(standard OAuth)redirect_uri: automatic flow →http://localhost:PORT/callback; manual flow →https://platform.claude.com/oauth/code/callbackscope(space-separated; either all scopes or justuser:inferenceifinferenceOnly=true)code_challenge(PKCE S256 hash)code_challenge_method=S256(always S256)state(CSRF protection)- Optional:
orgUUID,login_hint,login_method
- Returns fully-formed URL string
- Builds URL to
-
exchangeCodeForTokens(authCode, state, codeVerifier, port, useManualRedirect, expiresIn?): Promise<OAuthTokenExchangeResponse>- POST to TOKEN_URL
- Body:
{ "grant_type": "authorization_code", "code": "AUTH_CODE", "redirect_uri": "http://localhost:PORT/callback" or manual URL, "client_id": "CLIENT_ID", "code_verifier": "FULL_43-CHAR_VERIFIER", "state": "STATE" } - Response: Axios POST with 15s timeout
- Validates response.status === 200
- Logs
tengu_oauth_token_exchange_successevent - Returns:
{ access_token, refresh_token, expires_in, scope, account: {...}, organization: {...} }
-
refreshOAuthToken(refreshToken, {scopes?: string[]}): Promise<OAuthTokens>- POST to TOKEN_URL with grant_type=refresh_token
- Body:
{ "grant_type": "refresh_token", "refresh_token": "REFRESH_TOKEN", "client_id": "CLIENT_ID", "scope": "SCOPES or default CLAUDE_AI_OAUTH_SCOPES" } - Implements Scope Expansion: Backend allows requesting scopes beyond the original grant
- Returns access token, new refresh token (or same if unchanged), expires_in
- Profile Fetch Optimization: Skips
/api/oauth/profilecall if config already has billing info + secure storage has subscription data- Reduces ~7M requests/day fleet-wide
- Falls back to fetch if either is missing
- Updates config with new profile info: displayName, hasExtraUsageEnabled, billingType, accountCreatedAt, subscriptionCreatedAt
- Merges new profile info with existing cached data (null coalescing)
- Logs
tengu_oauth_token_refresh_successortengu_oauth_token_refresh_failure - Token Lifetime Calculation:
expiresAt = Date.now() + expiresIn * 1000
-
fetchProfileInfo(accessToken): Promise<{subscriptionType, rateLimitTier, ...}>- Calls
getOauthProfileFromOauthToken(accessToken)(see getOauthProfile.ts) - Parses
organization.organization_type→ subscription type mapping:claude_max→maxclaude_pro→proclaude_enterprise→enterpriseclaude_team→team- Unknown →
null
- Extracts:
rate_limit_tier,has_extra_usage_enabled,billing_type,display_name,created_at,subscription_created_at - Logs
tengu_oauth_profile_fetch_success
- Calls
-
isOAuthTokenExpired(expiresAt: number | null): boolean- 5-Minute Buffer:
now + 5*60*1000 >= expiresAt - Returns
falseif expiresAt is null (env var tokens with unknown expiry) - Used before calling refreshOAuthToken()
- 5-Minute Buffer:
-
createAndStoreApiKey(accessToken): Promise<string | null>- POST to API_KEY_URL (requires
org:create_api_keyscope) - Response:
{ raw_key: "api_key_string" } - Calls
saveApiKey(apiKey)to store in macOS Keychain or config - Logs
tengu_oauth_api_keysuccess/failure
- POST to API_KEY_URL (requires
-
fetchAndStoreUserRoles(accessToken): Promise<void>- GET to ROLES_URL with Bearer token
- Response:
{ organization_role, workspace_role, organization_name } - Saves to config.oauthAccount
- Logs
tengu_oauth_roles_stored
-
getOrganizationUUID(): Promise<string | null>- Checks config first (no network call if cached)
- Falls back to profile fetch if config missing
- Returns
profile.organization.uuidor null
-
populateOAuthAccountInfoIfNeeded(): Promise<boolean>- Checks env vars first:
CLAUDE_CODE_ACCOUNT_UUID,CLAUDE_CODE_USER_EMAIL,CLAUDE_CODE_ORGANIZATION_UUID - If env vars present, stores to config immediately (eliminates race condition with early telemetry)
- Waits for any in-flight token refresh to complete
- Checks if config already has full profile (billing type, account created, subscription created)
- If missing, fetches profile and stores
- Returns true if freshly populated, false if already cached
- Checks env vars first:
-
storeOAuthAccountInfo({accountUuid, emailAddress, organizationUuid, ...}): void- Saves to global config; used by profile fetch, env var population
- Idempotent: checks if config already matches before updating
Token Lifetime:
- Access tokens: Issued with
expires_in(seconds); converted toexpiresAt(ms since epoch) - Refresh tokens: No explicit expiry; assumed valid until server rejects
- Expiry check: 5-minute buffer means tokens are refreshed 5 minutes before actual expiry
Purpose: Fetch OAuth profile info using access token or API key.
Functions:
-
getOauthProfileFromApiKey(): Promise<OAuthProfileResponse | undefined>- Requires: config.oauthAccount.accountUuid + API key
- GET to
/api/claude_cli_profile - Headers:
x-api-key,anthropic-beta: oauth-2025-04-20 - Params:
account_uuid - Used as fallback if OAuth token unavailable
-
getOauthProfileFromOauthToken(accessToken): Promise<OAuthProfileResponse | undefined>- GET to
/api/oauth/profile - Headers:
Authorization: Bearer {accessToken},Content-Type: application/json - 10s timeout
- Silently logs errors (no throwing)
- Returns undefined on network failures
- GET to
Response Structure (OAuthProfileResponse):
{
account: {
uuid: string
email: string
display_name?: string
created_at?: string
}
organization: {
uuid: string
organization_type: 'claude_max' | 'claude_pro' | 'claude_enterprise' | 'claude_team'
rate_limit_tier?: string
has_extra_usage_enabled?: boolean
billing_type?: string
subscription_created_at?: string
}
}Purpose: Coordinates entire OAuth 2.0 Authorization Code flow with PKCE.
OAuthService Class:
-
Constructor
- Generates code verifier once
- Stored in instance variable (lives until service destroyed)
-
startOAuthFlow(authURLHandler, options?): Promise<OAuthTokens>- High-level orchestration of entire auth flow
- Options:
loginWithClaudeAi: Route to claude.ai or ConsoleinferenceOnly: Request onlyuser:inferencescopeexpiresIn: Custom token lifetimeorgUUID: Pre-populate organizationloginHint,loginMethod: Pre-fill login formskipBrowserOpen: Let caller manage URLs (SDK control protocol)
Flow Steps:
- Create and start AuthCodeListener on random port
- Generate code challenge from verifier (PKCE)
- Generate state token (CSRF)
- Build two URLs:
- Manual flow:
https://platform.claude.com/oauth/code/callback - Automatic flow:
http://localhost:PORT/callback
- Manual flow:
- Call authURLHandler with both URLs:
- If
skipBrowserOpen=true: Let caller decide where to open - Else: Show manual URL to user; open automatic URL in browser
- If
- Wait for auth code via
waitForAuthorizationCode(state, onReady) - Log whether code came from automatic or manual flow
- Exchange code for tokens:
- Calls
client.exchangeCodeForTokens(code, state, codeVerifier, port, isManual) - Passes code verifier (proves we created the code challenge)
- Calls
- Fetch profile info (subscription, rate limit tier)
- Handle success redirect (for automatic flow)
- Format and return OAuthTokens object
- Clean up AuthCodeListener on error or success
-
waitForAuthorizationCode(state, onReady): Promise<string>- Sets up manual auth code resolver
- Delegates to AuthCodeListener.waitForAuthorization()
- Handles both automatic and manual flows
- Automatic: Browser hits
http://localhost:PORT/callback?code=...&state=... - Manual: User pastes code via handleManualAuthCodeInput()
-
handleManualAuthCodeInput({authorizationCode, state}): void- Called when user pastes auth code in CLI prompt
- Resolves the auth code promise
- Closes auth code listener (no need for HTTP server)
-
formatTokens(response, subscriptionType, rateLimitTier, profile?): OAuthTokens- Transforms API response to OAuthTokens
- Calculates
expiresAt = Date.now() + expires_in * 1000 - Parses scopes
- Stores account UUID, email, org UUID if present in response
-
cleanup(): void- Closes AuthCodeListener
- Clears manual auth code resolver
OAuth Flow Sequence:
User → cli /login
↓
OAuthService.startOAuthFlow()
↓
Start AuthCodeListener on localhost
Generate PKCE: verifier + challenge
Generate state token
↓
Build URLs:
- Manual: https://platform.claude.com/oauth/code/callback
- Auto: http://localhost:PORT/callback
↓
Call authURLHandler(manualUrl, autoUrl)
↓
If not skipBrowserOpen:
- Show manual URL to user
- Open browser to auto URL
↓
User logs in at OAuth provider
Provider redirects to manual or auto URL
↓
If auto: AuthCodeListener captures redirect
If manual: User receives code, pastes in CLI
↓
OAuthService receives auth code
↓
Exchange code → tokens:
POST /v1/oauth/token {
grant_type: authorization_code
code: ...
code_verifier: ... (PKCE)
state: ... (CSRF validation)
redirect_uri: ... (matches auth request)
}
↓
Receive: {
access_token
refresh_token
expires_in
scope
account: {uuid, email_address}
organization: {uuid}
}
↓
Fetch profile info (subscription type, rate limit)
GET /api/oauth/profile (Bearer token)
↓
Format OAuthTokens:
{
accessToken
refreshToken
expiresAt: now + expires_in*1000
scopes: parsed scope string
subscriptionType: claude_max|pro|enterprise|team|null
rateLimitTier: rate_limit_tier
profile: full OAuth profile
tokenAccount: {uuid, email, orgUuid}
}
↓
Handle success redirect (auto flow only)
Redirect browser to success page
↓
Return OAuthTokens to caller
File Storage Hierarchy:
~/.claude/
.credentials.json (Secure Storage backed by macOS Keychain or encrypted file)
{
claudeAiOauth: {
accessToken: "..."
refreshToken: "..."
expiresAt: 1234567890000
scopes: ["user:inference", "user:profile", ...]
subscriptionType: "max" | "pro" | "enterprise" | "team" | null
rateLimitTier: "..." | null
}
}
Token Storage Function: saveOAuthTokensIfNeeded(tokens): {success, warning?}
-
Guards:
- Skips if scopes don't include
user:inference(not Claude.ai auth) - Skips if
refreshTokenorexpiresAtmissing (inference-only env tokens)
- Skips if scopes don't include
-
Smart Merge Logic:
- Preserves existing subscription data on refresh
- Falls back to existing value if profile fetch returned null
- Prevents losing subscription type on transient profile fetch failures
-
Storage Update:
- Reads current secure storage
- Merges new tokens with existing data
- Calls
secureStorage.update(storageData) - Clears memoization cache on success
-
Backend Options:
- macOS: Keychain (via SecureStorage wrapper)
- Linux: Encrypted file (libsecret or similar)
- Windows: Credential Manager
Memoized function (caches forever until explicit clear):
- Check
--baremode: Return null if bare-only auth - Check env var
CLAUDE_CODE_OAUTH_TOKEN(e.g., from parent process)- Returns inference-only token:
{accessToken, refreshToken: null, expiresAt: null, scopes: ['user:inference']}
- Returns inference-only token:
- Check file descriptor
CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR- Same inference-only format
- Read secure storage
- Returns
storageData.claudeAiOauthor null
- Returns
- Logs errors; never throws
Async Variant: getClaudeAIOAuthTokensAsync(): Promise<OAuthTokens | null>
- Avoids blocking keychain reads
- Delegates to sync version for env/FD tokens
- Uses async
secureStorage.readAsync()for file-based storage
Problem: Multiple Claude Code instances (terminal 1, 2, ...) read from same keychain. Instance 1 calls /login and refreshes tokens; instance 2's memoize cache is stale.
Solution:
-
File Modification Time Check:
invalidateOAuthCacheIfDiskChanged()- Checks mtime of
~/.claude/.credentials.json - Clears memoize cache if file changed (different process wrote)
- Called at start of
checkAndRefreshOAuthTokenIfNeeded()
- Checks mtime of
-
Keychain Cache TTL: macOS keychain storage has 30s TTL
- Sync reads bounce off memoize (forever) → keychain cache (30s) → actual keychain (slow)
- If memoize misses, keychain cache ensures fresh reads every 30s
Expiration Check: isOAuthTokenExpired(expiresAt: number | null): boolean
- Returns true if
now + 5*60*1000 >= expiresAt(5-minute buffer) - Returns false if expiresAt is null (env tokens with unknown expiry)
- Prevents "token suddenly invalid" surprise when server time differs slightly
Architecture: Distributed Lock-Based Refresh
Problem: Multiple Claude Code instances hit 401 simultaneously; N instances shouldn't all refresh concurrently (thundering herd, duplicate profile fetches).
Solution: File-based distributed lock in ~/.claude/:
1. Check if token expired (local check with 5-min buffer)
2. Re-read from keychain async (another process might have refreshed already)
3. If still expired, acquire file lock: lockfile.lock(~/.claude/)
4. Check AGAIN after acquiring lock (prevent TOCTOU race)
5. If still expired:
- Call refreshOAuthToken(refreshToken, {scopes?})
- Save new tokens: saveOAuthTokensIfNeeded()
- Clear memoize cache
6. Release lock
7. Return true if refresh succeeded, false otherwise
Retry Logic:
- Max 5 retries if lock is held by another process
- Exponential backoff: 1s + random(0-1s) per retry
- Gives other process time to finish refresh and release lock
Concurrent Call Deduplication:
- In-flight promise deduplication via
pendingRefreshCheck - Multiple threads calling
checkAndRefreshOAuthTokenIfNeeded()without retryCount/force share same promise - Prevents thrashing keychain on startup when multiple components initialize
On refresh failure:
- Logs error
- Clears cache and re-reads from keychain
- If different token in keychain, another process succeeded → return true
- If same token still expired → return false
- Caller must handle token unavailable case
Token Refresh Failure Scenario:
- User's subscription expires or is revoked server-side
- Refresh returns 400 "invalid_grant" (refresh token revoked)
- Must re-authenticate:
claude /login
Logical flow (inferred from OAuthService cleanup):
-
performLogout()
- Clears secure storage (removes tokens from keychain)
- Clears memoize cache
- Removes API key if stored
-
Post-logout behavior:
- Next API call without token → 401 error
- Auth layer catches 401 → prompts
/login
Not directly implemented in OAuth client. Server-side revocation happens when:
- User logs out on claude.ai
- User changes password
- User revokes CLI access in account settings
- Subscription cancelled or downgraded
Result: Refresh token becomes invalid → invalid_grant on next refresh attempt.
Problem PKCE Solves:
Authorization code grant vulnerable to interception in mobile/desktop apps (no secure backend):
Attacker intercepts authorization code in redirect
Attacker exchanges code for tokens (if they know client_id + secret)
PKCE prevents this by:
-
Client generates code verifier: Random 43-44 character string
codeVerifier = base64URLEncode(randomBytes(32))
-
Client computes challenge: SHA256 hash of verifier
codeChallenge = base64URLEncode(sha256(codeVerifier))
-
Authorization request includes challenge:
GET /oauth/authorize?...&code_challenge=...&code_challenge_method=S256Server stores code_challenge in authorization record
-
Code exchange includes verifier:
POST /oauth/token { code: "AUTH_CODE", code_verifier: "ORIGINAL_43_CHAR_STRING" }
-
Server validates verifier:
sha256(code_verifier) === stored_code_challengeIf attacker has code but not verifier, they can't exchange it
Implementation Details:
- Code verifier generated once in OAuthService constructor
- Stored in instance memory (not persisted)
- Passed to
exchangeCodeForTokens()which includes it in POST body - If exchange fails, verifier is lost (new flow creates new verifier)
- Server uses S256 (SHA256), not plain method
Request:
GET https://platform.claude.com/oauth/authorize?
client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e
response_type=code
redirect_uri=http://localhost:PORT/callback (or manual)
scope=user:inference+user:profile+...
code_challenge=HASH_OF_VERIFIER
code_challenge_method=S256
state=RANDOM_STATE
code=true (show Max upsell)
login_hint=user@example.com (optional)
login_method=sso (optional)
orgUUID=ORG_ID (optional)
Redirect Response:
- Automatic flow: Browser redirects to
http://localhost:PORT/callback?code=...&state=... - Manual flow: User receives code, pastes in CLI
POST https://platform.claude.com/v1/oauth/token
Request Body (Authorization Code Grant):
{
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "http://localhost:PORT/callback",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"code_verifier": "FULL_43_CHAR_VERIFIER",
"state": "RANDOM_STATE"
}Response (200 OK):
{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"refresh_token": "LONG_STRING",
"expires_in": 3600,
"scope": "user:inference user:profile user:sessions:claude_code user:mcp_servers user:file_upload",
"account": {
"uuid": "ACCOUNT_UUID",
"email_address": "user@example.com"
},
"organization": {
"uuid": "ORG_UUID"
}
}Request Body (Refresh Token Grant):
{
"grant_type": "refresh_token",
"refresh_token": "REFRESH_TOKEN_STRING",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"scope": "user:inference user:profile ..."
}GET https://api.anthropic.com/api/oauth/profile
- Header:
Authorization: Bearer {accessToken} - Response: Full OAuth profile with account, organization, subscription info
GET https://api.anthropic.com/api/claude_cli_profile?account_uuid=...
- Header:
x-api-key: {apiKey},anthropic-beta: oauth-2025-04-20 - Fallback if OAuth token unavailable
Threat:
- Attacker intercepts authorization code in browser redirect
- Attacker exchanges code for tokens using their own client_id
PKCE Mitigation:
- Code verifier generated client-side; never transmitted
- Attacker without verifier cannot exchange code
- SHA256 hash is one-way; can't reverse from code_challenge
Residual Risk: Attacker controls the OAuth server (out of scope)
Threat:
- Attacker tricks user into visiting
oauth/authorizewith attacker's parameters - User approves, redirect to attacker's app with valid code
- Attacker uses code with their client_id
Mitigation:
- OAuthService generates random state token
- AuthCodeListener validates incoming state matches generated state
- Mismatch → 400 error, auth code rejected
- Attacker cannot predict state value
Implementation: Line 164-169 in auth-code-listener.ts:
if (state !== this.expectedState) {
res.writeHead(400)
res.end('Invalid state parameter')
this.reject(new Error('Invalid state parameter'))
return
}Threat:
- Access token is short-lived but visible in browser URL during redirect
- Browser history, logs, proxy logs could leak token
Mitigation:
- Authorization code (not access token) is in redirect URL
- Code is single-use, short-lived (minutes)
- Tokens are exchanged server-side via POST body (not URL)
- No sensitive data in URLs except temporary authorization code
Residual Risk: Replay of authorization code by attacker
- Mitigated by PKCE (attacker lacks code_verifier)
- Mitigated by redirect_uri validation (code only valid for registered URI)
Threat:
- Tokens stored in plaintext on disk
- Malware reads
~/.claude/.credentials.json - Attacker has access token + refresh token
Mitigation:
- macOS: Uses Keychain (encrypted by OS, per-user)
- Linux: Uses libsecret or encrypted file storage
- Windows: Uses Credential Manager
- Tokens never logged (checked via
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHStype) - Code verifier only in memory, never stored
Residual Risk:
- If attacker has local machine access (root), can read keychain
- If malware compromises process memory, can steal tokens mid-refresh
- Encrypted at-rest; encrypted in transit (HTTPS)
Threat:
- Attacker captures token in transit or from storage
- Uses token to call API
Mitigation:
- Tokens are short-lived: 1 hour typical
- Tokens are issued with HTTPS-only transmission
- Tokens bound to specific OAuth client (client_id)
- Tokens bound to specific user account (stored in JWT claims)
- Server validates token signature (JWT)
Residual Risk:
- Attacker with token can use it for 1 hour
- Mitigated by OAuth application scope restrictions (can't do everything with inference token)
Threat:
- Attacker steals refresh token
- Uses it to get new access tokens indefinitely
Mitigation:
- Refresh token can be revoked server-side (user logs out, changes password)
- Server-side revocation prevents future token exchanges
- Client validates token not expired before using
- Refresh tokens stored in Keychain (OS-level encryption)
Residual Risk:
- Until revocation, attacker can refresh indefinitely
- Mitigation requires user awareness (log out when suspicious)
Threat:
- User copies refresh token to another device
- Both devices use same token simultaneously
Note: Not explicitly mitigated. Tokens are personal; not designed for sharing.
Threat:
- Attacker program listens on localhost:9999
- Tricks Claude Code into redirecting to attacker's localhost
Mitigation:
- AuthCodeListener binds to
localhost(not0.0.0.0) - Localhost is loopback; not accessible from network
- OS assigns port randomly; can't be pre-registered by attacker
- Attacker must already have code execution on machine
Residual Risk: If attacker has local code execution, can intercept redirects anyway
Threat:
- User visits attacker's website
- Website displays code prompt asking user to paste code
- User unknowingly enters code for attacker
Mitigation:
- State parameter still validated by AuthCodeListener
- Code must match expected state
- User must paste code voluntarily
Residual Risk: User social engineering (out of scope)
Threat:
- Client thinks token valid, server thinks it's expired (system clock ahead)
- Token accepted by server, later rejected
Mitigation:
- 5-minute buffer in expiry check
- If server rejects token with 401, client forces refresh immediately
handleOAuth401Error()detects 401 and refreshes token- Distributed lock ensures only one refresh happens
- Deduplication prevents race conditions
Implementation: Lines 1360-1392 in auth.ts
HTTP Request Handler (http.ts)
↓
checkAndRefreshOAuthTokenIfNeeded()
↓ (async, non-blocking)
getClaudeAIOAuthTokens()
↓
Access Token Valid?
├─ No → Refresh Token
│ POST /v1/oauth/token + refreshToken
│ Save new tokens
│ Clear caches
└─ Yes → Use token
↓
axios.request({
headers: {
Authorization: `Bearer ${accessToken}`
}
})
↓
Receive 200 or 401
├─ 200 → Return response
└─ 401 → handleOAuth401Error(token)
Force refresh + retry
Problem: Keychain read on macOS is slow (~15-100ms per access).
Solution: Prefetch at startup (src/utils/secureStorage/keychainPrefetch.ts):
- Background spawn of
security find-generic-passwordat main.tsx top-level - Completes while imports are running (parallelism)
- Result cached for first getApiKeyFromConfigOrMacOSKeychain() call
- Avoids blocking initial API request
USER_TYPE=ant → Allow staging/local OAuth
USE_LOCAL_OAUTH=true → Use localhost:8000 (developer only)
USE_STAGING_OAUTH=true → Use staging endpoints
CLAUDE_CODE_OAUTH_TOKEN=<token> → Force env-var token (service key)
CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=N → Token from FD N
CLAUDE_CODE_ACCOUNT_UUID=<uuid> → Pre-populate account (SDK)
CLAUDE_CODE_USER_EMAIL=<email> → Pre-populate email (SDK)
CLAUDE_CODE_ORGANIZATION_UUID=<uuid> → Pre-populate org (SDK)
CLAUDE_CODE_OAUTH_CLIENT_ID=<client_id> → Override client ID (Xcode integration)
CLAUDE_CODE_CUSTOM_OAUTH_URL=<base_url> → Override all OAuth URLs (FedStart only)
https://beacon.claude-ai.staging.ant.dev
https://claude.fedstart.com
https://claude-staging.fedstart.com
Only FedStart/PubSec deployments supported to prevent credential leakage.
| Stage | Location | TTL | Format | Scope |
|---|---|---|---|---|
| Code Verifier | Memory (OAuthService) | Flow duration (~5min) | 43-44 char base64url | N/A |
| Auth Code | Browser redirect | Minutes (backend configured) | Random string | Single-use |
| Access Token | Secure storage + memory (memoized) | 1 hour (typical) | JWT | User:inference + others |
| Refresh Token | Secure storage only (Keychain) | Months (no client-side expiry) | Opaque string | Refresh only |
| State Token | Memory (AuthCodeListener) | Flow duration | 32 bytes base64url | CSRF validation |
┌─────────────────────────────────────────────────────────────────┐
│ Client (Claude Code) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Step 1: Generate Verifier (one-time, never transmitted) │
│ ──────────────────────────────────────────────────────────────│
│ verifier = base64URLEncode(randomBytes(32)) │
│ → 43-44 character random string │
│ │
│ Step 2: Compute Challenge (sent to server) │
│ ──────────────────────────────────────────────────────────────│
│ challenge = base64URLEncode(sha256(verifier)) │
│ → 43 character hash │
│ │
│ Step 3: Authorization Request (browser) │
│ ──────────────────────────────────────────────────────────────│
│ GET /oauth/authorize? │
│ ...&code_challenge=CHALLENGE&code_challenge_method=S256 │
│ │
│ → Server stores: challenge, method=S256 │
│ │
│ Step 4: User Approves, Receives Code │
│ ──────────────────────────────────────────────────────────────│
│ Redirect: localhost/callback?code=AUTH_CODE&state=STATE │
│ │
│ → Code tied to specific challenge in server │
│ │
│ Step 5: Token Exchange (PKCE Validation) │
│ ──────────────────────────────────────────────────────────────│
│ POST /oauth/token { │
│ grant_type: authorization_code │
│ code: AUTH_CODE │
│ code_verifier: ORIGINAL_43_CHAR_VERIFIER │
│ } │
│ │
│ → Server: sha256(verifier) === stored_challenge ✓ │
│ → If mismatch: Reject with 400 invalid_grant │
│ → If match: Return {access_token, refresh_token} │
│ │
└─────────────────────────────────────────────────────────────────┘
Not fully supported in current implementation. However:
-
Environment-based account switching:
- Set
CLAUDE_CODE_OAUTH_TOKENto different token - System uses that token instead of keychain token
- Allows running CLI with different credentials per invocation
- Set
-
Org UUID support:
- OAuth request can include
orgUUIDparameter - Pre-selects organization on login page
- Useful for CLI in managed environments
- OAuth request can include
-
Account info storage:
- Only one account cached in
~/.claude/.credentials.json - Multiple accounts not supported (would require account selection UI)
- Token refresh always applies to stored account
- Only one account cached in
-
AuthCodeListener socket error:
- Rejects with Error: "Failed to start OAuth callback server"
- User sees error in CLI
-
Token exchange network error:
- Axios 15s timeout
- Retries handled by caller (auth.ts)
- If persistent: User must retry
/login
-
Profile fetch failure:
- Silently logs error
- Returns undefined
- Falls back to existing profile data in config
- Prevents total failure due to transient API issues
-
Token refresh failure:
- Logs error
- Retries with exponential backoff + distributed lock
- Max 5 retries over ~5 seconds
- If all fail: Returns false, caller handles token unavailable
Problem: Multiple CLI instances; instance 1 refreshes, instance 2 doesn't know.
Solution:
- File mtime check: Detect if
~/.claude/.credentials.jsonchanged - Keychain cache TTL: macOS cache refreshed every 30s
- Memoize cache: Explicitly cleared after refresh + on explicit clear commands
user:inference- Use Claude API (read-only)user:profile- Read account profile (name, org, subscription)org:create_api_key- Create API keys for Consoleuser:sessions:claude_code- Manage CLI sessions (?)user:mcp_servers- Manage MCP serversuser:file_upload- Upload files to Claude
Backend allows requesting scopes beyond original grant:
- User logs in with inference-only scope
- Later, feature needs file_upload scope
- Refresh request includes new scope
- Backend honors expansion (per
ALLOWED_SCOPE_EXPANSIONSconfig) - User never sees consent dialog again
Implementation (client.ts:159-162):
scope: (requestedScopes?.length
? requestedScopes
: CLAUDE_AI_OAUTH_SCOPES
).join(' ')Strengths:
- ✓ PKCE prevents authorization code interception
- ✓ State parameter prevents CSRF
- ✓ Tokens stored in OS Keychain (encrypted)
- ✓ 5-minute expiry buffer prevents surprise expirations
- ✓ Distributed lock prevents concurrent refresh thundering herd
- ✓ Localhost binding prevents network-level attacks
- ✓ Scope expansion prevents re-auth friction
Weaknesses:
- ✗ No token rotation on refresh (refresh token reused unless revoked)
- ✗ Manual flow vulnerable to social engineering (user pastes code)
- ✗ No explicit device binding (tokens valid on any machine)
- ✗ Error redirect uses same page for success/error (TODO comment)
- ✗ Profile fetch errors silently logged (could hide auth issues)
Operational:
- Solid cache invalidation strategy for multi-instance scenarios
- Exponential backoff + lock timeout prevents distributed deadlock
- Comprehensive error logging for debugging auth issues
- Analytics instrumentation for monitoring OAuth health
End of Analysis