OAuth Auth
The @framers/agentos/auth subpath export provides OAuth primitives: a browser-based OAuth 2.0 authorization-code flow with PKCE for OpenAI (obtaining API access from a ChatGPT subscription, as the Codex CLI does), the same flow for Twitter, Instagram, LinkedIn and Facebook, and the token store, callback server and PKCE helpers they share. For a web server it provides the same grant split across two requests (RedirectOAuthFlow), secrets sealed at rest with AES-256-GCM (sealSecret, openSecret) and a token store that keeps each grant sealed (SealedTokenStore).
Architecture
@framers/agentos/auth
├── types.ts # Core interfaces: IOAuthFlow, IOAuthTokenStore, OAuthTokenSet
├── FileTokenStore.ts # File-based token persistence (~/.wunderland/auth/)
├── OpenAIOAuthFlow.ts # OpenAI browser PKCE flow (Codex CLI client)
├── BrowserOAuthFlow.ts # Abstract base for browser authorization-code + PKCE flows
├── TwitterOAuthFlow.ts, InstagramOAuthFlow.ts, LinkedInOAuthFlow.ts, FacebookOAuthFlow.ts
├── RedirectOAuthFlow.ts # A web server's authorization-code + PKCE flow, split across two requests
├── sealing.ts # sealSecret, openSecret: AES-256-GCM with key ids and a bound context
├── SealedTokenStore.ts # IOAuthTokenStore that keeps a grant's refresh token sealed
├── callback-server.ts # Local callback server (startCallbackServer)
├── pkce.ts # generateCodeVerifier, generateCodeChallenge, generateState
├── utils.ts # openBrowser, isTokenValid
└── index.ts # Barrel export
Core Interfaces
type AuthMethod = 'api-key' | 'oauth';
interface OAuthTokenSet {
accessToken: string;
refreshToken?: string;
expiresAt: number; // Unix epoch ms
idToken?: string;
metadata?: Record<string, string>;
}
interface IOAuthFlow {
readonly providerId: string;
authenticate(): Promise<OAuthTokenSet>;
refresh(tokens: OAuthTokenSet): Promise<OAuthTokenSet>;
isValid(tokens: OAuthTokenSet): boolean;
getAccessToken(): Promise<string>;
}
interface IOAuthTokenStore {
load(providerId: string): Promise<OAuthTokenSet | null>;
save(providerId: string, tokens: OAuthTokenSet): Promise<void>;
clear(providerId: string): Promise<void>;
}
These interfaces are provider-agnostic. IOAuthFlow defines the contract for any OAuth-based LLM provider authentication, and IOAuthTokenStore abstracts token persistence.
OpenAI Implementation
OpenAIOAuthFlow implements IOAuthFlow with the browser-based authorization-code flow and PKCE, using the Codex CLI's public client ID (app_EMoamEEZ73f0CkXaXp7hrann).
Flow
- Generate a PKCE code verifier and challenge, and a
statevalue. - Start a callback server on
localhost:1455. - Open the system browser at
https://auth.openai.com/oauth/authorize; the user logs in and OpenAI redirects tohttp://localhost:1455/auth/callback. - Exchange the authorization code and verifier at
https://auth.openai.com/oauth/token. - Exchange the returned
id_tokenfor an OpenAI API key at the same endpoint (token exchange,requested_token: openai-api-key); when that exchange fails, the OAuth access token is kept instead. - Save the token set to the token store.
The callback is awaited for up to 10 minutes. A refresh posts the refresh token to https://auth.openai.com/oauth/token.
Usage
import { OpenAIOAuthFlow, FileTokenStore } from '@framers/agentos/auth';
const flow = new OpenAIOAuthFlow({
tokenStore: new FileTokenStore(),
onBrowserOpen: (authUrl) => {
console.log(`Opening ${authUrl}`);
},
});
// Interactive login: opens the browser and waits for the callback
const tokens = await flow.authenticate();
// Get a usable token (refreshes it when it is within 5 minutes of expiry)
const apiKey = await flow.getAccessToken();
// Check validity
flow.isValid(tokens); // true if not expired (with 5-min buffer)
getAccessToken() throws when the store holds no tokens for openai; run authenticate() first.
Options
interface OpenAIOAuthFlowOptions {
tokenStore?: IOAuthTokenStore; // Default: FileTokenStore
clientId?: string; // Default: Codex CLI public client ID
onBrowserOpen?: (authUrl: string) => void; // Called before the browser opens
}
FileTokenStore
Stores tokens as JSON files at ~/.wunderland/auth/{providerId}.json with 0o600 permissions.
import { FileTokenStore } from '@framers/agentos/auth';
const store = new FileTokenStore(); // Default: ~/.wunderland/auth/
const store2 = new FileTokenStore('/custom'); // Custom directory
await store.save('openai', tokens);
const loaded = await store.load('openai'); // OAuthTokenSet | null
await store.clear('openai'); // Deletes the file
Features:
- Creates directories recursively if they don't exist
- Sanitizes provider IDs to prevent path traversal
- Returns
nullfor corrupted or invalid JSON - Works with any
providerIdstring
Web Server Flow
A web server runs the grant across two requests: the one that sends the person to the provider, and the provider's redirect back. RedirectOAuthFlow splits the authorization-code flow with PKCE at that redirect. begin answers the address with its state and PKCE codeVerifier; the server keeps both (in a short-lived row found by a cookie, for example) and hands them back to complete. The flow holds each grant's access token in the process's memory alone: it hands its store the grant's refresh token and metadata, as a token set with accessToken: '' and expiresAt: 0, and never the access token or the id token, so no store is given them to write. A SealedTokenStore keeps the refresh token and the metadata sealed.
import { RedirectOAuthFlow, SealedTokenStore, type RedirectOAuthConfig } from '@framers/agentos/auth';
class ExampleOAuthFlow extends RedirectOAuthFlow {
readonly providerId = 'example';
protected getConfig(): RedirectOAuthConfig {
return {
authorizationEndpoint: 'https://auth.example.com/authorize',
tokenEndpoint: 'https://auth.example.com/token',
revocationEndpoint: 'https://auth.example.com/revoke',
scopes: ['files.read'],
clientId: process.env.EXAMPLE_CLIENT_ID ?? '',
clientSecret: process.env.EXAMPLE_CLIENT_SECRET,
};
}
}
// Sealed grants live in the server's own table, one row per key.
const store = new SealedTokenStore(
{
get: (key) => grants.sealedText(key), // the row's sealed text, or null
set: (key, sealed) => grants.write(key, sealed),
delete: (key) => grants.remove(key),
},
{ current: { id: 'k2', key: currentKey }, previous: [{ id: 'k1', key: previousKey }] },
{ namespace: JSON.stringify([tenantId, accountId, 'example']) },
);
const flow = new ExampleOAuthFlow(store);
// Request 1: send the person to `url`; keep `state` and `codeVerifier` on the server.
const { url, state, codeVerifier } = flow.begin({ redirectUri: 'https://app.example.com/oauth/callback' });
// Request 2: the provider redirects back with `code` and `state`.
const tokens = await flow.complete({
code,
state: returnedState,
expectedState: state,
codeVerifier,
redirectUri: 'https://app.example.com/oauth/callback',
});
await flow.keep('connection-42', tokens);
// Any later request, in any process that reads the same table:
const accessToken = await flow.accessToken('connection-42');
// Disconnecting: revoke at the provider, then clear the grant whatever the provider answered.
const { revoked } = await flow.forget('connection-42');
begin({ redirectUri, extraParams?, scopes? })builds the authorization address withresponse_type=code,client_id,redirect_uri,scope(the configured scopes, orscopes, joined by spaces), a freshstate,code_challenge(the S256 challenge of a fresh verifier) andcode_challenge_method=S256, plus anyextraParams, such asaccess_type: 'offline'. An extra parameter with the name of one of those seven is left out, so the address always carries thestatethatbeginanswers and the challenge of the verifier it answers. A query thatauthorizationEndpointalready has is kept (RFC 6749 section 3.1); a parameter in it with the name of one thatbeginadds is replaced, so none of thosebeginadds is sent twice.complete(input)comparesstatewithexpectedStatein constant time and throwsOAuthGrantRefusedwith reasonstate, calling no endpoint, when they differ orexpectedStateis empty. It then postsgrant_type=authorization_code, the code, the redirect address, the verifier and the client's credentials to the token endpoint as a form, withAccept: application/json, and answers the token set:expiresAtfromexpires_in(an hour when the answer has none),metadata.scopefromscope.- A token endpoint answer that is not a success, or whose
access_tokenis missing or empty, throwsOAuthGrantRefusedwith its reason (exchangeorrefresh) and the HTTPstatus. Its message contains only the local reason and numeric status.providerErrorretains only these RFC 6749 codes:invalid_grant,invalid_client,invalid_request,unauthorized_client,unsupported_grant_type,invalid_scope,access_denied,server_error,temporarily_unavailable. Other provider error text is discarded. A body that is not a JSON object, such asnull, an array, a string or a number, is read as an answer with no fields, so it is refused the same way. keep(key, tokens)saves the grant's refresh token and metadata in the store and holds the token set in memory. EveryaccessToken(key)loads the stored grant before answering a cached token: deletion evicts the cache and throwsOAuthGrantRefusedwith reasonmissing; a changed refresh token or metadata evicts the cache and refreshes the current stored grant. A matching cached token is usable untilrefreshBufferMs(five minutes by default) before expiry. At that boundary, or without a cached token, the flow refreshes from the stored refresh token, one refresh at a time per key, and holds the result. A refused refresh never falls back to the cached token. A refresh token in the answer replaces the stored one; an answer without one leaves it in place. With no stored refresh token to refresh, it throws with reasonmissing.revoke(token)posts the token alone as a form torevocationEndpoint(RFC 7009) and throws when the flow has none.forget(key)revokes the stored refresh token, then clears the store and the memory forkeywhatever the provider answered, and answers{ revoked }.- Within one flow,
keep, everyaccessTokenread andforgetof the same key run one after another, in the order called. A read afterforgetwaits for it, including when an earlier refresh is still pending.forgetrevokes the refresh token that an earlier refresh leaves; a laterkeepsaves its grant afterforgetclears the old one. - After the token endpoint answers, a refresh reloads the store before saving or returning its result. If the grant was deleted or its refresh token or metadata changed, the result is discarded with reason
missing. After saving a rotated token or changed metadata, it checks again before caching or returning the access token. These checks require authoritative store reads and detect changes visible at those reads. They are not atomic across flows:IOAuthTokenStorehas no conditional save, generation or shared claim. Another flow can change a grant between a check and a save or token return, and deleting then recreating identical grant fields is indistinguishable from no change. For strict disconnect ordering and refresh-token rotation across flows or processes, the caller must serialize all operations for a grant through one flow or a shared external coordinator, including direct store writes. Two uncoordinated flows can still refresh the same grant at once.
The default token calls send the client id, and the client secret when one is configured, in the form body, which RFC 6749 section 2.3.1 lets a server accept as well as HTTP Basic. A provider whose endpoints differ overrides exchangeCode, refreshTokens, postExchange or revoke.
Sealed Secrets
sealSecret(plain, key, keyId, context?) seals a string or bytes with AES-256-GCM under a 32-byte key, with a random 96-bit nonce for each seal, into v1.<keyId>.<base64url(nonce | ciphertext | tag)>. The context, for example the id of the row the value belongs to, is bound as additional authenticated data, so a sealed value copied to another row does not open there. openSecret(sealed, keys, context?) opens it with the key whose id the text names and answers the bytes, or throws SealedSecretError with reason format (not a version 1 sealed text), key (no key with that id was given) or tampered (the key, the bytes or the context differ from the seal's). A key id is 1 to 32 letters, digits, - or _, and sealSecret refuses a key that is not 32 bytes.
To turn a key over, seal with the new key and keep the old one among the keys that open.
SealedTokenStore
SealedTokenStore is an IOAuthTokenStore over three functions the server gives: get, set and delete of a sealed text by key. Its required third constructor argument is { namespace: string }, a nonempty, stable scope identifying the tenant, account and provider of those rows. Choose distinct namespaces for distinct scopes even when they share sealing keys. save(key, tokens) seals the refresh token and the metadata with the current key, bound to the unambiguous JSON tuple ["SealedTokenStore", namespace, key] as authenticated additional data. A sealed grant copied between namespaces cannot load even at the same row key. The access token and the id token are never kept. load(key) answers the refresh token and the metadata with accessToken: '' and expiresAt: 0, so a flow treats the grant as expired and refreshes it, or null when nothing is kept; a value that does not open throws SealedSecretError. A grant sealed with one of the previous keys loads in the same namespace, and its next save seals it with current.
The generic sealed envelope remains v1; it versions the encryption format, not the store's context. Values written by the earlier, unreleased store with only the row key as context no longer load. There is deliberately no row-only fallback because it would permit substitution across namespaces. Reconnect those grants using the required namespace; this changes an unreleased store format, not a published compatibility contract.
Integration with LLM Providers
The OpenAIProvider in AgentOS core accepts an optional oauthFlow config:
import { OpenAIProvider } from '@framers/agentos/core/llm/providers/implementations/OpenAIProvider';
const provider = new OpenAIProvider();
await provider.initialize({
apiKey: '', // not needed when oauthFlow is set
defaultModelId: 'gpt-4o',
oauthFlow: flow, // { getAccessToken(): Promise<string> }
});
When oauthFlow is set, the provider calls getAccessToken() before each API request instead of using the static apiKey.
Adding New Providers
To add OAuth support for a new LLM provider:
- Create a new class implementing
IOAuthFlow - Set
providerIdto the provider's registry ID (e.g.,'anthropic') - Implement the provider's OAuth flow in
authenticate() - Implement token refresh in
refresh() - Use
FileTokenStoreor a customIOAuthTokenStorefor persistence
export class ExampleOAuthFlow implements IOAuthFlow {
readonly providerId = 'example-provider';
// ... implement authenticate(), refresh(), isValid(), getAccessToken()
}
The FileTokenStore automatically namespaces by providerId, so multiple providers can coexist.
LLM Provider Support
| Provider | OAuth Status | CLI Provider Alternative |
|---|---|---|
| OpenAI | Supported: the Codex CLI's browser PKCE flow with public client ID app_EMoamEEZ73f0CkXaXp7hrann. OpenAI maintainers have confirmed permissive terms for third-party usage. | N/A (OAuth works directly) |
| Anthropic | Not available — no consumer OAuth API | claude-code-cli — use Claude Code CLI with Max subscription. Anthropic explicitly supports programmatic claude -p calls. See CLI Providers. |
| Google Gemini | Not available — API keys only | gemini-cli — use Gemini CLI with Google account. WARNING: Google's ToS may prohibit third-party CLI invocation with OAuth auth. Use at your own risk. See CLI Providers. |
Authentication Strategy by Use Case
| Use Case | Recommended Auth | Provider |
|---|---|---|
| Production deployment | API key (ANTHROPIC_API_KEY, OPENAI_API_KEY) | anthropic, openai |
| Personal development (Claude) | Claude Code CLI login | claude-code-cli |
| Personal development (Gemini) | API key from AI Studio (free) | gemini |
| ChatGPT subscription users | OpenAI OAuth (Codex flow) | openai with OAuth |
| Multi-provider fallback | OpenRouter API key | openrouter |
Only providers with legitimate, Terms of Service-compliant authentication flows should be implemented. Session token extraction from consumer web products is not supported.
Subpath Export
Import from @framers/agentos/auth:
import {
OpenAIOAuthFlow,
FileTokenStore,
RedirectOAuthFlow,
OAuthGrantRefused,
SealedTokenStore,
sealSecret,
openSecret,
SealedSecretError,
type IOAuthFlow,
type IOAuthTokenStore,
type OAuthTokenSet,
type AuthMethod,
type OAuthProviderConfig,
type OpenAIOAuthFlowOptions,
} from '@framers/agentos/auth';
The export is configured in package.json:
{
"exports": {
"./auth": {
"import": "./dist/core/llm/auth/index.js",
"types": "./dist/core/llm/auth/index.d.ts"
}
}
}