Two independent flows live here: the OpenAI/Codex login (below) and the Claude Code login, which share the protocol primitives (PKCE, loopback listener, typed errors, redaction) but persist to different stores.
Browser-based OAuth 2.0 (Authorization Code + PKCE) for the OpenAI provider, as an alternative to pasting a static API key. Every request is served by a short-lived access token that is refreshed transparently; the refresh token is held in the OS credential store and never written to a plaintext config file.
- Layered, single-responsibility components. OAuth protocol, token storage, secret storage, the callback listener, and the provider are separate units.
- Dependency injection everywhere. Every collaborator (fetch, clock, randomness, keyring, browser, callback server) is injected, so the whole system is testable without a network or a browser.
- No singleton / module-level state. Each login owns its instances.
- Async-only, fully typed. No
any; all I/O isPromise-based. - Secrets never leak. Tokens are never logged, never put in error messages,
and never written to plaintext files. See
redaction.ts.
packages/runtime/src/providers/oauth/
├── index.ts Public surface + createOpenAIOAuthProvider()
├── types.ts Shared data types (OAuthTokens, configs, Clock)
├── errors.ts Typed error hierarchy (one per failure mode)
├── redaction.ts Secret redaction for logs/errors
├── pkce-helper.ts PKCEHelper — PKCE verifier/challenge + CSRF state
├── oauth-client.ts OAuthClient — authorize URL, code exchange, refresh, revoke
├── local-callback-server.ts LocalCallbackServer — loopback redirect receiver
├── browser-launcher.ts BrowserLauncher — opens the system browser
├── secure-credential-store.ts SecureCredentialStore — opaque secrets (OS keychain)
├── token-store.ts TokenStore — token persistence over a credential store
├── openai-oauth-provider.ts OpenAIOAuthProvider — orchestration + OpenAI capabilities
├── claude-code-oauth-client.ts ClaudeCodeOAuthClient — Anthropic's JSON token endpoint
├── claude-code-credentials.ts ClaudeCodeCredentialsStore — ~/.claude/.credentials.json
└── claude-code-login.ts ClaudeCodeLogin — orchestration (loopback + manual paste)
packages/runtime/tests/providers/oauth/
├── pkce-helper.test.ts unit
├── oauth-client.test.ts unit
├── stores.test.ts unit (TokenStore + SecureCredentialStore)
├── redaction.test.ts unit
├── local-callback-server.test.ts unit (real loopback sockets)
├── openai-oauth-provider.test.ts unit (orchestration with injected fakes)
├── oauth-flow.integration.test.ts integration (full flow over real sockets)
└── claude-code-oauth.test.ts unit + loopback integration (Claude Code flow)
OpenAIOAuthProvider orchestration + inherited OpenAI capabilities
├─ OAuthClient OAuth 2.0 protocol (PKCE code exchange/refresh)
├─ PKCEHelper PKCE + CSRF-state generation
├─ LocalCallbackServer localhost redirect receiver
├─ BrowserLauncher opens the system browser
└─ TokenStore token persistence
└─ SecureCredentialStore opaque secret persistence (OS keychain)
import { createOpenAIOAuthProvider } from "@nodetool-ai/runtime/oauth";
const provider = createOpenAIOAuthProvider({
clientId: process.env.OPENAI_OAUTH_CLIENT_ID!,
accountId: currentUser.id // namespaces stored tokens
});
// One-time interactive login (opens the browser).
if (!(await provider.isAuthenticated())) {
await provider.login({ timeoutMs: 300_000 });
}
// Use it like any other provider — tokens refresh automatically.
const reply = await provider.generateMessage({ model: "gpt-5.4-mini", messages });
// Later:
await provider.logout(); // revokes + clears stored tokensFor tests, swap in the in-memory backends:
new OpenAIOAuthProvider({
oauthClient: new OAuthClient({ config, fetchFn: fakeFetch, clock }),
tokenStore: new InMemoryTokenStore(),
browserLauncher: { open: async () => {} },
callbackServerFactory: () => fakeServer,
openAIClientFactory: (token) => fakeOpenAIClient
});sequenceDiagram
autonumber
actor User
participant Provider as OpenAIOAuthProvider
participant PKCE as PKCEHelper
participant CB as LocalCallbackServer
participant Browser as BrowserLauncher
participant Auth as OpenAI Auth Server
participant Client as OAuthClient
participant Store as TokenStore
participant Sec as SecureCredentialStore (OS keychain)
User->>Provider: login()
Provider->>CB: listen()
CB-->>Provider: redirectUri (http://127.0.0.1:port/callback)
Provider->>PKCE: createPkcePair() + createState()
PKCE-->>Provider: {verifier, challenge, S256}, state
Provider->>Client: buildAuthorizationUrl(redirectUri, state, challenge)
Client-->>Provider: authorization URL
Provider->>Browser: open(authUrl)
Browser->>Auth: GET /authorize?code_challenge=…&state=…
Auth->>User: login + consent
User->>Auth: approve
Auth->>CB: 302 redirect /callback?code=…&state=…
CB->>CB: validate state == expected (CSRF)
CB-->>Provider: { code }
Provider->>Client: exchangeAuthorizationCode(code, verifier, redirectUri)
Client->>Auth: POST /token (code, code_verifier, client_id)
Auth-->>Client: { access_token, refresh_token, expires_in }
Client-->>Provider: OAuthTokens
Provider->>Store: save(tokens)
Store->>Sec: set("openai:account", JSON)
Provider->>CB: close()
Provider-->>User: authenticated
Failure modes on this path: BrowserLaunchError (open fails),
CallbackTimeoutError (no redirect within the timeout), StateMismatchError
(CSRF), AuthorizationDeniedError (user denies), OAuthNetworkError /
TokenExchangeError (token endpoint).
Triggered lazily by any request (generateMessage / generateMessages /
getAccessToken) when the cached access token is within the expiry skew.
Concurrent callers share a single in-flight refresh.
sequenceDiagram
autonumber
participant Caller
participant Provider as OpenAIOAuthProvider
participant Store as TokenStore
participant Client as OAuthClient
participant Auth as OpenAI Auth Server
Caller->>Provider: generateMessage() / getAccessToken()
Provider->>Provider: ensureValidSession()
alt no session in memory
Provider->>Store: load()
Store-->>Provider: OAuthTokens | null
end
alt access token still valid
Provider-->>Caller: serve with current token
else expired (within skew)
Provider->>Provider: refresh() (single-flight)
alt refresh token present
Provider->>Client: refreshAccessToken(refreshToken)
Client->>Auth: POST /token (grant_type=refresh_token)
alt success
Auth-->>Client: { access_token, refresh_token?, expires_in }
Client-->>Provider: OAuthTokens (reuse old refresh if omitted)
Provider->>Store: save(tokens)
Provider-->>Caller: serve with new token
else invalid_grant / revoked
Auth-->>Client: 400 invalid_grant
Client-->>Provider: InvalidRefreshTokenError / CredentialsRevokedError
Provider->>Store: clear()
Provider-->>Caller: throw (re-login required)
end
else no refresh token
Provider->>Store: clear()
Provider-->>Caller: throw SessionExpiredError
end
end
All errors extend OAuthError and carry a stable code:
| Error | code | Cause |
|---|---|---|
InvalidRefreshTokenError |
invalid_refresh_token |
Refresh rejected (invalid_grant) |
SessionExpiredError |
session_expired |
Expired with no refresh token |
BrowserLaunchError |
browser_launch_failed |
Default browser would not start |
CallbackTimeoutError |
callback_timeout |
No redirect within the timeout |
OAuthNetworkError |
network_error |
DNS/TLS/connection/5xx failure |
CredentialsRevokedError |
credentials_revoked |
Grant revoked server-side |
StateMismatchError |
state_mismatch |
CSRF: state did not match |
AuthorizationDeniedError |
authorization_denied |
User denied consent |
TokenExchangeError |
token_exchange_failed |
Code→token exchange failed |
NotAuthenticatedError |
not_authenticated |
Used before login() |
- ✅ PKCE required —
S256only; verifier never leaves the client until the token exchange. - ✅ CSRF state validation —
LocalCallbackServerrejects any callback whosestatedoes not match before reading thecode. - ✅ Tokens never logged — redacted via
redaction.ts; only non-secret metadata (scope, expiry) is logged. - ✅ No plaintext token files — persistence goes only through
SecureCredentialStore. - ✅ Refresh tokens encrypted by the OS credential store — the default
KeychainSecureCredentialStoreuses the platform keychain (keytar). - ✅ Bearer not exposed to containers —
getContainerEnv()returns{}so the short-lived OAuth token is not baked into a code-runner environment.
Note:
DEFAULT_OPENAI_OAUTH_CONFIGendpoints are sensible placeholders. PointoauthConfigat OpenAI's published authorization/token/revocation endpoints and the client id registered for your application.
This subsystem (a localhost-callback flow that opens the OS browser) is the right fit for desktop/CLI hosts where the Node process can both open a browser and listen on loopback.
The web app drives the same Codex flow from the API server, sharing this
module's protocol layer (OAuthClient + PKCE + LocalCallbackServer). Because
the public Codex client only permits the loopback redirect
http://localhost:1455/auth/callback, the redirect cannot bounce through a
server route — the API process binds the Codex loopback listener itself:
- Backend:
packages/websocket/src/oauth-api.tsexposes/api/oauth/openai/{start,tokens,disconnect}.startbuilds the Codex authorization URL (createCodexOAuthProvider's config — public client id, no secret, Codex scopes/params), binds a one-shotLocalCallbackServeron port 1455, and returns the auth URL; the code exchange completes in the background and persists tokens through the encryptedOAuthCredentialmodel. The published client id is overridable viaCODEX_OAUTH_CLIENT_ID. Because it binds loopback and relies on the same machine's browser, it is a same-machine flow (desktop / local server). - Frontend: the Settings → Integrations panel
(
web/src/components/menus/RemoteSettingsMenu.tsx) renders an "OpenAI Authentication" section with Connect with OpenAI / Disconnect buttons that open the auth URL and poll/api/oauth/openai/tokensfor completion.
ClaudeCodeLogin signs in with a Claude Pro/Max subscription using the same
public OAuth client the claude CLI uses, and writes the result to the file the
Claude Agent SDK authenticates from. A NodeTool login and a claude login are
therefore interchangeable: ClaudeAgentProvider needs no token plumbing, because
the SDK's bundled binary reads the credentials itself.
ClaudeCodeLogin orchestration (begin → complete → persist)
├─ ClaudeCodeOAuthClient protocol: authorize URL, exchange, refresh, profile
├─ PKCEHelper PKCE + CSRF state (shared)
├─ LocalCallbackServer loopback receiver (shared)
├─ BrowserLauncher opens the browser (shared)
└─ ClaudeCodeCredentialsStore ~/.claude/.credentials.json
Anthropic's server is not a stock OAuth 2.0 provider, which is why
ClaudeCodeOAuthClient is a sibling of OAuthClient rather than a
configuration of it:
- The token endpoint takes a JSON body, not
application/x-www-form-urlencoded. - The code exchange echoes the CSRF
statein that body; without it the exchange is rejected. - The authorization URL carries an extra
code=trueflag, which asks the server to also display a paste-able code.
Refresh also narrows the scope set: org:create_api_key is requested at login
(it lets the console mint an API key) but dropped on every refresh.
begin() mints one PKCE pair and state, then hands back both URLs:
- Loopback (
authUrl) — the browser is redirected to an ephemeral127.0.0.1listener; the registered redirect uses thelocalhostspelling, matching the CLI. Only works when the browser runs on this machine. - Manual (
manualAuthUrl) — the console shows<code>#<state>to paste back. The only option on a headless or remote host.
They differ only in redirect_uri, which must match between the authorization
request and the exchange — hence waitForRedirect() and
completeWithPastedCode() are separate completions of the same pending login.
$CLAUDE_CONFIG_DIR/.credentials.json (default ~/.claude/.credentials.json),
mode 0600, written through a same-directory temp file so a concurrent claude
process never reads a half-written file. Keys the CLI owns are preserved; only
claudeAiOauth is replaced:
{
"claudeAiOauth": {
"accessToken": "…",
"refreshToken": "…",
"expiresAt": 1799999999000,
"scopes": ["user:profile", "user:inference", "…"],
"subscriptionType": "max",
"rateLimitTier": null
}
}Because that file is per-machine-user rather than per-NodeTool-user, the login is
process-wide: it is the credential the server's own claude subprocess will use.
refresh() exists for callers that need a live token outside the SDK (status
display, CLAUDE_CODE_OAUTH_TOKEN). Running the provider does not require it —
the CLI refreshes on its own.
nodetool auth claude login # --console, --manual, --no-browser, --json
nodetool auth claude status
nodetool auth claude refresh # --force
nodetool auth claude logoutOver HTTP, packages/websocket/src/oauth-api.ts exposes
/api/oauth/claude/{start,complete,tokens,disconnect}. start binds the
loopback listener and returns both URLs plus the state; complete takes a pasted
code; tokens reports connection status in the shape the shared
useOAuthConnection hook expects. The Models & Providers settings page
renders a sign-in card for it.