@moxxy/plugin-provider-openai-codex 0.21.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/dist/codex/headers.d.ts +5 -0
  3. package/dist/codex/headers.d.ts.map +1 -0
  4. package/dist/codex/headers.js +39 -0
  5. package/dist/codex/headers.js.map +1 -0
  6. package/dist/codex/sse-event-handler.d.ts +16 -0
  7. package/dist/codex/sse-event-handler.d.ts.map +1 -0
  8. package/dist/codex/sse-event-handler.js +155 -0
  9. package/dist/codex/sse-event-handler.js.map +1 -0
  10. package/dist/codex/stream-consumer.d.ts +4 -0
  11. package/dist/codex/stream-consumer.d.ts.map +1 -0
  12. package/dist/codex/stream-consumer.js +176 -0
  13. package/dist/codex/stream-consumer.js.map +1 -0
  14. package/dist/codex/stream-types.d.ts +61 -0
  15. package/dist/codex/stream-types.d.ts.map +1 -0
  16. package/dist/codex/stream-types.js +19 -0
  17. package/dist/codex/stream-types.js.map +1 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +31 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/login.d.ts +33 -0
  23. package/dist/login.d.ts.map +1 -0
  24. package/dist/login.js +94 -0
  25. package/dist/login.js.map +1 -0
  26. package/dist/models.d.ts +10 -0
  27. package/dist/models.d.ts.map +1 -0
  28. package/dist/models.js +19 -0
  29. package/dist/models.js.map +1 -0
  30. package/dist/oauth.d.ts +74 -0
  31. package/dist/oauth.d.ts.map +1 -0
  32. package/dist/oauth.js +170 -0
  33. package/dist/oauth.js.map +1 -0
  34. package/dist/profile.d.ts +13 -0
  35. package/dist/profile.d.ts.map +1 -0
  36. package/dist/profile.js +39 -0
  37. package/dist/profile.js.map +1 -0
  38. package/dist/provider.d.ts +79 -0
  39. package/dist/provider.d.ts.map +1 -0
  40. package/dist/provider.js +306 -0
  41. package/dist/provider.js.map +1 -0
  42. package/dist/translate.d.ts +77 -0
  43. package/dist/translate.d.ts.map +1 -0
  44. package/dist/translate.js +172 -0
  45. package/dist/translate.js.map +1 -0
  46. package/dist/types.d.ts +27 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +2 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +66 -0
  51. package/src/codex/headers.ts +41 -0
  52. package/src/codex/sse-event-handler.test.ts +85 -0
  53. package/src/codex/sse-event-handler.ts +173 -0
  54. package/src/codex/stream-consumer.test.ts +280 -0
  55. package/src/codex/stream-consumer.ts +181 -0
  56. package/src/codex/stream-types.ts +61 -0
  57. package/src/index.ts +65 -0
  58. package/src/login.ts +121 -0
  59. package/src/models.ts +21 -0
  60. package/src/oauth.test.ts +223 -0
  61. package/src/oauth.ts +200 -0
  62. package/src/profile.ts +52 -0
  63. package/src/provider.test.ts +507 -0
  64. package/src/provider.ts +360 -0
  65. package/src/translate.test.ts +95 -0
  66. package/src/translate.ts +224 -0
  67. package/src/types.ts +28 -0
