blob: 3729d3ab4ae9e0fcdc9779aff926d6312f532f51 [file]
import { randomUUID } from 'node:crypto';
import type { ProviderType } from '@maka/core/llm-connections';
import { TOKEN_REFRESH_SKEW_MS } from '@maka/core/oauth-subscription';
import {
OAUTH_MAX_TOKEN_CHARS,
OAUTH_PROVIDER_CONTRACTS,
oauthExpiresAt,
optionalOAuthBoundedString,
requireOAuthBoundedString,
requireOAuthDataRecord,
requireOAuthPositiveInteger,
} from './oauth-provider-contracts.js';
export type OAuthSubscriptionProvider = Extract<
ProviderType,
'claude-subscription' | 'openai-codex' | 'github-copilot' | 'xai-oauth'
>;
export interface OAuthSubscriptionTokens {
access_token: string;
refresh_token: string;
expires_at: number;
token_type?: string;
scope?: string;
account_uuid?: string;
id_token?: string;
account_id?: string;
base_url?: string;
}
export function isOAuthSubscriptionProvider(
providerType: ProviderType,
): providerType is OAuthSubscriptionProvider {
return (
providerType === 'claude-subscription' ||
providerType === 'openai-codex' ||
providerType === 'github-copilot' ||
providerType === 'xai-oauth'
);
}
export function parseOAuthSubscriptionTokens(raw: string): OAuthSubscriptionTokens | null {
try {
const parsed = JSON.parse(raw) as unknown;
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const record = parsed as Record<string, unknown>;
if (typeof record.access_token !== 'string' || record.access_token.length === 0) return null;
if (typeof record.refresh_token !== 'string' || record.refresh_token.length === 0) return null;
if (typeof record.expires_at !== 'number' || !Number.isFinite(record.expires_at)) return null;
return {
access_token: record.access_token,
refresh_token: record.refresh_token,
expires_at: record.expires_at,
...(typeof record.token_type === 'string' ? { token_type: record.token_type } : {}),
...(typeof record.scope === 'string' ? { scope: record.scope } : {}),
...(typeof record.account_uuid === 'string' ? { account_uuid: record.account_uuid } : {}),
...(typeof record.id_token === 'string' ? { id_token: record.id_token } : {}),
...(typeof record.account_id === 'string' ? { account_id: record.account_id } : {}),
...(typeof record.base_url === 'string' ? { base_url: record.base_url } : {}),
};
} catch {
return null;
}
}
export function serializeOAuthSubscriptionTokens(tokens: OAuthSubscriptionTokens): string {
return JSON.stringify(tokens);
}
export function extractOAuthSubscriptionAccessToken(raw: string): string | null {
return parseOAuthSubscriptionTokens(raw)?.access_token ?? null;
}
export interface OAuthSubscriptionCredentialStore {
getSecret(slug: string, kind: 'oauth_token'): Promise<string | null>;
setSecret?(slug: string, kind: 'oauth_token', value: string): Promise<void>;
compareAndSetSecret?(
slug: string,
kind: 'oauth_token',
expected: string | null,
value: string,
): Promise<{ committed: true } | { committed: false; current: string | null }>;
}
export interface ResolveOAuthSubscriptionAccessTokenInput {
providerType: OAuthSubscriptionProvider;
slug: string;
credentialStore: OAuthSubscriptionCredentialStore;
now?: () => number;
fetchFn?: typeof fetch;
}
export type OAuthSubscriptionRefreshAndPersistOutcome =
| { outcome: 'refreshed'; tokens: OAuthSubscriptionTokens }
| { outcome: 'superseded'; tokens: OAuthSubscriptionTokens }
| { outcome: 'logged-out' }
| { outcome: 'refresh-failed'; error: unknown }
| { outcome: 'storage-failed'; error: unknown };
export type OAuthSubscriptionResolveAndPersistOutcome =
| { outcome: 'current'; tokens: OAuthSubscriptionTokens }
| OAuthSubscriptionRefreshAndPersistOutcome;
export type RefreshAndPersistOAuthSubscriptionTokensInput = {
slug: string;
credentialStore: OAuthSubscriptionCredentialStore;
now?: () => number;
fetchFn?: typeof fetch;
} & (
| { providerType: OAuthSubscriptionProvider; refreshTokens?: never }
| {
providerType?: never;
refreshTokens: (
tokens: OAuthSubscriptionTokens,
signal: AbortSignal,
) => Promise<OAuthSubscriptionTokens>;
}
);
export type ResolveAndPersistOAuthSubscriptionTokensInput =
RefreshAndPersistOAuthSubscriptionTokensInput & { refreshSkewMs?: number };
const CLAUDE = OAUTH_PROVIDER_CONTRACTS['claude-subscription'];
const CODEX = OAUTH_PROVIDER_CONTRACTS['openai-codex'];
const XAI = OAUTH_PROVIDER_CONTRACTS['xai-oauth'];
const OAUTH_REFRESH_LEASE_MS = 30_000;
const OAUTH_REFRESH_REQUEST_TIMEOUT_MS = 20_000;
const OAUTH_REFRESH_FINALIZE_BUDGET_MS = 10_000;
const OAUTH_REFRESH_LEASE_POLL_MS = 25;
const OAUTH_REFRESH_LEASE_FIELD = '_refresh_lock';
interface OAuthRefreshLease {
id: string;
expires_at: number;
}
export async function resolveOAuthSubscriptionAccessToken(
input: ResolveOAuthSubscriptionAccessTokenInput,
): Promise<string | null> {
const tokens = await resolveOAuthSubscriptionTokens(input);
return tokens?.access_token ?? null;
}
export async function resolveOAuthSubscriptionTokens(
input: ResolveOAuthSubscriptionAccessTokenInput,
): Promise<OAuthSubscriptionTokens | null> {
const result = await resolveAndPersistOAuthSubscriptionTokens(input);
return result.outcome === 'current' ||
result.outcome === 'refreshed' ||
result.outcome === 'superseded'
? result.tokens
: null;
}
export async function resolveAndPersistOAuthSubscriptionTokens(
input: ResolveAndPersistOAuthSubscriptionTokensInput,
): Promise<OAuthSubscriptionResolveAndPersistOutcome> {
let raw: string | null;
try {
raw = await input.credentialStore.getSecret(input.slug, 'oauth_token');
} catch (error) {
return { outcome: 'storage-failed', error };
}
if (raw === null) return { outcome: 'logged-out' };
const tokens = parseOAuthSubscriptionTokens(raw);
if (!tokens) {
return { outcome: 'storage-failed', error: new Error('Stored OAuth token is invalid.') };
}
const now = input.now ?? (() => Date.now());
if (tokens.expires_at - now() > (input.refreshSkewMs ?? TOKEN_REFRESH_SKEW_MS)) {
return { outcome: 'current', tokens };
}
return refreshAndPersistOAuthSubscriptionTokensFromRaw(input, raw);
}
export async function refreshAndPersistOAuthSubscriptionTokens(
input: RefreshAndPersistOAuthSubscriptionTokensInput,
): Promise<OAuthSubscriptionRefreshAndPersistOutcome> {
let raw: string | null;
try {
raw = await input.credentialStore.getSecret(input.slug, 'oauth_token');
} catch (error) {
return { outcome: 'storage-failed', error };
}
if (raw === null) return { outcome: 'logged-out' };
return refreshAndPersistOAuthSubscriptionTokensFromRaw(input, raw);
}
async function refreshAndPersistOAuthSubscriptionTokensFromRaw(
input: RefreshAndPersistOAuthSubscriptionTokensInput,
raw: string,
): Promise<OAuthSubscriptionRefreshAndPersistOutcome> {
const tokens = parseOAuthSubscriptionTokens(raw);
if (!tokens) {
return { outcome: 'storage-failed', error: new Error('Stored OAuth token is invalid.') };
}
if (!input.credentialStore.compareAndSetSecret && !input.credentialStore.setSecret) {
return { outcome: 'storage-failed', error: new Error('Credential store is read-only.') };
}
if (input.credentialStore.compareAndSetSecret) {
return refreshAndPersistWithLease(input, raw);
}
let refreshed: OAuthSubscriptionTokens;
try {
refreshed = await refreshTokensForInput(input, tokens);
} catch (error) {
return { outcome: 'refresh-failed', error };
}
const serialized = serializeOAuthSubscriptionTokens(refreshed);
try {
await input.credentialStore.setSecret!(input.slug, 'oauth_token', serialized);
} catch (error) {
return { outcome: 'storage-failed', error };
}
return { outcome: 'refreshed', tokens: refreshed };
}
async function refreshAndPersistWithLease(
input: RefreshAndPersistOAuthSubscriptionTokensInput,
initialRaw: string,
): Promise<OAuthSubscriptionRefreshAndPersistOutcome> {
const compareAndSet = input.credentialStore.compareAndSetSecret!.bind(input.credentialStore);
let raw = initialRaw;
for (;;) {
const tokens = parseOAuthSubscriptionTokens(raw);
if (!tokens) {
return { outcome: 'storage-failed', error: new Error('Stored OAuth token is invalid.') };
}
const lease = parseOAuthRefreshLease(raw);
if (lease && lease.expires_at > Date.now()) {
let current: string | null;
try {
current = await waitForOAuthCredentialChange(
input.credentialStore,
input.slug,
raw,
lease.expires_at,
);
} catch (error) {
return { outcome: 'storage-failed', error };
}
if (current === null) return { outcome: 'logged-out' };
if (current === raw) continue;
const currentTokens = parseOAuthSubscriptionTokens(current);
if (!currentTokens) {
return { outcome: 'storage-failed', error: new Error('Stored OAuth token is invalid.') };
}
if (parseOAuthRefreshLease(current)) {
raw = current;
continue;
}
if (
serializeOAuthSubscriptionTokens(currentTokens) === serializeOAuthSubscriptionTokens(tokens)
) {
raw = current;
continue;
}
return { outcome: 'superseded', tokens: currentTokens };
}
const refreshLease = {
id: randomUUID(),
expires_at: Date.now() + OAUTH_REFRESH_LEASE_MS,
};
const claimedRaw = serializeOAuthSubscriptionTokensWithLease(tokens, refreshLease);
let claim: { committed: true } | { committed: false; current: string | null };
try {
claim = await compareAndSet(input.slug, 'oauth_token', raw, claimedRaw);
} catch (error) {
return { outcome: 'storage-failed', error };
}
if (!claim.committed) {
if (claim.current === null) return { outcome: 'logged-out' };
const currentTokens = parseOAuthSubscriptionTokens(claim.current);
if (!currentTokens) {
return { outcome: 'storage-failed', error: new Error('Stored OAuth token is invalid.') };
}
if (parseOAuthRefreshLease(claim.current)) {
raw = claim.current;
continue;
}
return { outcome: 'superseded', tokens: currentTokens };
}
let refreshed: OAuthSubscriptionTokens;
try {
refreshed = await refreshTokensForInput(input, tokens, refreshLease.expires_at);
} catch (error) {
try {
const released = await compareAndSet(input.slug, 'oauth_token', claimedRaw, raw);
if (!released.committed) {
if (released.current === null) return { outcome: 'logged-out' };
const current = parseOAuthSubscriptionTokens(released.current);
if (!current) {
return {
outcome: 'storage-failed',
error: new Error('Stored OAuth token is invalid.'),
};
}
if (parseOAuthRefreshLease(released.current)) {
raw = released.current;
continue;
}
return { outcome: 'superseded', tokens: current };
}
} catch (storageError) {
return { outcome: 'storage-failed', error: storageError };
}
return { outcome: 'refresh-failed', error };
}
try {
const committed = await compareAndSet(
input.slug,
'oauth_token',
claimedRaw,
serializeOAuthSubscriptionTokens(refreshed),
);
if (!committed.committed) {
if (committed.current === null) return { outcome: 'logged-out' };
const current = parseOAuthSubscriptionTokens(committed.current);
if (!current) {
return { outcome: 'storage-failed', error: new Error('Stored OAuth token is invalid.') };
}
if (parseOAuthRefreshLease(committed.current)) {
raw = committed.current;
continue;
}
return { outcome: 'superseded', tokens: current };
}
} catch (error) {
return { outcome: 'storage-failed', error };
}
return { outcome: 'refreshed', tokens: refreshed };
}
}
async function refreshTokensForInput(
input: RefreshAndPersistOAuthSubscriptionTokensInput,
tokens: OAuthSubscriptionTokens,
leaseExpiresAt?: number,
): Promise<OAuthSubscriptionTokens> {
const controller = new AbortController();
const timeoutError = new Error('OAuth token refresh timed out.');
const timeoutMs =
leaseExpiresAt === undefined
? OAUTH_REFRESH_REQUEST_TIMEOUT_MS
: Math.min(
OAUTH_REFRESH_REQUEST_TIMEOUT_MS,
Math.max(0, leaseExpiresAt - Date.now() - OAUTH_REFRESH_FINALIZE_BUDGET_MS),
);
if (timeoutMs === 0) throw timeoutError;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutMs);
});
try {
const refresh = input.refreshTokens
? input.refreshTokens(tokens, controller.signal)
: refreshOAuthSubscriptionTokens({
providerType: input.providerType,
tokens,
now: input.now,
fetchFn: input.fetchFn,
signal: controller.signal,
});
return await Promise.race([refresh, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
function parseOAuthRefreshLease(raw: string): OAuthRefreshLease | null {
try {
const record = JSON.parse(raw) as Record<string, unknown>;
const candidate = record[OAUTH_REFRESH_LEASE_FIELD];
if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate))
return null;
const lease = candidate as Record<string, unknown>;
return typeof lease.id === 'string' &&
lease.id.length > 0 &&
typeof lease.expires_at === 'number' &&
Number.isFinite(lease.expires_at)
? { id: lease.id, expires_at: lease.expires_at }
: null;
} catch {
return null;
}
}
function serializeOAuthSubscriptionTokensWithLease(
tokens: OAuthSubscriptionTokens,
lease: OAuthRefreshLease,
): string {
return JSON.stringify({ ...tokens, [OAUTH_REFRESH_LEASE_FIELD]: lease });
}
async function waitForOAuthCredentialChange(
store: OAuthSubscriptionCredentialStore,
slug: string,
expected: string,
leaseExpiresAt: number,
): Promise<string | null> {
for (;;) {
const remaining = leaseExpiresAt - Date.now();
if (remaining <= 0) return store.getSecret(slug, 'oauth_token');
await new Promise((resolve) =>
setTimeout(resolve, Math.min(OAUTH_REFRESH_LEASE_POLL_MS, remaining)),
);
const current = await store.getSecret(slug, 'oauth_token');
if (current !== expected) return current;
}
}
/**
* Provider-specific refresh request. Exported so the desktop services
* force-refresh through the same HTTP contract the pure-Node resolve
* path uses — one refresh implementation per provider, not two.
* Throws on a failed refresh; persistence is the caller's concern.
*/
export async function refreshOAuthSubscriptionTokens(input: {
providerType: OAuthSubscriptionProvider;
tokens: OAuthSubscriptionTokens;
now?: () => number;
fetchFn?: typeof fetch;
signal?: AbortSignal;
}): Promise<OAuthSubscriptionTokens> {
const now = input.now ?? (() => Date.now());
const fetchFn = input.fetchFn ?? fetch;
switch (input.providerType) {
case 'claude-subscription':
return refreshClaudeSubscriptionTokens(input.tokens, now, fetchFn, input.signal);
case 'openai-codex':
return refreshOpenAiCodexTokens(input.tokens, now, fetchFn, input.signal);
case 'github-copilot':
return input.tokens;
case 'xai-oauth':
return refreshXaiOAuthTokens(input.tokens, now, fetchFn, input.signal);
}
}
export const GITHUB_COPILOT_DEFAULT_API_ENDPOINT = 'https://api.githubcopilot.com';
export const GITHUB_COPILOT_API_VERSION = '2026-06-01';
export const GITHUB_COPILOT_COMPAT_HEADERS = {
'User-Agent': 'GitHubCopilotChat/0.35.0',
'Editor-Version': 'vscode/1.107.0',
'Editor-Plugin-Version': 'copilot-chat/0.35.0',
'Copilot-Integration-Id': 'vscode-chat',
} as const;
export function createGitHubCopilotAccountTokens(githubToken: string): OAuthSubscriptionTokens {
return {
access_token: githubToken,
refresh_token: githubToken,
expires_at: Number.MAX_SAFE_INTEGER,
token_type: 'Bearer',
base_url: GITHUB_COPILOT_DEFAULT_API_ENDPOINT,
};
}
export function isSupportedGitHubCopilotAccountToken(token: string): boolean {
return token.startsWith('gho_') || token.startsWith('ghu_') || token.startsWith('github_pat_');
}
/**
* Guard a refresh response before it may replace the stored authority:
* a 200 with a missing/empty access token or a non-positive expiry must
* surface as a refresh failure, never overwrite a still-working record
* with garbage. Returns the validated required fields.
*/
function requireRefreshedTokenFields(payload: unknown): {
record: Record<string, unknown>;
accessToken: string;
expiresInSeconds: number;
} {
const record = requireOAuthDataRecord(payload);
return {
record,
accessToken: requireOAuthBoundedString(record.access_token, OAUTH_MAX_TOKEN_CHARS),
expiresInSeconds: requireOAuthPositiveInteger(record.expires_in, 366 * 24 * 60 * 60),
};
}
/** A rotated refresh token must be a non-empty string; otherwise keep the previous one. */
function nextRefreshToken(candidate: unknown, previous: string): string {
if (candidate === undefined || candidate === '') return previous;
return requireOAuthBoundedString(candidate, OAUTH_MAX_TOKEN_CHARS);
}
async function refreshClaudeSubscriptionTokens(
tokens: OAuthSubscriptionTokens,
now: () => number,
fetchFn: typeof fetch,
signal?: AbortSignal,
): Promise<OAuthSubscriptionTokens> {
const response = await fetchFn(CLAUDE.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': CLAUDE.tokenUserAgent,
},
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: tokens.refresh_token,
client_id: CLAUDE.clientId,
}),
signal,
});
if (!response.ok) throw new Error(`Claude OAuth token refresh failed (${response.status}).`);
const {
record: payload,
accessToken,
expiresInSeconds,
} = requireRefreshedTokenFields(await response.json());
const account =
payload.account === undefined ? undefined : requireOAuthDataRecord(payload.account);
return {
access_token: accessToken,
refresh_token: nextRefreshToken(payload.refresh_token, tokens.refresh_token),
expires_at: oauthExpiresAt(now(), expiresInSeconds),
token_type: optionalOAuthBoundedString(payload.token_type, 256) ?? tokens.token_type,
scope: optionalOAuthBoundedString(payload.scope, 4 * 1024) ?? tokens.scope,
account_uuid:
(account ? optionalOAuthBoundedString(account.uuid, 1_024) : undefined) ??
tokens.account_uuid,
};
}
async function refreshOpenAiCodexTokens(
tokens: OAuthSubscriptionTokens,
now: () => number,
fetchFn: typeof fetch,
signal?: AbortSignal,
): Promise<OAuthSubscriptionTokens> {
const body = new URLSearchParams({
grant_type: 'refresh_token',
client_id: CODEX.clientId,
refresh_token: tokens.refresh_token,
});
const response = await fetchFn(CODEX.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': CODEX.tokenUserAgent,
},
body: body.toString(),
signal,
});
if (!response.ok) throw new Error(`Codex OAuth token refresh failed (${response.status}).`);
const {
record: payload,
accessToken,
expiresInSeconds,
} = requireRefreshedTokenFields(await response.json());
return {
access_token: accessToken,
refresh_token: nextRefreshToken(payload.refresh_token, tokens.refresh_token),
id_token:
optionalOAuthBoundedString(payload.id_token, OAUTH_MAX_TOKEN_CHARS) ?? tokens.id_token,
expires_at: oauthExpiresAt(now(), expiresInSeconds),
account_id: tokens.account_id,
};
}
async function refreshXaiOAuthTokens(
tokens: OAuthSubscriptionTokens,
now: () => number,
fetchFn: typeof fetch,
signal?: AbortSignal,
): Promise<OAuthSubscriptionTokens> {
const response = await fetchFn(XAI.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: XAI.clientId,
refresh_token: tokens.refresh_token,
}).toString(),
signal,
});
if (!response.ok) throw new Error(`xAI OAuth token refresh failed (${response.status}).`);
const payload = requireOAuthDataRecord(await response.json());
const { record, accessToken, expiresInSeconds } = requireRefreshedTokenFields({
...payload,
expires_in:
payload.expires_in === undefined ? XAI.defaultTokenLifetimeSeconds : payload.expires_in,
});
return {
access_token: accessToken,
refresh_token: nextRefreshToken(record.refresh_token, tokens.refresh_token),
expires_at: oauthExpiresAt(now(), expiresInSeconds),
token_type: optionalOAuthBoundedString(record.token_type, 256) ?? tokens.token_type,
scope: optionalOAuthBoundedString(record.scope, 4 * 1024) ?? tokens.scope,
};
}