File size: 4,162 Bytes
391c43e | 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 | /**
* Codex CLI OAuth — Token management for ChatGPT subscription access
*
* The long-lived refresh_token is stored in an HttpOnly cookie (osw_codex_rt)
* and never exposed to JavaScript. Only the short-lived access_token (~1 hour)
* is kept in localStorage.
*/
import { CodexAuthData } from '@/lib/llm/providers/types';
import { configManager } from '@/lib/config/storage';
/**
* Send the full auth payload to the server. The server stores the
* refresh_token in an HttpOnly cookie and returns the non-sensitive fields.
*/
export async function connectCodex(auth: CodexAuthData): Promise<CodexAuthData> {
const res = await fetch('/api/auth/codex/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(auth),
});
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Connect failed' }));
throw new Error(data.error || 'Failed to connect Codex session');
}
// Server returns { access_token, expires_at, user_email } — no refresh_token
return res.json();
}
/**
* Delete the HttpOnly refresh token cookie and clear localStorage.
*/
export async function disconnectCodex(): Promise<void> {
const res = await fetch('/api/auth/codex/disconnect', {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) {
throw new Error('Failed to clear server session');
}
configManager.clearCodexAuth();
}
/**
* Check whether the server has a refresh token cookie set.
*/
export async function checkCodexStatus(): Promise<boolean> {
const res = await fetch('/api/auth/codex/status', {
credentials: 'same-origin',
});
if (!res.ok) return false;
const data = await res.json();
return !!data.hasRefreshToken;
}
/**
* Refresh the access token using the HttpOnly cookie. The client sends no
* token — the server reads it from the cookie automatically.
*/
export async function refreshAccessToken(): Promise<CodexAuthData> {
const res = await fetch('/api/auth/codex/token', {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Token refresh failed' }));
throw new Error(data.error || `Token refresh failed: ${res.status}`);
}
// Server returns { access_token, expires_at }
return res.json();
}
/**
* Ensure the stored Codex token is valid. Refreshes if expired.
* Returns the valid access token, or throws if refresh fails.
*/
export async function ensureValidCodexToken(): Promise<string> {
const auth = configManager.getCodexAuth();
if (!auth) {
throw new Error('ChatGPT session not found. Please log in via Settings.');
}
if (!configManager.isCodexTokenExpired()) {
return auth.access_token;
}
// Token expired or near-expiry — refresh via HttpOnly cookie
try {
const refreshed = await refreshAccessToken();
configManager.setCodexAuth(refreshed);
return refreshed.access_token;
} catch {
configManager.clearCodexAuth();
throw new Error('ChatGPT session expired. Please re-authenticate in Settings.');
}
}
/**
* Parse a pasted auth JSON (from running `codex login` locally).
* Handles the actual ~/.codex/auth.json format where tokens are nested:
* { "tokens": { "access_token": "...", "refresh_token": "...", ... }, ... }
* Also accepts a flat format with top-level access_token/refresh_token.
*/
export function parseCodexAuthJson(json: string): CodexAuthData {
const parsed = JSON.parse(json);
// Codex CLI nests tokens under a "tokens" key
const tokens = parsed.tokens || parsed;
const accessToken = tokens.access_token || tokens.token;
const refreshToken = tokens.refresh_token;
if (!accessToken) {
throw new Error('Missing access_token in pasted JSON');
}
if (!refreshToken) {
throw new Error('Missing refresh_token in pasted JSON');
}
return {
access_token: accessToken,
refresh_token: refreshToken,
expires_at: tokens.expires_at || parsed.expires_at || Math.floor(Date.now() / 1000) + 3600,
user_email: tokens.user_email || parsed.user_email || tokens.email || parsed.email,
};
}
|