@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.
- package/LICENSE +21 -0
- package/dist/codex/headers.d.ts +5 -0
- package/dist/codex/headers.d.ts.map +1 -0
- package/dist/codex/headers.js +39 -0
- package/dist/codex/headers.js.map +1 -0
- package/dist/codex/sse-event-handler.d.ts +16 -0
- package/dist/codex/sse-event-handler.d.ts.map +1 -0
- package/dist/codex/sse-event-handler.js +155 -0
- package/dist/codex/sse-event-handler.js.map +1 -0
- package/dist/codex/stream-consumer.d.ts +4 -0
- package/dist/codex/stream-consumer.d.ts.map +1 -0
- package/dist/codex/stream-consumer.js +176 -0
- package/dist/codex/stream-consumer.js.map +1 -0
- package/dist/codex/stream-types.d.ts +61 -0
- package/dist/codex/stream-types.d.ts.map +1 -0
- package/dist/codex/stream-types.js +19 -0
- package/dist/codex/stream-types.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/login.d.ts +33 -0
- package/dist/login.d.ts.map +1 -0
- package/dist/login.js +94 -0
- package/dist/login.js.map +1 -0
- package/dist/models.d.ts +10 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +19 -0
- package/dist/models.js.map +1 -0
- package/dist/oauth.d.ts +74 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +170 -0
- package/dist/oauth.js.map +1 -0
- package/dist/profile.d.ts +13 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +39 -0
- package/dist/profile.js.map +1 -0
- package/dist/provider.d.ts +79 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +306 -0
- package/dist/provider.js.map +1 -0
- package/dist/translate.d.ts +77 -0
- package/dist/translate.d.ts.map +1 -0
- package/dist/translate.js +172 -0
- package/dist/translate.js.map +1 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +66 -0
- package/src/codex/headers.ts +41 -0
- package/src/codex/sse-event-handler.test.ts +85 -0
- package/src/codex/sse-event-handler.ts +173 -0
- package/src/codex/stream-consumer.test.ts +280 -0
- package/src/codex/stream-consumer.ts +181 -0
- package/src/codex/stream-types.ts +61 -0
- package/src/index.ts +65 -0
- package/src/login.ts +121 -0
- package/src/models.ts +21 -0
- package/src/oauth.test.ts +223 -0
- package/src/oauth.ts +200 -0
- package/src/profile.ts +52 -0
- package/src/provider.test.ts +507 -0
- package/src/provider.ts +360 -0
- package/src/translate.test.ts +95 -0
- package/src/translate.ts +224 -0
- 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
|
+
};
|