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
166
src/services/api/codexOAuth.test.ts
Normal file
166
src/services/api/codexOAuth.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
|
||||
import { CodexOAuthService } from './codexOAuth.js'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalCallbackPort = process.env.CODEX_OAUTH_CALLBACK_PORT
|
||||
const originalClientId = process.env.CODEX_OAUTH_CLIENT_ID
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
|
||||
if (originalCallbackPort === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CALLBACK_PORT
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = originalCallbackPort
|
||||
}
|
||||
|
||||
if (originalClientId === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CLIENT_ID
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = originalClientId
|
||||
}
|
||||
})
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate test port.')))
|
||||
return
|
||||
}
|
||||
|
||||
const { port } = address
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildCallbackRequest(authUrl: string): string {
|
||||
const authorizeUrl = new URL(authUrl)
|
||||
const redirectUri = authorizeUrl.searchParams.get('redirect_uri')
|
||||
const state = authorizeUrl.searchParams.get('state')
|
||||
|
||||
if (!redirectUri || !state) {
|
||||
throw new Error('Codex OAuth test did not receive a valid authorization URL.')
|
||||
}
|
||||
|
||||
const callbackUrl = new URL(redirectUri)
|
||||
callbackUrl.searchParams.set('code', 'auth-code')
|
||||
callbackUrl.searchParams.set('state', state)
|
||||
return callbackUrl.toString()
|
||||
}
|
||||
|
||||
test('serves updated success copy after a successful Codex OAuth flow', async () => {
|
||||
const callbackPort = await getFreePort()
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
|
||||
globalThis.fetch = mock(async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith('http://localhost:')) {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
const service = new CodexOAuthService()
|
||||
let callbackResponsePromise!: Promise<Response>
|
||||
|
||||
const flowPromise = service.startOAuthFlow(async authUrl => {
|
||||
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
|
||||
})
|
||||
|
||||
const tokens = await flowPromise
|
||||
const callbackResponse = await callbackResponsePromise
|
||||
const html = await callbackResponse.text()
|
||||
|
||||
expect(tokens.accessToken).toBe('access-token')
|
||||
expect(tokens.refreshToken).toBe('refresh-token')
|
||||
expect(html).toContain('You can return to OpenClaude now.')
|
||||
expect(html).toContain(
|
||||
'OpenClaude will finish activating your new Codex OAuth login.',
|
||||
)
|
||||
expect(html).not.toContain('continue automatically')
|
||||
})
|
||||
|
||||
test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => {
|
||||
const callbackPort = await getFreePort()
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
|
||||
let resolveFetchStart!: () => void
|
||||
const fetchStarted = new Promise<void>(resolve => {
|
||||
resolveFetchStart = resolve
|
||||
})
|
||||
|
||||
globalThis.fetch = mock((input, init) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith('http://localhost:')) {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
resolveFetchStart()
|
||||
|
||||
const signal = init?.signal
|
||||
if (!signal) {
|
||||
return
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason)
|
||||
return
|
||||
}
|
||||
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
const service = new CodexOAuthService()
|
||||
let callbackResponsePromise!: Promise<Response>
|
||||
|
||||
const flowPromise = service.startOAuthFlow(async authUrl => {
|
||||
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
|
||||
})
|
||||
|
||||
await fetchStarted
|
||||
service.cleanup()
|
||||
|
||||
await expect(flowPromise).rejects.toThrow('Codex OAuth flow was cancelled.')
|
||||
|
||||
const callbackResponse = await callbackResponsePromise
|
||||
const html = await callbackResponse.text()
|
||||
|
||||
expect(html).toContain('Codex login cancelled')
|
||||
expect(html).toContain('retry in OpenClaude')
|
||||
})
|
||||
Reference in New Issue
Block a user