Add Codex OAuth provider flow for ChatGPT account sign-in (#503)
* feat: add Codex OAuth provider flow * fix: harden Codex OAuth storage, session activation, and UI
This commit is contained in:
committed by
GitHub
parent
252808bbd0
commit
fc7dc9ca0d
307
src/services/api/codexOAuth.ts
Normal file
307
src/services/api/codexOAuth.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user