blob: f42363bbaa440abde842e2867e485943ef7d2ecf [file]
import type { OAuthSubscriptionTokens } from './subscription-credentials.js';
import {
decodeOAuthInitialTokenPayload,
OAUTH_LOGIN_MAX_TOKEN_CHARS,
OAuthTokenEndpointError,
requestOAuthEndpointJson,
requestOAuthTokenEndpointJson,
} from './oauth-login.js';
import {
OAUTH_PROVIDER_CONTRACTS,
OAuthDeviceAuthorizationExpiredError,
requireOAuthBoundedString,
requireOAuthDataRecord,
} from './oauth-provider-contracts.js';
const CODEX = OAUTH_PROVIDER_CONTRACTS['openai-codex'];
const CODEX_DEVICE_USERCODE_ENDPOINT = `${CODEX.deviceAuthBaseUrl}/deviceauth/usercode`;
const CODEX_DEVICE_TOKEN_ENDPOINT = `${CODEX.deviceAuthBaseUrl}/deviceauth/token`;
export interface CodexDeviceAuthorization {
readonly deviceAuthId: string;
readonly userCode: string;
readonly verificationUrl: string;
readonly expiresAt: number;
readonly intervalMs: number;
}
/** Result of a successful device-auth poll: the authorization code + PKCE verifier to exchange. */
export interface CodexDeviceAuthorizationGrant {
readonly authorizationCode: string;
readonly codeVerifier: string;
}
export interface StartCodexDeviceAuthorizationInput {
readonly fetchFn: typeof fetch;
readonly signal: AbortSignal;
readonly now?: () => number;
readonly timeoutMs?: number;
}
export interface PollCodexDeviceAuthorizationInput extends StartCodexDeviceAuthorizationInput {
readonly authorization: CodexDeviceAuthorization;
readonly sleep?: (delayMs: number, signal: AbortSignal) => Promise<void>;
/** Runs immediately before one token request becomes non-cancellable. */
readonly onPollAdmission?: () => void;
/** Runs after a retryable response restores the cancellation boundary. */
readonly onPollRetry?: () => void;
}
export interface ExchangeCodexDeviceAuthorizationCodeInput {
readonly grant: CodexDeviceAuthorizationGrant;
readonly fetchFn: typeof fetch;
readonly signal: AbortSignal;
readonly now?: () => number;
readonly timeoutMs?: number;
}
/**
* Request a one-time ChatGPT device code (`POST /deviceauth/usercode`).
* Mirrors the official codex CLI (`codex-rs login/src/device_code_auth.rs`):
* the verification page is the fixed server-owned `auth.openai.com/codex/device`
* URL, and the user enters the returned `user_code` there.
*/
export async function startCodexDeviceAuthorization(
input: StartCodexDeviceAuthorizationInput,
): Promise<CodexDeviceAuthorization> {
const response = await requestOAuthEndpointJson({
endpoint: CODEX_DEVICE_USERCODE_ENDPOINT,
init: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: CODEX.clientId }),
},
fetchFn: input.fetchFn,
signal: input.signal,
timeoutMs: input.timeoutMs,
});
if (!response.ok) throw new OAuthTokenEndpointError('provider_rejected', response.status);
const payload = requireOAuthDataRecord(response.payload);
const deviceAuthId = requireOAuthBoundedString(payload.device_auth_id, 1_024);
// The official CLI decoder accepts both `user_code` and `usercode`
// (codex-rs login/src/device_code_auth.rs serde aliases).
const userCode = requireOAuthBoundedString(payload.user_code ?? payload.usercode, 1_024);
const intervalSeconds = deviceIntervalSeconds(payload.interval);
const now = input.now?.() ?? Date.now();
return {
deviceAuthId,
userCode,
verificationUrl: CODEX.deviceVerifyUrl,
expiresAt: deviceExpiry(payload.expires_at, now),
intervalMs: intervalSeconds * 1_000,
};
}
/**
* Poll `POST /deviceauth/token` until the browser approval lands. Mirrors
* the official CLI: 403/404 means still pending (retry after the interval),
* 200 returns `{authorization_code, code_challenge, code_verifier}`.
*
* The poll never issues a request once the local `expires_at` has passed:
* it polls first, then sleeps at most until the boundary, and re-checks on
* every loop. A 200 issued while the code was still valid is honored even
* if it arrives just past the boundary (the grant was created in-window).
* An elapsed window throws `OAuthDeviceAuthorizationExpiredError` — the
* caller distinguishes "user did not finish in time" from a provider
* rejection.
*
* After `onPollAdmission`, the in-flight request uses an independent
* AbortSignal so a caller cancellation cannot discard a poll that has
* already consumed the one-time device code; the per-request intrinsic
* deadline still bounds it.
*/
export async function pollCodexDeviceAuthorization(
input: PollCodexDeviceAuthorizationInput,
): Promise<CodexDeviceAuthorizationGrant> {
const now = input.now ?? (() => Date.now());
const sleep = input.sleep ?? abortableSleep;
for (;;) {
if (now() >= input.authorization.expiresAt) {
throw new OAuthDeviceAuthorizationExpiredError();
}
input.signal.throwIfAborted();
input.onPollAdmission?.();
const response = await requestOAuthEndpointJson({
endpoint: CODEX_DEVICE_TOKEN_ENDPOINT,
init: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
device_auth_id: input.authorization.deviceAuthId,
user_code: input.authorization.userCode,
}),
},
fetchFn: input.fetchFn,
// The admitted request must complete even if the caller cancels;
// the per-request deadline still bounds it.
signal: new AbortController().signal,
timeoutMs: input.timeoutMs,
});
if (response.ok) {
const payload = requireOAuthDataRecord(response.payload);
const authorizationCode = requireOAuthBoundedString(
payload.authorization_code,
OAUTH_LOGIN_MAX_TOKEN_CHARS,
);
const codeVerifier = requireOAuthBoundedString(
payload.code_verifier,
OAUTH_LOGIN_MAX_TOKEN_CHARS,
);
return { authorizationCode, codeVerifier };
}
// 403/404 = still pending (per the official CLI); anything else is a
// hard failure.
if (response.status === 403 || response.status === 404) {
input.onPollRetry?.();
input.signal.throwIfAborted();
// Sleep at most until the window elapses; the loop-top check then
// stops polling once expired.
const remaining = input.authorization.expiresAt - now();
if (remaining <= 0) throw new OAuthDeviceAuthorizationExpiredError();
await sleep(Math.min(input.authorization.intervalMs, remaining), input.signal);
continue;
}
throw new OAuthTokenEndpointError('provider_rejected', response.status);
}
}
/**
* Exchange the device-auth authorization code at the token endpoint with
* the fixed deviceauth redirect URI. The device flow uses a server-issued
* PKCE verifier (returned by the poll), so it cannot reuse the loopback
* `exchangeOAuthAuthorizationCode` path (which pins `redirect_uri` to a
* local loopback host).
*/
export async function exchangeCodexDeviceAuthorizationCode(
input: ExchangeCodexDeviceAuthorizationCodeInput,
): Promise<OAuthSubscriptionTokens> {
if (input.signal.aborted) throw new OAuthTokenEndpointError('aborted');
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: CODEX.clientId,
code: input.grant.authorizationCode,
code_verifier: input.grant.codeVerifier,
redirect_uri: CODEX.deviceRedirectUri,
});
const { payload, status } = await requestOAuthTokenEndpointJson({
endpoint: CODEX.tokenEndpoint,
init: {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': CODEX.tokenUserAgent,
},
body: body.toString(),
},
fetchFn: input.fetchFn,
signal: input.signal,
timeoutMs: input.timeoutMs,
});
try {
return decodeOAuthInitialTokenPayload('openai-codex', payload, input.now?.() ?? Date.now());
} catch (error) {
const category = error instanceof OAuthTokenEndpointError ? error.category : 'invalid_response';
throw new OAuthTokenEndpointError(category, status);
}
}
/** The deviceauth response returns `interval` as a numeric string. */
function deviceIntervalSeconds(value: unknown): number {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isInteger(parsed) && parsed > 0) return parsed;
}
throw new OAuthTokenEndpointError('invalid_response');
}
/** `expires_at` arrives as an ISO-8601 UTC string; fall back to 15 minutes. */
function deviceExpiry(value: unknown, now: number): number {
if (typeof value === 'string') {
const parsed = Date.parse(value);
if (Number.isFinite(parsed) && parsed > now) return parsed;
}
return now + 15 * 60 * 1_000;
}
function abortableSleep(delayMs: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new DOMException('OAuth login cancelled', 'AbortError'));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException('OAuth login cancelled', 'AbortError'));
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, delayMs);
signal.addEventListener('abort', onAbort, { once: true });
});
}