package/src/oauth.ts ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Codex-specific OAuth pieces: constants, JWT-claim extraction, and thin
3
+ * wrappers that adapt `@moxxy/plugin-oauth`'s generic helpers into the
4
+ * `CodexTokens` shape the provider class consumes. The actual flow
5
+ * orchestration lives in `profile.ts` + `login.ts`.
6
+ */
7
+
8
+ import { Buffer } from 'node:buffer';
9
+ import {
10
+ buildAuthUrl,
11
+ computeCodeChallenge,
12
+ exchangeCodeForToken as oauthExchangeCodeForToken,
13
+ generateCodeVerifier,
14
+ generateState as oauthGenerateState,
15
+ refreshAccessToken,
16
+ type TokenSet,
17
+ } from '@moxxy/plugin-oauth';
18
+ import type { CodexTokens, PkceCodes } from './types.js';
19
+
20
+ /**
21
+ * Public OAuth client id baked into the first-party Codex / OpenCode clients.
22
+ * Same value used by codex-rs (`codex-rs/login/src/auth/manager.rs`) and
23
+ * opencode (`packages/opencode/src/plugin/codex.ts`) — using it lets a moxxy
24
+ * login interoperate with credentials produced by either tool.
25
+ */
26
+ export const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
27
+ export const ISSUER = 'https://auth.openai.com';
28
+ export const AUTHORIZE_URL = `${ISSUER}/oauth/authorize`;
29
+ export const TOKEN_URL = `${ISSUER}/oauth/token`;
30
+ export const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
31
+ export const DEFAULT_CALLBACK_PORT = 1455;
32
+ export const DEFAULT_REDIRECT_PATH = '/auth/callback';
33
+ export const DEFAULT_REDIRECT_URI = `http://localhost:${DEFAULT_CALLBACK_PORT}${DEFAULT_REDIRECT_PATH}`;
34
+ export const SCOPES = 'openid profile email offline_access';
35
+ export const ORIGINATOR = 'moxxy';
36
+
37
+ /**
38
+ * PKCE pair for the Codex flow. Delegates to `@moxxy/plugin-oauth`'s shared
39
+ * primitives (S256 verifier -> challenge) instead of hand-rolling crypto;
40
+ * kept as a thin wrapper so it returns the `PkceCodes` shape and stays a
41
+ * stable public export.
42
+ */
43
+ export async function generatePKCE(): Promise<PkceCodes> {
44
+ const verifier = generateCodeVerifier();
45
+ return { verifier, challenge: computeCodeChallenge(verifier) };
46
+ }
47
+
48
+ export const generateState = oauthGenerateState;
49
+
50
+ /**
51
+ * Thin wrapper over `@moxxy/plugin-oauth`'s generic `buildAuthUrl` that
52
+ * stamps in the Codex-specific extras. Exported because tests + downstream
53
+ * consumers may want to build URLs without running the full flow.
54
+ */
55
+ export function buildAuthorizeUrl(redirectUri: string, pkce: PkceCodes, state: string): string {
56
+ return buildAuthUrl({
57
+ authUrl: AUTHORIZE_URL,
58
+ clientId: CLIENT_ID,
59
+ redirectUri,
60
+ scopes: SCOPES.split(' '),
61
+ codeChallenge: pkce.challenge,
62
+ state,
63
+ extraAuthParams: {
64
+ id_token_add_organizations: 'true',
65
+ codex_cli_simplified_flow: 'true',
66
+ originator: ORIGINATOR,
67
+ },
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Hard cap on the base64url payload segment we'll decode + JSON.parse. A
73
+ * legitimate OAuth id_token/access_token JWT payload is a few hundred bytes to
74
+ * a couple KiB; 64 KiB is far beyond any real claim set. The cap is defense in
75
+ * depth: although the token reaches us over TLS from the issuer, a hostile or
76
+ * MITM'd token endpoint — or a tampered persisted vault entry replayed back in
77
+ * — could otherwise hand us a multi-megabyte/gigabyte segment that we'd
78
+ * base64-decode and `JSON.parse` in full. Over the cap we degrade to
79
+ * `undefined` (callers already treat a missing account-id claim as fine).
80
+ */
81
+ const MAX_JWT_SEGMENT_CHARS = 64 * 1024;
82
+
83
+ /**
84
+ * JWT-claim extraction — header.payload.signature with base64url-encoded
85
+ * segments. We never verify the signature: the access_token is only ever
86
+ * sent back to the issuer (or its API gateway), so trust is rooted in the
87
+ * fact that we received the token over TLS from the token endpoint. The
88
+ * only thing we use these claims for is plucking the chatgpt_account_id
89
+ * for the per-request header.
90
+ */
91
+ export function parseJwtClaims(jwt: string): Record<string, unknown> | undefined {
92
+ if (typeof jwt !== 'string') return undefined;
93
+ const parts = jwt.split('.');
94
+ if (parts.length !== 3) return undefined;
95
+ const payload = parts[1]!;
96
+ // Bound the decode: never allocate/parse an oversized payload segment.
97
+ if (payload.length > MAX_JWT_SEGMENT_CHARS) return undefined;
98
+ try {
99
+ const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as unknown;
100
+ // Guard against a payload that parses to a non-object (e.g. `"5"`, `[]`,
101
+ // `null`): downstream callers index it as a claim bag, so reject anything
102
+ // that isn't a plain object rather than returning a surprising value.
103
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
104
+ return parsed as Record<string, unknown>;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ interface AccountIdSource {
111
+ readonly access_token?: string;
112
+ readonly id_token?: string;
113
+ }
114
+
115
+ /**
116
+ * Account-id priority order matches opencode's `extractAccountIdFromClaims`:
117
+ * the explicit top-level claim → the namespaced auth-bag claim → first
118
+ * organization id. Returning undefined is fine — the API just won't
119
+ * receive the optional ChatGPT-Account-Id header.
120
+ */
121
+ export function extractAccountId(tokens: AccountIdSource): string | undefined {
122
+ for (const candidate of [tokens.id_token, tokens.access_token]) {
123
+ if (!candidate) continue;
124
+ const claims = parseJwtClaims(candidate);
125
+ if (!claims) continue;
126
+ const direct = claims['chatgpt_account_id'];
127
+ if (typeof direct === 'string' && direct) return direct;
128
+ const authBag = claims['https://api.openai.com/auth'];
129
+ if (authBag && typeof authBag === 'object') {
130
+ const fromBag = (authBag as Record<string, unknown>)['chatgpt_account_id'];
131
+ if (typeof fromBag === 'string' && fromBag) return fromBag;
132
+ }
133
+ const orgs = claims['organizations'];
134
+ if (Array.isArray(orgs) && orgs.length > 0) {
135
+ const first = orgs[0] as { id?: unknown };
136
+ if (first && typeof first.id === 'string' && first.id) return first.id;
137
+ }
138
+ }
139
+ return undefined;
140
+ }
141
+
142
+ function toCodexTokens(set: TokenSet): CodexTokens {
143
+ if (!set.refreshToken) {
144
+ throw new Error('OAuth token response missing refresh_token');
145
+ }
146
+ const accountId = extractAccountId({
147
+ ...(set.idToken ? { id_token: set.idToken } : {}),
148
+ access_token: set.accessToken,
149
+ });
150
+ return {
151
+ access: set.accessToken,
152
+ refresh: set.refreshToken,
153
+ expires: set.expiresAt ?? Date.now() + 3600_000,
154
+ ...(accountId ? { accountId } : {}),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Wrapper around plugin-oauth's `exchangeCodeForToken` that returns the
160
+ * `CodexTokens` shape (with `accountId` plucked from the id_token JWT).
161
+ * Tests + downstream callers use it directly; the live login path goes
162
+ * through `runOauthLogin(codexOauthProfile, ...)`.
163
+ */
164
+ export async function exchangeCodeForTokens(
165
+ code: string,
166
+ redirectUri: string,
167
+ pkce: PkceCodes,
168
+ fetchImpl: typeof fetch = fetch,
169
+ ): Promise<CodexTokens> {
170
+ const set = await oauthExchangeCodeForToken(
171
+ {
172
+ tokenUrl: TOKEN_URL,
173
+ code,
174
+ redirectUri,
175
+ clientId: CLIENT_ID,
176
+ codeVerifier: pkce.verifier,
177
+ },
178
+ fetchImpl,
179
+ );
180
+ return toCodexTokens(set);
181
+ }
182
+
183
+ /**
184
+ * Refresh both the access AND refresh tokens. The OAuth server issues a
185
+ * fresh refresh_token on every refresh and INVALIDATES the previous one —
186
+ * callers must persist the returned tokens BEFORE issuing any API call
187
+ * that might fail mid-flight, otherwise a crash will lock the user out.
188
+ */
189
+ export async function refreshTokens(
190
+ refreshToken: string,
191
+ fetchImpl: typeof fetch = fetch,
192
+ ): Promise<CodexTokens> {
193
+ const set = await refreshAccessToken(
194
+ { tokenUrl: TOKEN_URL, clientId: CLIENT_ID, refreshToken },
195
+ fetchImpl,
196
+ );
197
+ return toCodexTokens(
198
+ set.refreshToken ? set : { ...set, refreshToken },
199
+ );
200
+ }
package/src/profile.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `OAuthProviderProfile` for the OpenAI Codex (ChatGPT Pro/Plus) provider.
3
+ * Drives both `runOauthLogin` (interactive) and `ensureFreshTokens`
4
+ * (pre-request refresh) from `@moxxy/plugin-oauth`. Codex-specific
5
+ * concerns:
6
+ * - the non-standard OpenAI device-flow dialect (via `openaiDeviceFlow`)
7
+ * - `chatgpt_account_id` extraction from id_token / access_token JWTs
8
+ * - the bundled-CLI auth flags (`id_token_add_organizations`, etc.)
9
+ */
10
+
11
+ import { openaiDeviceFlow, type OAuthProviderProfile, type TokenSet } from '@moxxy/plugin-oauth';
12
+ import {
13
+ AUTHORIZE_URL,
14
+ CLIENT_ID,
15
+ DEFAULT_CALLBACK_PORT,
16
+ DEFAULT_REDIRECT_PATH,
17
+ ISSUER,
18
+ ORIGINATOR,
19
+ SCOPES,
20
+ TOKEN_URL,
21
+ extractAccountId,
22
+ } from './oauth.js';
23
+
24
+ export const CODEX_PROVIDER_ID = 'openai-codex';
25
+
26
+ export const codexOauthProfile: OAuthProviderProfile = {
27
+ id: CODEX_PROVIDER_ID,
28
+ displayName: 'ChatGPT Pro/Plus',
29
+ authUrl: AUTHORIZE_URL,
30
+ tokenUrl: TOKEN_URL,
31
+ clientId: CLIENT_ID,
32
+ scopes: SCOPES.split(' '),
33
+ // These flags are what codex-rs / opencode pass; without them the
34
+ // returned id_token won't carry the chatgpt_account_id / organizations
35
+ // claims we need to populate the ChatGPT-Account-Id header.
36
+ extraAuthParams: {
37
+ id_token_add_organizations: 'true',
38
+ codex_cli_simplified_flow: 'true',
39
+ originator: ORIGINATOR,
40
+ },
41
+ redirect: { port: DEFAULT_CALLBACK_PORT, path: DEFAULT_REDIRECT_PATH },
42
+ deviceFlow: openaiDeviceFlow({
43
+ issuer: ISSUER,
44
+ tokenUrl: TOKEN_URL,
45
+ verificationUri: `${ISSUER}/codex/device`,
46
+ }),
47
+ extractAccountId: (tokens: TokenSet) =>
48
+ extractAccountId({
49
+ ...(tokens.idToken ? { id_token: tokens.idToken } : {}),
50
+ access_token: tokens.accessToken,
51
+ }),
52
+ };