| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| 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`;
|
|
|
|
|
| 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 {
|
|
|
| deviceAuthId: string;
|
|
|
| userCode: string;
|
|
|
| intervalSec: number;
|
|
|
| verificationUri: string;
|
| }
|
|
|
| export interface CodexDeviceTokens {
|
| access_token: string;
|
| refresh_token: string;
|
| id_token: string;
|
| expires_in: number;
|
| }
|
|
|
| interface RunOptions {
|
|
|
| clientId?: string;
|
|
|
| onUserCode?: (userCode: CodexUserCode) => void;
|
|
|
| signal?: AbortSignal;
|
|
|
| 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;
|
| }
|
|
|
| |
| |
| |
|
|
| 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,
|
| };
|
| }
|
|
|
| |
| |
| |
|
|
| 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");
|
|
|
| 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 };
|
| }
|
|
|
|
|
| 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
|
| );
|
| }
|
| }
|
|
|
|
|
| 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,
|
| };
|
| }
|
|
|
| |
| |
| |
| |
|
|
| 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);
|
| }
|
|
|
|
|
| function startMonotonic(): number {
|
| return typeof performance !== "undefined" && typeof performance.now === "function"
|
| ? performance.now()
|
| : Date.now();
|
| }
|
|
|