feat: replace /connect claude with /connect codex for OpenAI OAuth (#527)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* OAuth 2.0 utilities for Anthropic authentication
|
||||
* Uses Authorization Code Flow with PKCE
|
||||
*/
|
||||
|
||||
export const ANTHROPIC_OAUTH_CONFIG = {
|
||||
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
authorizationUrl: "https://claude.ai/oauth/authorize",
|
||||
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
|
||||
redirectUri: "https://console.anthropic.com/oauth/code/callback",
|
||||
scope: "org:create_api_key user:profile user:inference",
|
||||
} as const;
|
||||
|
||||
export interface AnthropicTokens {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface OAuthError {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier (43-128 characters of unreserved URI characters)
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code challenge from verifier using SHA-256
|
||||
*/
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure state parameter (32-byte hex)
|
||||
*/
|
||||
export function generateState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode (RFC 4648)
|
||||
*/
|
||||
function base64UrlEncode(buffer: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...buffer));
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
export async function generatePKCE(): Promise<{
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
}> {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth flow - returns authorization URL and PKCE values
|
||||
*/
|
||||
export async function startAnthropicOAuth(): Promise<{
|
||||
authorizationUrl: string;
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
}> {
|
||||
const state = generateState();
|
||||
const { codeVerifier, codeChallenge } = await generatePKCE();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
|
||||
redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri,
|
||||
scope: ANTHROPIC_OAUTH_CONFIG.scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
|
||||
const authorizationUrl = `${ANTHROPIC_OAUTH_CONFIG.authorizationUrl}?${params.toString()}`;
|
||||
|
||||
return {
|
||||
authorizationUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
state: string,
|
||||
): Promise<AnthropicTokens> {
|
||||
const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
// Show full response for debugging
|
||||
throw new Error(
|
||||
`Failed to exchange code for tokens (HTTP ${response.status}): ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as AnthropicTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token
|
||||
*/
|
||||
export async function refreshAnthropicToken(
|
||||
refreshToken: string,
|
||||
): Promise<AnthropicTokens> {
|
||||
const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({
|
||||
error: "unknown_error",
|
||||
error_description: `HTTP ${response.status}`,
|
||||
}))) as OAuthError;
|
||||
throw new Error(
|
||||
`Failed to refresh access token: ${error.error_description || error.error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as AnthropicTokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate credentials by making a test API call
|
||||
* OAuth tokens require the anthropic-beta header
|
||||
*/
|
||||
export async function validateAnthropicCredentials(
|
||||
accessToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Use the models endpoint to validate the token
|
||||
const response = await fetch("https://api.anthropic.com/v1/models", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid Anthropic access token, refreshing if necessary
|
||||
* Returns null if no OAuth tokens are configured
|
||||
*/
|
||||
export async function getAnthropicAccessToken(): Promise<string | null> {
|
||||
// Lazy import to avoid circular dependencies
|
||||
const { settingsManager } = await import("../settings-manager");
|
||||
|
||||
const tokens = settingsManager.getAnthropicTokens();
|
||||
if (!tokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired or about to expire (within 5 minutes)
|
||||
const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000;
|
||||
if (tokens.expires_at < fiveMinutesFromNow && tokens.refresh_token) {
|
||||
try {
|
||||
const newTokens = await refreshAnthropicToken(tokens.refresh_token);
|
||||
settingsManager.storeAnthropicTokens(newTokens);
|
||||
return newTokens.access_token;
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh Anthropic access token:", error);
|
||||
// Return existing token even if refresh failed - it might still work
|
||||
return tokens.access_token;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Anthropic OAuth is configured and valid
|
||||
*/
|
||||
export async function hasValidAnthropicAuth(): Promise<boolean> {
|
||||
const token = await getAnthropicAccessToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
return validateAnthropicCredentials(token);
|
||||
}
|
||||
390
src/auth/openai-oauth.ts
Normal file
390
src/auth/openai-oauth.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* OAuth 2.0 utilities for OpenAI Codex authentication
|
||||
* Uses Authorization Code Flow with PKCE and local callback server
|
||||
* Compatible with Codex CLI authentication flow
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
|
||||
export const OPENAI_OAUTH_CONFIG = {
|
||||
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
authorizationUrl: "https://auth.openai.com/oauth/authorize",
|
||||
tokenUrl: "https://auth.openai.com/oauth/token",
|
||||
defaultPort: 1455,
|
||||
callbackPath: "/auth/callback",
|
||||
scope: "openid profile email offline_access",
|
||||
} as const;
|
||||
|
||||
export interface OpenAITokens {
|
||||
access_token: string;
|
||||
id_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface OAuthError {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a minimal OAuth callback page with ASCII art
|
||||
*/
|
||||
function renderOAuthPage(options: {
|
||||
success: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
autoClose?: boolean;
|
||||
}): string {
|
||||
const { title, message, autoClose } = options;
|
||||
|
||||
// ASCII art logo (escaped for HTML)
|
||||
const asciiLogo = ` ██████ ██╗ ███████╗████████╗████████╗ █████╗
|
||||
██ ██ ██║ ██╔════╝╚══██╔══╝╚══██╔══╝██╔══██╗
|
||||
██ ▇▇ ██ ██║ █████╗ ██║ ██║ ███████║
|
||||
██ ██ ██║ ██╔══╝ ██║ ██║ ██╔══██║
|
||||
██████ ███████╗███████╗ ██║ ██║ ██║ ██║
|
||||
╚═════╝ ╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title} - Letta Code</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #161616;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.ascii-art {
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: #404040;
|
||||
white-space: pre;
|
||||
user-select: none;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #e5e5e5;
|
||||
margin-bottom: 12px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.message {
|
||||
font-size: 16px;
|
||||
color: #737373;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.ascii-art { font-size: 8px; }
|
||||
.title { font-size: 24px; }
|
||||
.message { font-size: 14px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="ascii-art">${asciiLogo}</div>
|
||||
<h1 class="title">${title}</h1>
|
||||
<p class="message">${message}</p>
|
||||
</div>
|
||||
${autoClose ? `<script>setTimeout(() => window.close(), 2000);</script>` : ""}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier (43-128 characters of unreserved URI characters)
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64UrlEncode(array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code challenge from verifier using SHA-256
|
||||
*/
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure state parameter (32-byte hex)
|
||||
*/
|
||||
export function generateState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode (RFC 4648)
|
||||
*/
|
||||
function base64UrlEncode(buffer: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...buffer));
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT payload (no signature verification - for local extraction only)
|
||||
*/
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
const payload = parts[1];
|
||||
if (!payload) {
|
||||
throw new Error("Missing JWT payload");
|
||||
}
|
||||
// Handle base64url encoding
|
||||
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const decoded = atob(padded);
|
||||
return JSON.parse(decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ChatGPT Account ID from access token JWT
|
||||
* The account ID is in the custom claim: https://api.openai.com/auth.chatgpt_account_id
|
||||
*/
|
||||
export function extractAccountIdFromToken(accessToken: string): string {
|
||||
try {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
// The account ID is in the custom claim path
|
||||
const authClaim = payload["https://api.openai.com/auth"] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (authClaim && typeof authClaim.chatgpt_account_id === "string") {
|
||||
return authClaim.chatgpt_account_id;
|
||||
}
|
||||
throw new Error("chatgpt_account_id not found in token claims");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to extract account ID from token: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge
|
||||
*/
|
||||
export async function generatePKCE(): Promise<{
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
}> {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
return { codeVerifier, codeChallenge };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local HTTP server to receive OAuth callback
|
||||
* Returns a promise that resolves with the authorization code when received
|
||||
*/
|
||||
export function startLocalOAuthServer(
|
||||
expectedState: string,
|
||||
port = OPENAI_OAUTH_CONFIG.defaultPort,
|
||||
): Promise<{ result: OAuthCallbackResult; server: http.Server }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || "", `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === OPENAI_OAUTH_CONFIG.callbackPath) {
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const error = url.searchParams.get("error");
|
||||
const errorDescription = url.searchParams.get("error_description");
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
success: false,
|
||||
title: "Authentication Failed",
|
||||
message: `Error: ${error}`,
|
||||
detail: errorDescription || undefined,
|
||||
}),
|
||||
);
|
||||
reject(
|
||||
new Error(`OAuth error: ${error} - ${errorDescription || ""}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
success: false,
|
||||
title: "Authentication Failed",
|
||||
message: "Missing authorization code or state parameter.",
|
||||
}),
|
||||
);
|
||||
reject(new Error("Missing authorization code or state parameter"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== expectedState) {
|
||||
res.writeHead(400, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
success: false,
|
||||
title: "Authentication Failed",
|
||||
message:
|
||||
"State mismatch - the authorization may have been tampered with.",
|
||||
}),
|
||||
);
|
||||
reject(
|
||||
new Error(
|
||||
"State mismatch - the authorization may have been tampered with",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success!
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(
|
||||
renderOAuthPage({
|
||||
success: true,
|
||||
title: "Authorization Successful",
|
||||
message: "You can close this window and return to Letta Code.",
|
||||
autoClose: true,
|
||||
}),
|
||||
);
|
||||
|
||||
resolve({ result: { code, state }, server });
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not found");
|
||||
}
|
||||
});
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
reject(
|
||||
new Error(
|
||||
`Port ${port} is already in use. Please close any application using this port and try again.`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
// Server started successfully, waiting for callback
|
||||
});
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(
|
||||
() => {
|
||||
server.close();
|
||||
reject(
|
||||
new Error("OAuth timeout - no callback received within 5 minutes"),
|
||||
);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OAuth flow - returns authorization URL and PKCE values
|
||||
* Also starts local server to receive callback
|
||||
*/
|
||||
export async function startOpenAIOAuth(
|
||||
port = OPENAI_OAUTH_CONFIG.defaultPort,
|
||||
): Promise<{
|
||||
authorizationUrl: string;
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
}> {
|
||||
const state = generateState();
|
||||
const { codeVerifier, codeChallenge } = await generatePKCE();
|
||||
const redirectUri = `http://localhost:${port}${OPENAI_OAUTH_CONFIG.callbackPath}`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: OPENAI_OAUTH_CONFIG.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: OPENAI_OAUTH_CONFIG.scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
id_token_add_organizations: "true",
|
||||
codex_cli_simplified_flow: "true",
|
||||
originator: "codex_cli_rs",
|
||||
});
|
||||
|
||||
const authorizationUrl = `${OPENAI_OAUTH_CONFIG.authorizationUrl}?${params.toString()}`;
|
||||
|
||||
return {
|
||||
authorizationUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
redirectUri: string,
|
||||
): Promise<OpenAITokens> {
|
||||
const response = await fetch(OPENAI_OAUTH_CONFIG.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: OPENAI_OAUTH_CONFIG.clientId,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Failed to exchange code for tokens (HTTP ${response.status}): ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as OpenAITokens;
|
||||
}
|
||||
Reference in New Issue
Block a user