* feat: add Codex OAuth provider flow * fix: harden Codex OAuth storage, session activation, and UI
308 lines
8.8 KiB
TypeScript
308 lines
8.8 KiB
TypeScript
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
|
|
import {
|
|
generateCodeChallenge,
|
|
generateCodeVerifier,
|
|
generateState,
|
|
} from '../oauth/crypto.js'
|
|
import {
|
|
asTrimmedString,
|
|
CODEX_OAUTH_ISSUER,
|
|
CODEX_OAUTH_ORIGINATOR,
|
|
CODEX_OAUTH_SCOPE,
|
|
escapeHtml,
|
|
exchangeCodexIdTokenForApiKey,
|
|
getCodexOAuthCallbackPort,
|
|
getCodexOAuthClientId,
|
|
parseChatgptAccountId,
|
|
} from './codexOAuthShared.js'
|
|
|
|
type CodexOAuthTokenResponse = {
|
|
id_token?: string
|
|
access_token?: string
|
|
refresh_token?: string
|
|
}
|
|
|
|
export type CodexOAuthTokens = {
|
|
apiKey?: string
|
|
accessToken: string
|
|
refreshToken: string
|
|
idToken?: string
|
|
accountId?: string
|
|
}
|
|
|
|
function buildCodexAuthorizeUrl(options: {
|
|
port: number
|
|
codeChallenge: string
|
|
state: string
|
|
}): string {
|
|
const redirectUri = `http://localhost:${options.port}/auth/callback`
|
|
const authUrl = new URL(`${CODEX_OAUTH_ISSUER}/oauth/authorize`)
|
|
|
|
authUrl.searchParams.append('response_type', 'code')
|
|
authUrl.searchParams.append('client_id', getCodexOAuthClientId())
|
|
authUrl.searchParams.append('redirect_uri', redirectUri)
|
|
authUrl.searchParams.append('scope', CODEX_OAUTH_SCOPE)
|
|
authUrl.searchParams.append('code_challenge', options.codeChallenge)
|
|
authUrl.searchParams.append('code_challenge_method', 'S256')
|
|
authUrl.searchParams.append('id_token_add_organizations', 'true')
|
|
authUrl.searchParams.append('codex_cli_simplified_flow', 'true')
|
|
authUrl.searchParams.append('state', options.state)
|
|
authUrl.searchParams.append('originator', CODEX_OAUTH_ORIGINATOR)
|
|
|
|
return authUrl.toString()
|
|
}
|
|
|
|
function renderSuccessPage(): string {
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Codex Login Complete</title>
|
|
<style>
|
|
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
|
|
h1 { margin: 0 0 12px; font-size: 22px; }
|
|
p { margin: 0 0 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Codex login complete</h1>
|
|
<p>You can return to OpenClaude now.</p>
|
|
<p>OpenClaude will finish activating your new Codex OAuth login.</p>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
function renderErrorPage(message: string): string {
|
|
const safeMessage = escapeHtml(message)
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Codex Login Failed</title>
|
|
<style>
|
|
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
|
|
h1 { margin: 0 0 12px; font-size: 22px; color: #991b1b; }
|
|
p { margin: 0 0 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Codex login failed</h1>
|
|
<p>${safeMessage}</p>
|
|
<p>You can close this window and try again in OpenClaude.</p>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
function renderCancelledPage(): string {
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Codex Login Cancelled</title>
|
|
<style>
|
|
body { font-family: sans-serif; padding: 32px; line-height: 1.5; color: #111827; }
|
|
h1 { margin: 0 0 12px; font-size: 22px; }
|
|
p { margin: 0 0 10px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Codex login cancelled</h1>
|
|
<p>You can close this window and retry in OpenClaude.</p>
|
|
</body>
|
|
</html>`
|
|
}
|
|
|
|
async function exchangeAuthorizationCode(options: {
|
|
authorizationCode: string
|
|
codeVerifier: string
|
|
port: number
|
|
signal?: AbortSignal
|
|
}): Promise<CodexOAuthTokens> {
|
|
const redirectUri = `http://localhost:${options.port}/auth/callback`
|
|
const body = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code: options.authorizationCode,
|
|
redirect_uri: redirectUri,
|
|
client_id: getCodexOAuthClientId(),
|
|
code_verifier: options.codeVerifier,
|
|
})
|
|
|
|
const response = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body,
|
|
signal: options.signal
|
|
? AbortSignal.any([options.signal, AbortSignal.timeout(15_000)])
|
|
: AbortSignal.timeout(15_000),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
throw new Error(
|
|
errorText.trim()
|
|
? `Codex OAuth token exchange failed (${response.status}): ${errorText.trim()}`
|
|
: `Codex OAuth token exchange failed with status ${response.status}.`,
|
|
)
|
|
}
|
|
|
|
const payload = (await response.json()) as CodexOAuthTokenResponse
|
|
const accessToken = asTrimmedString(payload.access_token)
|
|
const refreshToken = asTrimmedString(payload.refresh_token)
|
|
if (!accessToken || !refreshToken) {
|
|
throw new Error(
|
|
'Codex OAuth completed, but the token response was missing credentials.',
|
|
)
|
|
}
|
|
|
|
const idToken = asTrimmedString(payload.id_token)
|
|
const apiKey = idToken
|
|
? await exchangeCodexIdTokenForApiKey(idToken).catch(() => undefined)
|
|
: undefined
|
|
|
|
return {
|
|
apiKey,
|
|
accessToken,
|
|
refreshToken,
|
|
idToken,
|
|
accountId:
|
|
parseChatgptAccountId(idToken) ?? parseChatgptAccountId(accessToken),
|
|
}
|
|
}
|
|
|
|
export class CodexOAuthService {
|
|
private authCodeListener: AuthCodeListener | null = null
|
|
private port: number | null = null
|
|
private tokenExchangeAbortController: AbortController | null = null
|
|
|
|
private buildCancellationError(): Error {
|
|
return new Error('Codex OAuth flow was cancelled.')
|
|
}
|
|
|
|
async startOAuthFlow(
|
|
authURLHandler: (authUrl: string) => Promise<void>,
|
|
): Promise<CodexOAuthTokens> {
|
|
const codeVerifier = generateCodeVerifier()
|
|
const callbackPort = getCodexOAuthCallbackPort()
|
|
const authCodeListener = new AuthCodeListener('/auth/callback')
|
|
|
|
this.authCodeListener = authCodeListener
|
|
this.port = null
|
|
|
|
try {
|
|
const port = await authCodeListener.start(callbackPort)
|
|
this.port = port
|
|
|
|
const state = generateState()
|
|
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
|
const authUrl = buildCodexAuthorizeUrl({
|
|
port,
|
|
codeChallenge,
|
|
state,
|
|
})
|
|
|
|
try {
|
|
const authorizationCode = await authCodeListener.waitForAuthorization(
|
|
state,
|
|
async () => {
|
|
await authURLHandler(authUrl)
|
|
},
|
|
)
|
|
|
|
const tokenExchangeAbortController = new AbortController()
|
|
this.tokenExchangeAbortController = tokenExchangeAbortController
|
|
|
|
let tokens: CodexOAuthTokens
|
|
try {
|
|
tokens = await exchangeAuthorizationCode({
|
|
authorizationCode,
|
|
codeVerifier,
|
|
port,
|
|
signal: tokenExchangeAbortController.signal,
|
|
})
|
|
} finally {
|
|
if (
|
|
this.tokenExchangeAbortController === tokenExchangeAbortController
|
|
) {
|
|
this.tokenExchangeAbortController = null
|
|
}
|
|
}
|
|
|
|
if (this.authCodeListener !== authCodeListener) {
|
|
throw this.buildCancellationError()
|
|
}
|
|
|
|
authCodeListener.handleSuccessRedirect([], res => {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
})
|
|
res.end(renderSuccessPage())
|
|
})
|
|
|
|
return tokens
|
|
} catch (error) {
|
|
const resolvedError =
|
|
this.authCodeListener === authCodeListener
|
|
? error
|
|
: this.buildCancellationError()
|
|
|
|
if (authCodeListener.hasPendingResponse()) {
|
|
const isCancellation =
|
|
resolvedError instanceof Error &&
|
|
resolvedError.message === 'Codex OAuth flow was cancelled.'
|
|
|
|
authCodeListener.handleErrorRedirect(res => {
|
|
res.writeHead(isCancellation ? 200 : 400, {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
})
|
|
res.end(
|
|
isCancellation
|
|
? renderCancelledPage()
|
|
: renderErrorPage(
|
|
resolvedError instanceof Error
|
|
? resolvedError.message
|
|
: String(resolvedError),
|
|
),
|
|
)
|
|
})
|
|
}
|
|
throw resolvedError
|
|
} finally {
|
|
this.cleanup()
|
|
}
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
if (
|
|
message.includes('EADDRINUSE') ||
|
|
message.includes(String(callbackPort))
|
|
) {
|
|
throw new Error(
|
|
`Codex OAuth needs localhost:${callbackPort} for its callback. Close any app already using that port and try again.`,
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
cleanup(): void {
|
|
const cancellationError = this.buildCancellationError()
|
|
|
|
this.tokenExchangeAbortController?.abort(cancellationError)
|
|
this.tokenExchangeAbortController = null
|
|
|
|
if (this.authCodeListener?.hasPendingResponse()) {
|
|
this.authCodeListener.handleErrorRedirect(res => {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
})
|
|
res.end(renderCancelledPage())
|
|
})
|
|
}
|
|
|
|
this.authCodeListener?.cancelPendingAuthorization(cancellationError)
|
|
this.authCodeListener = null
|
|
this.port = null
|
|
}
|
|
}
|