File size: 11,029 Bytes
bc4a7e8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | /**
* Codex (OpenAI) browser-driven device authorization flow.
*
* Runs ENTIRELY in the user's browser. `auth.openai.com` blocks datacenter IPs
* (Cloudflare) but allows CORS, so the device flow MUST originate from the
* user's browser β never from the OmniRoute server. The final tokens are handed
* to the backend for persistence (see the OAuth route's persistence path).
*
* Wire contract (from OpenAI Codex CLI β codex-rs/login/src/device_code_auth.rs,
* cross-checked against tumf/opencode-openai-device-auth). This is NOT the
* RFC 8628 device grant; it is OpenAI's custom "deviceauth" flow:
*
* 1. POST {BASE}/api/accounts/deviceauth/usercode (JSON) { client_id }
* -> 200 { device_auth_id, user_code|usercode, interval }
* -> 404 device code login disabled for this account/workspace (admin gating)
* 2. POST {BASE}/api/accounts/deviceauth/token (JSON) { device_auth_id, user_code }
* -> 200 { authorization_code, code_verifier } (PKCE is generated server-side!)
* -> 403 | 404 authorization still pending β keep polling
* 3. POST {BASE}/oauth/token (form) authorization_code grant
* with code + code_verifier (from step 2) + redirect_uri = {BASE}/deviceauth/callback
* -> 200 { access_token, refresh_token, id_token, expires_in }
*
* Verification: the user opens {BASE}/codex/device and types the user_code.
*
* NOTE: this module must stay free of server-only imports (e.g. CODEX_CONFIG,
* open-sse) so it can be bundled for the browser. The client_id below is the
* public Codex CLI client identifier (same literal as CODEX_CONFIG.clientId);
* it relies on PKCE, not secrecy (RFC 8252).
*/
const BASE_URL = "https://auth.openai.com";
const API_BASE_URL = `${BASE_URL}/api/accounts`;
const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const VERIFICATION_URI = `${BASE_URL}/codex/device`;
const REDIRECT_URI = `${BASE_URL}/deviceauth/callback`;
/** Total time the user has to authorize before we give up (OpenAI expires the code in 15 min). */
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
const DEFAULT_INTERVAL_SEC = 5;
export type CodexDeviceFlowErrorCode =
| "device_disabled"
| "usercode_failed"
| "exchange_failed"
| "timeout"
| "aborted"
| "network";
export class CodexDeviceFlowError extends Error {
code: CodexDeviceFlowErrorCode;
status?: number;
constructor(code: CodexDeviceFlowErrorCode, message: string, status?: number) {
super(message);
this.name = "CodexDeviceFlowError";
this.code = code;
this.status = status;
}
}
export interface CodexUserCode {
/** Opaque handle returned by the usercode endpoint; required to poll. */
deviceAuthId: string;
/** One-time code the user types at the verification URL. */
userCode: string;
/** Server-suggested poll interval, in seconds. */
intervalSec: number;
/** URL the user opens to enter the code. */
verificationUri: string;
}
export interface CodexDeviceTokens {
access_token: string;
refresh_token: string;
id_token: string;
expires_in: number;
}
interface RunOptions {
/** Public Codex CLI client id; defaults to the embedded public value. */
clientId?: string;
/** Called once the user code is obtained, so the UI can display / copy / open it. */
onUserCode?: (userCode: CodexUserCode) => void;
/** Abort the flow (e.g. modal closed). */
signal?: AbortSignal;
/** Override the overall timeout (defaults to 15 minutes). */
timeoutMs?: number;
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new CodexDeviceFlowError("aborted", "Device flow aborted");
}
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
cleanup();
reject(new CodexDeviceFlowError("aborted", "Device flow aborted"));
};
function cleanup() {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
}
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
function normalizeInterval(raw: unknown): number {
const n = typeof raw === "string" ? parseInt(raw, 10) : typeof raw === "number" ? raw : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_INTERVAL_SEC;
}
/**
* Step 1 β request a one-time user code + device_auth_id.
* A 404 here means device code login is disabled for this account/workspace.
*/
export async function requestUserCode(
clientId: string = DEFAULT_CLIENT_ID,
signal?: AbortSignal
): Promise<CodexUserCode> {
let res: Response;
try {
res = await fetch(`${API_BASE_URL}/deviceauth/usercode`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ client_id: clientId }),
signal,
});
} catch (e: any) {
if (e?.name === "AbortError") throw new CodexDeviceFlowError("aborted", "Device flow aborted");
throw new CodexDeviceFlowError("network", `Failed to reach OpenAI: ${e?.message || e}`);
}
if (res.status === 404) {
throw new CodexDeviceFlowError(
"device_disabled",
"Device code login is not enabled for this account. Enable it in ChatGPT security settings (or ask your workspace admin), or use the localhost 'Adicionar' flow.",
404
);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new CodexDeviceFlowError(
"usercode_failed",
`Failed to request device code (${res.status}): ${text}`,
res.status
);
}
const data: any = await res.json();
const userCode = data.user_code || data.usercode;
if (!data.device_auth_id || !userCode) {
throw new CodexDeviceFlowError(
"usercode_failed",
"Device code response missing device_auth_id or user_code"
);
}
return {
deviceAuthId: data.device_auth_id,
userCode,
intervalSec: normalizeInterval(data.interval),
verificationUri: VERIFICATION_URI,
};
}
/**
* Step 2 β poll until the user authorizes. Returns the authorization_code and the
* server-generated code_verifier needed for the token exchange.
*/
export async function pollForAuthorization(
deviceAuthId: string,
userCode: string,
intervalSec: number,
opts: { signal?: AbortSignal; timeoutMs?: number } = {}
): Promise<{ authorizationCode: string; codeVerifier: string }> {
const { signal, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
const deadline = startMonotonic() + timeoutMs;
while (true) {
throwIfAborted(signal);
await delay(intervalSec * 1000, signal);
if (startMonotonic() >= deadline) {
throw new CodexDeviceFlowError("timeout", "Authorization timed out. Start a new session.");
}
let res: Response;
try {
res = await fetch(`${API_BASE_URL}/deviceauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }),
signal,
});
} catch (e: any) {
if (e?.name === "AbortError")
throw new CodexDeviceFlowError("aborted", "Device flow aborted");
// Transient network error β keep polling until the deadline.
continue;
}
if (res.ok) {
const data: any = await res.json();
if (!data.authorization_code || !data.code_verifier) {
throw new CodexDeviceFlowError(
"usercode_failed",
"Authorization response missing authorization_code or code_verifier"
);
}
return { authorizationCode: data.authorization_code, codeVerifier: data.code_verifier };
}
// 403 / 404 β still pending; anything else is a hard failure.
if (res.status === 403 || res.status === 404) continue;
const text = await res.text().catch(() => "");
throw new CodexDeviceFlowError(
"usercode_failed",
`Polling failed (${res.status}): ${text}`,
res.status
);
}
}
/** Step 3 β exchange the authorization_code (+ server code_verifier) for tokens. */
export async function exchangeCodeForTokens(
authorizationCode: string,
codeVerifier: string,
clientId: string = DEFAULT_CLIENT_ID,
signal?: AbortSignal
): Promise<CodexDeviceTokens> {
let res: Response;
try {
res = await fetch(`${BASE_URL}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: clientId,
code: authorizationCode,
code_verifier: codeVerifier,
redirect_uri: REDIRECT_URI,
}).toString(),
signal,
});
} catch (e: any) {
if (e?.name === "AbortError") throw new CodexDeviceFlowError("aborted", "Device flow aborted");
throw new CodexDeviceFlowError("network", `Token exchange failed: ${e?.message || e}`);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new CodexDeviceFlowError(
"exchange_failed",
`Token exchange failed (${res.status}): ${text}`,
res.status
);
}
const data: any = await res.json();
if (!data.access_token) {
throw new CodexDeviceFlowError("exchange_failed", "Token exchange returned no access_token");
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token,
id_token: data.id_token,
expires_in: data.expires_in,
};
}
/**
* High-level orchestrator: request the user code (firing `onUserCode` so the UI can
* show it), poll until authorized, then exchange for tokens. The caller then ships
* the returned tokens to the backend for persistence.
*/
export async function runCodexDeviceFlow(opts: RunOptions = {}): Promise<CodexDeviceTokens> {
const clientId = opts.clientId || DEFAULT_CLIENT_ID;
throwIfAborted(opts.signal);
const userCode = await requestUserCode(clientId, opts.signal);
opts.onUserCode?.(userCode);
const { authorizationCode, codeVerifier } = await pollForAuthorization(
userCode.deviceAuthId,
userCode.userCode,
userCode.intervalSec,
{ signal: opts.signal, timeoutMs: opts.timeoutMs }
);
return exchangeCodeForTokens(authorizationCode, codeVerifier, clientId, opts.signal);
}
/** Monotonic-ish clock that tolerates environments where performance is unavailable. */
function startMonotonic(): number {
return typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
}
|