egaki 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,387 @@
1
+ // ChatGPT OAuth authentication for egaki.
2
+ // Implements the same OAuth 2.0 PKCE browser flow used by OpenAI Codex CLI.
3
+ // Tokens are stored inside credentials.json under the 'chatgpt' key as a
4
+ // structured object (not a plain string like other providers).
5
+ //
6
+ // Tokens are stored so egaki can reuse the ChatGPT login for Codex-style
7
+ // backend requests and refresh them when needed.
8
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
9
+ import { spawn } from 'node:child_process'
10
+ import { spinner, log, note } from '@clack/prompts'
11
+ import pc from 'picocolors'
12
+
13
+ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
14
+ const ISSUER = 'https://auth.openai.com'
15
+ const OAUTH_PORT = 1455
16
+ const REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback`
17
+
18
+ // ─── types ───────────────────────────────────────────────────────────────────
19
+
20
+ export type ChatGptAuth = {
21
+ email?: string
22
+ accountId?: string
23
+ plan?: string
24
+ refresh: string
25
+ access: string
26
+ expires: number
27
+ }
28
+
29
+ type OAuthTokens = {
30
+ id_token?: string
31
+ access_token: string
32
+ refresh_token: string
33
+ expires_in?: number
34
+ }
35
+
36
+ type PendingOAuth = {
37
+ verifier: string
38
+ state: string
39
+ resolve: (tokens: OAuthTokens) => void
40
+ reject: (error: Error) => void
41
+ }
42
+
43
+ // ─── PKCE helpers ────────────────────────────────────────────────────────────
44
+
45
+ function randomString(length: number): string {
46
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
47
+ const bytes = crypto.getRandomValues(new Uint8Array(length))
48
+ return Array.from(bytes)
49
+ .map((b) => chars[b % chars.length])
50
+ .join('')
51
+ }
52
+
53
+ function base64UrlEncode(buffer: ArrayBuffer): string {
54
+ const bytes = new Uint8Array(buffer)
55
+ const binary = String.fromCharCode(...bytes)
56
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
57
+ }
58
+
59
+ async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
60
+ const verifier = randomString(43)
61
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
62
+ return { verifier, challenge: base64UrlEncode(hash) }
63
+ }
64
+
65
+ function generateState(): string {
66
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
67
+ }
68
+
69
+ // ─── JWT parsing ─────────────────────────────────────────────────────────────
70
+
71
+ function parseJwtClaims(token: string): Record<string, unknown> | undefined {
72
+ const parts = token.split('.')
73
+ if (parts.length !== 3) return undefined
74
+ const payload = parts[1]
75
+ if (!payload) return undefined
76
+ try {
77
+ return JSON.parse(Buffer.from(payload, 'base64url').toString()) as Record<string, unknown>
78
+ } catch {
79
+ return undefined
80
+ }
81
+ }
82
+
83
+ function extractEmail(tokens: OAuthTokens): string | undefined {
84
+ const idClaims = tokens.id_token ? parseJwtClaims(tokens.id_token) : undefined
85
+ const accessClaims = parseJwtClaims(tokens.access_token)
86
+ return (
87
+ (typeof idClaims?.email === 'string' ? idClaims.email : undefined) ??
88
+ (typeof accessClaims?.email === 'string' ? accessClaims.email : undefined)
89
+ )
90
+ }
91
+
92
+ function extractPlanTypeFromTokens(tokens: OAuthTokens): string | undefined {
93
+ const idClaims = tokens.id_token ? parseJwtClaims(tokens.id_token) : undefined
94
+ const accessClaims = parseJwtClaims(tokens.access_token)
95
+ const idAuth = idClaims?.['https://api.openai.com/auth'] as { chatgpt_plan_type?: string } | undefined
96
+ const accessAuth = accessClaims?.['https://api.openai.com/auth'] as
97
+ | { chatgpt_plan_type?: string }
98
+ | undefined
99
+ return idAuth?.chatgpt_plan_type ?? accessAuth?.chatgpt_plan_type
100
+ }
101
+
102
+ function extractAccountId(tokens: OAuthTokens): string | undefined {
103
+ const idClaims = tokens.id_token ? parseJwtClaims(tokens.id_token) : undefined
104
+ const accessClaims = parseJwtClaims(tokens.access_token)
105
+
106
+ const idAuth = idClaims?.['https://api.openai.com/auth'] as Record<string, unknown> | undefined
107
+ const accessAuth = accessClaims?.['https://api.openai.com/auth'] as
108
+ | Record<string, unknown>
109
+ | undefined
110
+
111
+ return (
112
+ (typeof idClaims?.chatgpt_account_id === 'string' ? idClaims.chatgpt_account_id : undefined) ??
113
+ (typeof idAuth?.chatgpt_account_id === 'string' ? idAuth.chatgpt_account_id : undefined) ??
114
+ (typeof accessClaims?.chatgpt_account_id === 'string'
115
+ ? accessClaims.chatgpt_account_id
116
+ : undefined) ??
117
+ (typeof accessAuth?.chatgpt_account_id === 'string'
118
+ ? accessAuth.chatgpt_account_id
119
+ : undefined)
120
+ )
121
+ }
122
+
123
+ /** Extract the ChatGPT plan type (e.g. "plus", "pro") from the access token. */
124
+ export function extractPlanType(auth: ChatGptAuth): string | undefined {
125
+ if (auth.plan) return auth.plan
126
+ const claims = parseJwtClaims(auth.access)
127
+ const authClaim = claims?.['https://api.openai.com/auth'] as
128
+ | { chatgpt_plan_type?: string }
129
+ | undefined
130
+ return authClaim?.chatgpt_plan_type
131
+ }
132
+
133
+ // ─── OAuth URL + token exchange ──────────────────────────────────────────────
134
+
135
+ function buildAuthorizeUrl(pkce: { challenge: string }, state: string): string {
136
+ const params = new URLSearchParams({
137
+ response_type: 'code',
138
+ client_id: CLIENT_ID,
139
+ redirect_uri: REDIRECT_URI,
140
+ scope: 'openid profile email offline_access api.connectors.read api.connectors.invoke',
141
+ code_challenge: pkce.challenge,
142
+ code_challenge_method: 'S256',
143
+ id_token_add_organizations: 'true',
144
+ codex_cli_simplified_flow: 'true',
145
+ state,
146
+ originator: 'egaki',
147
+ })
148
+ return `${ISSUER}/oauth/authorize?${params.toString()}`
149
+ }
150
+
151
+ async function exchangeCodeForTokens(code: string, verifier: string): Promise<OAuthTokens> {
152
+ const response = await fetch(`${ISSUER}/oauth/token`, {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
155
+ body: new URLSearchParams({
156
+ grant_type: 'authorization_code',
157
+ code,
158
+ redirect_uri: REDIRECT_URI,
159
+ client_id: CLIENT_ID,
160
+ code_verifier: verifier,
161
+ }).toString(),
162
+ })
163
+ if (!response.ok) {
164
+ throw new Error(`Token exchange failed: ${response.status}`)
165
+ }
166
+ return (await response.json()) as OAuthTokens
167
+ }
168
+
169
+ // ─── local callback server ───────────────────────────────────────────────────
170
+
171
+ const HTML_SUCCESS = `<!doctype html>
172
+ <html><body style="font-family:system-ui;text-align:center;padding:4em">
173
+ <h1>Authorization Successful</h1>
174
+ <p>You can close this window and return to the terminal.</p>
175
+ </body></html>`
176
+
177
+ const htmlError = (error: string) =>
178
+ `<!doctype html><html><body style="font-family:system-ui;text-align:center;padding:4em">
179
+ <h1>Authorization Failed</h1><pre>${error}</pre></body></html>`
180
+
181
+ let oauthServer: ReturnType<typeof createServer> | undefined
182
+ let pendingOAuth: PendingOAuth | undefined
183
+
184
+ function handleOAuthRequest(req: IncomingMessage, res: ServerResponse): void {
185
+ const url = new URL(req.url ?? '/', `http://localhost:${OAUTH_PORT}`)
186
+
187
+ if (url.pathname !== '/auth/callback') {
188
+ res.statusCode = 404
189
+ res.end('Not found')
190
+ return
191
+ }
192
+
193
+ const code = url.searchParams.get('code')
194
+ const state = url.searchParams.get('state')
195
+ const error = url.searchParams.get('error')
196
+ const errorDescription = url.searchParams.get('error_description')
197
+
198
+ if (error) {
199
+ const message = errorDescription || error
200
+ pendingOAuth?.reject(new Error(message))
201
+ pendingOAuth = undefined
202
+ res.statusCode = 400
203
+ res.setHeader('Content-Type', 'text/html')
204
+ res.end(htmlError(message))
205
+ return
206
+ }
207
+
208
+ if (!code) {
209
+ const message = 'Missing authorization code'
210
+ pendingOAuth?.reject(new Error(message))
211
+ pendingOAuth = undefined
212
+ res.statusCode = 400
213
+ res.setHeader('Content-Type', 'text/html')
214
+ res.end(htmlError(message))
215
+ return
216
+ }
217
+
218
+ if (!pendingOAuth || state !== pendingOAuth.state) {
219
+ const message = 'Invalid state — potential CSRF attack'
220
+ pendingOAuth?.reject(new Error(message))
221
+ pendingOAuth = undefined
222
+ res.statusCode = 400
223
+ res.setHeader('Content-Type', 'text/html')
224
+ res.end(htmlError(message))
225
+ return
226
+ }
227
+
228
+ const current = pendingOAuth
229
+ pendingOAuth = undefined
230
+ exchangeCodeForTokens(code, current.verifier)
231
+ .then((tokens) => current.resolve(tokens))
232
+ .catch((err) => current.reject(err instanceof Error ? err : new Error(String(err))))
233
+
234
+ res.statusCode = 200
235
+ res.setHeader('Content-Type', 'text/html')
236
+ res.end(HTML_SUCCESS)
237
+ }
238
+
239
+ async function startOAuthServer(): Promise<void> {
240
+ if (oauthServer) return
241
+ oauthServer = createServer(handleOAuthRequest)
242
+ await new Promise<void>((resolve, reject) => {
243
+ oauthServer?.once('error', reject)
244
+ oauthServer?.listen(OAUTH_PORT, '127.0.0.1', () => resolve())
245
+ })
246
+ }
247
+
248
+ async function stopOAuthServer(): Promise<void> {
249
+ if (!oauthServer) return
250
+ await new Promise<void>((resolve) => oauthServer?.close(() => resolve()))
251
+ oauthServer = undefined
252
+ }
253
+
254
+ function waitForOAuthCallback(verifier: string, state: string): Promise<OAuthTokens> {
255
+ return new Promise((resolve, reject) => {
256
+ const timeout = setTimeout(
257
+ () => {
258
+ if (pendingOAuth) {
259
+ pendingOAuth = undefined
260
+ reject(new Error('OAuth callback timeout — authorization took too long'))
261
+ }
262
+ },
263
+ 5 * 60 * 1000,
264
+ )
265
+
266
+ pendingOAuth = {
267
+ verifier,
268
+ state,
269
+ resolve: (tokens) => {
270
+ clearTimeout(timeout)
271
+ resolve(tokens)
272
+ },
273
+ reject: (error) => {
274
+ clearTimeout(timeout)
275
+ reject(error)
276
+ },
277
+ }
278
+ })
279
+ }
280
+
281
+ function openBrowser(url: string): void {
282
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
283
+ const child = spawn(cmd, [url], { detached: true, stdio: 'ignore' })
284
+ child.unref()
285
+ }
286
+
287
+ // ─── public API ──────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Run the full ChatGPT OAuth browser flow with clack UI.
291
+ * Returns the ChatGptAuth object to be stored in credentials.json.
292
+ */
293
+ export async function chatGptOAuthLogin(): Promise<ChatGptAuth> {
294
+ await startOAuthServer()
295
+
296
+ const pkce = await generatePKCE()
297
+ const state = generateState()
298
+ const authUrl = buildAuthorizeUrl(pkce, state)
299
+ const callbackPromise = waitForOAuthCallback(pkce.verifier, state)
300
+
301
+ note(
302
+ `Open this URL in your browser to sign in with your ChatGPT account:\n\n` +
303
+ ` ${pc.cyan(pc.underline(authUrl))}\n\n` +
304
+ `${pc.dim(`Listening for callback on ${REDIRECT_URI}`)}`,
305
+ 'ChatGPT OAuth',
306
+ )
307
+
308
+ openBrowser(authUrl)
309
+
310
+ const s = spinner()
311
+ s.start('Waiting for authorization in browser...')
312
+
313
+ let tokens: OAuthTokens
314
+ try {
315
+ tokens = await callbackPromise
316
+ } finally {
317
+ await stopOAuthServer()
318
+ }
319
+
320
+ s.stop('Authorization received')
321
+
322
+ const now = Date.now()
323
+ const auth: ChatGptAuth = {
324
+ email: extractEmail(tokens),
325
+ accountId: extractAccountId(tokens),
326
+ plan: extractPlanTypeFromTokens(tokens),
327
+ refresh: tokens.refresh_token,
328
+ access: tokens.access_token,
329
+ expires: now + (tokens.expires_in ?? 3600) * 1000,
330
+ }
331
+
332
+ const plan = extractPlanType(auth)
333
+ const label = auth.email ?? auth.accountId ?? 'unknown'
334
+ log.success(
335
+ `Signed in as ${pc.bold(label)}` + (plan ? ` (${pc.cyan(plan)} plan)` : ''),
336
+ )
337
+
338
+ return auth
339
+ }
340
+
341
+ /**
342
+ * Refresh an expired ChatGPT access token using the refresh token.
343
+ * Returns the updated auth object, or an Error if refresh fails.
344
+ */
345
+ export async function refreshChatGptToken(auth: ChatGptAuth): Promise<ChatGptAuth | Error> {
346
+ try {
347
+ const response = await fetch(`${ISSUER}/oauth/token`, {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
350
+ body: new URLSearchParams({
351
+ grant_type: 'refresh_token',
352
+ refresh_token: auth.refresh,
353
+ client_id: CLIENT_ID,
354
+ }).toString(),
355
+ })
356
+ if (!response.ok) {
357
+ return new Error(`Token refresh failed: ${response.status}`)
358
+ }
359
+ const json = (await response.json()) as OAuthTokens
360
+ return {
361
+ ...auth,
362
+ plan: extractPlanTypeFromTokens(json) ?? auth.plan,
363
+ access: json.access_token,
364
+ refresh: json.refresh_token ?? auth.refresh,
365
+ expires: Date.now() + (json.expires_in ?? 3600) * 1000,
366
+ }
367
+ } catch (err) {
368
+ return err instanceof Error ? err : new Error(String(err))
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Refresh the auth object if needed and return the full updated state.
374
+ */
375
+ export async function getValidChatGptAuth(
376
+ auth: ChatGptAuth,
377
+ onRefresh?: (updated: ChatGptAuth) => void,
378
+ ): Promise<ChatGptAuth | Error> {
379
+ if (auth.expires > Date.now() + 60_000) {
380
+ return auth
381
+ }
382
+
383
+ const refreshed = await refreshChatGptToken(auth)
384
+ if (refreshed instanceof Error) return refreshed
385
+ onRefresh?.(refreshed)
386
+ return refreshed
387
+ }