mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
OpenAI rejects the authorize URL before callback when non-Codex query parameters are sent. The ChatGPT OAuth flow now matches the sub2api Codex-compatible shape by keeping originator out of the authorize URL and using OpenAI-specific hex state and PKCE verifier values. Constraint: The Codex OAuth client expects the standard authorize query shape used by sub2api. Rejected: Keep originator=opencode in the OAuth URL | originator is a later ChatGPT API request header concern, not an OAuth authorize parameter. Confidence: high Scope-risk: narrow Tested: bun test src/services/openaiAuth/client.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts src/server/__tests__/haha-openai-oauth-api.test.ts src/services/openaiAuth/fetch.test.ts src/services/openaiAuth/storage.test.ts Tested: git diff --check Tested: bun run check:server Not-tested: Live browser OAuth authorization against a real OpenAI account.
212 lines
6.1 KiB
TypeScript
212 lines
6.1 KiB
TypeScript
import { openBrowser } from '../../utils/browser.js'
|
|
import { AuthCodeListener } from '../oauth/auth-code-listener.js'
|
|
import {
|
|
buildOpenAIAuthorizeUrl,
|
|
exchangeOpenAICodeForTokens,
|
|
generateOpenAICodeVerifier,
|
|
generateOpenAIState,
|
|
isOpenAITokenExpired,
|
|
OPENAI_CODEX_OAUTH_PORT,
|
|
OPENAI_CODEX_REDIRECT_PATH,
|
|
refreshOpenAITokens,
|
|
normalizeOpenAITokens,
|
|
withRefreshedAccessToken,
|
|
} from './client.js'
|
|
import {
|
|
clearOpenAIOAuthTokenCache,
|
|
deleteOpenAIOAuthTokens,
|
|
getOpenAIOAuthTokensAsync,
|
|
saveOpenAIOAuthTokens,
|
|
} from './storage.js'
|
|
import type { OpenAIOAuthTokens } from './types.js'
|
|
|
|
const HTML_SUCCESS = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>cc-haha OpenAI Authorization Successful</title>
|
|
<style>
|
|
body { font-family: system-ui, -apple-system, sans-serif; display:flex; justify-content:center; align-items:center; height:100vh; margin:0; background:#131010; color:#f1ecec; }
|
|
.container { text-align:center; padding:2rem; }
|
|
p { color:#b7b1b1; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Authorization Successful</h1>
|
|
<p>You can close this window and return to Claude Code Haha.</p>
|
|
</div>
|
|
<script>setTimeout(() => window.close(), 2000)</script>
|
|
</body>
|
|
</html>`
|
|
|
|
const HTML_ERROR = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>cc-haha OpenAI Authorization Failed</title>
|
|
</head>
|
|
<body>
|
|
<h1>Authorization Failed</h1>
|
|
<p>You can close this window and return to Claude Code Haha.</p>
|
|
</body>
|
|
</html>`
|
|
|
|
export class OpenAIOAuthService {
|
|
private codeVerifier: string
|
|
private authCodeListener: AuthCodeListener | null = null
|
|
private port: number | null = null
|
|
private manualAuthCodeResolver: ((authorizationCode: string) => void) | null =
|
|
null
|
|
|
|
constructor() {
|
|
this.codeVerifier = generateOpenAICodeVerifier()
|
|
}
|
|
|
|
async startOAuthFlow(
|
|
authURLHandler: (url: string, automaticUrl?: string) => Promise<void>,
|
|
options?: {
|
|
skipBrowserOpen?: boolean
|
|
},
|
|
): Promise<OpenAIOAuthTokens> {
|
|
this.authCodeListener = new AuthCodeListener(OPENAI_CODEX_REDIRECT_PATH)
|
|
this.port = await this.authCodeListener.start(OPENAI_CODEX_OAUTH_PORT)
|
|
|
|
const state = generateOpenAIState()
|
|
const redirectUri = `http://localhost:${this.port}${OPENAI_CODEX_REDIRECT_PATH}`
|
|
const authorizeUrl = buildOpenAIAuthorizeUrl({
|
|
redirectUri,
|
|
codeVerifier: this.codeVerifier,
|
|
state,
|
|
})
|
|
|
|
const authorizationCode = await this.waitForAuthorizationCode(
|
|
state,
|
|
async () => {
|
|
if (options?.skipBrowserOpen) {
|
|
await authURLHandler(authorizeUrl, authorizeUrl)
|
|
} else {
|
|
await authURLHandler(authorizeUrl)
|
|
await openBrowser(authorizeUrl)
|
|
}
|
|
},
|
|
)
|
|
|
|
try {
|
|
const response = await exchangeOpenAICodeForTokens({
|
|
code: authorizationCode,
|
|
redirectUri,
|
|
codeVerifier: this.codeVerifier,
|
|
})
|
|
|
|
const tokens = normalizeOpenAITokens(response)
|
|
const storage = saveOpenAIOAuthTokens(tokens)
|
|
if (!storage.success) {
|
|
throw new Error(storage.warning ?? 'Failed to persist OpenAI OAuth tokens')
|
|
}
|
|
|
|
this.authCodeListener?.handleSuccessRedirect([], (res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
res.end(HTML_SUCCESS)
|
|
})
|
|
|
|
return tokens
|
|
} catch (error) {
|
|
this.authCodeListener?.handleSuccessRedirect([], (res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
res.end(HTML_ERROR)
|
|
})
|
|
throw error
|
|
} finally {
|
|
this.cleanup()
|
|
}
|
|
}
|
|
|
|
private async waitForAuthorizationCode(
|
|
state: string,
|
|
onReady: () => Promise<void>,
|
|
): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
this.manualAuthCodeResolver = resolve
|
|
|
|
this.authCodeListener
|
|
?.waitForAuthorization(state, onReady)
|
|
.then((authorizationCode) => {
|
|
this.manualAuthCodeResolver = null
|
|
resolve(authorizationCode)
|
|
})
|
|
.catch((error) => {
|
|
this.manualAuthCodeResolver = null
|
|
reject(error)
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Handle manual auth code input — e.g., user pastes the authorization code
|
|
* from the browser into the desktop UI when automatic redirect didn't work.
|
|
*/
|
|
handleManualAuthCodeInput(params: {
|
|
authorizationCode: string
|
|
state: string
|
|
}): void {
|
|
if (this.manualAuthCodeResolver) {
|
|
this.manualAuthCodeResolver(params.authorizationCode)
|
|
this.manualAuthCodeResolver = null
|
|
this.authCodeListener?.close()
|
|
}
|
|
}
|
|
|
|
async ensureFreshTokens(): Promise<OpenAIOAuthTokens | null> {
|
|
clearOpenAIOAuthTokenCache()
|
|
const tokens = await getOpenAIOAuthTokensAsync()
|
|
if (!tokens) return null
|
|
if (!isOpenAITokenExpired(tokens.expiresAt)) return tokens
|
|
|
|
const refreshed = await refreshOpenAITokens(tokens.refreshToken)
|
|
const updated = withRefreshedAccessToken(tokens, refreshed)
|
|
const storage = saveOpenAIOAuthTokens(updated)
|
|
if (!storage.success) {
|
|
throw new Error(storage.warning ?? 'Failed to persist refreshed OpenAI tokens')
|
|
}
|
|
|
|
return updated
|
|
}
|
|
|
|
async ensureFreshAccessToken(): Promise<string | null> {
|
|
const tokens = await this.ensureFreshTokens()
|
|
return tokens?.accessToken ?? null
|
|
}
|
|
|
|
logout(): boolean {
|
|
clearOpenAIOAuthTokenCache()
|
|
return deleteOpenAIOAuthTokens()
|
|
}
|
|
|
|
cleanup(): void {
|
|
this.authCodeListener?.close()
|
|
this.authCodeListener = null
|
|
this.port = null
|
|
this.manualAuthCodeResolver = null
|
|
}
|
|
}
|
|
|
|
export async function ensureFreshOpenAITokens(): Promise<OpenAIOAuthTokens | null> {
|
|
clearOpenAIOAuthTokenCache()
|
|
const tokens = await getOpenAIOAuthTokensAsync()
|
|
if (!tokens) return null
|
|
if (!isOpenAITokenExpired(tokens.expiresAt)) return tokens
|
|
|
|
try {
|
|
const refreshed = await refreshOpenAITokens(tokens.refreshToken)
|
|
const updated = withRefreshedAccessToken(tokens, refreshed)
|
|
const storage = saveOpenAIOAuthTokens(updated)
|
|
if (!storage.success) {
|
|
throw new Error(storage.warning ?? 'Failed to persist refreshed OpenAI tokens')
|
|
}
|
|
return updated
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|