egaki 0.3.0 → 0.4.1
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.
- package/CHANGELOG.md +51 -0
- package/README.md +15 -0
- package/dist/chatgpt-auth.d.ts +25 -0
- package/dist/chatgpt-auth.d.ts.map +1 -0
- package/dist/chatgpt-auth.js +18 -11
- package/dist/cli.js +234 -15
- package/dist/credentials.d.ts +17 -4
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +50 -10
- package/dist/login.d.ts +1 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +44 -3
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +36 -3
- package/dist/models.d.ts +5 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +11 -1
- package/package.json +3 -2
- package/src/chatgpt-auth.ts +387 -0
- package/src/cli.ts +304 -18
- package/src/credentials.ts +70 -17
- package/src/login.ts +49 -4
- package/src/model-catalog.ts +38 -3
- package/src/models.ts +11 -1
|
@@ -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
|
+
}
|