* feat: add Codex OAuth provider flow * fix: harden Codex OAuth storage, session activation, and UI
167 lines
4.6 KiB
TypeScript
167 lines
4.6 KiB
TypeScript
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')
|
|
})
|