@modelprofile.com/authswitch-signin 10.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.
- package/dist_ts_signin/index.d.ts +5 -0
- package/dist_ts_signin/index.js +6 -0
- package/dist_ts_signin/plugins.d.ts +2 -0
- package/dist_ts_signin/plugins.js +4 -0
- package/dist_ts_signin/signin.credential.d.ts +29 -0
- package/dist_ts_signin/signin.credential.js +78 -0
- package/dist_ts_signin/signin.device.d.ts +56 -0
- package/dist_ts_signin/signin.device.js +85 -0
- package/dist_ts_signin/signin.grants.d.ts +195 -0
- package/dist_ts_signin/signin.grants.js +408 -0
- package/dist_ts_signin/signin.interfaces.d.ts +62 -0
- package/dist_ts_signin/signin.interfaces.js +3 -0
- package/dist_ts_signin/signin.protocol.d.ts +93 -0
- package/dist_ts_signin/signin.protocol.js +397 -0
- package/license.md +21 -0
- package/openai-codex-license.txt +201 -0
- package/package.json +37 -0
- package/readme.md +163 -0
- package/third-party-notices.md +34 -0
- package/ts_signin/index.ts +5 -0
- package/ts_signin/plugins.ts +4 -0
- package/ts_signin/readme.md +163 -0
- package/ts_signin/signin.credential.ts +91 -0
- package/ts_signin/signin.device.ts +137 -0
- package/ts_signin/signin.grants.ts +518 -0
- package/ts_signin/signin.interfaces.ts +75 -0
- package/ts_signin/signin.protocol.ts +454 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// OpenAI protocol portions are adapted in TypeScript from OpenAI Codex (Apache-2.0) and modified by Task Venture Capital GmbH; see third-party-notices.md.
|
|
2
|
+
|
|
3
|
+
import type { TOpenAiRequestOutcome } from './signin.interfaces.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ChatGPT sign-in protocol Codex uses (`codex-rs/login`: `device_code_auth.rs`, `auth/manager.rs`,
|
|
7
|
+
* `auth/revoke.rs`, `oauth/client.rs`), for the one party that signs an account in and refreshes it: the device
|
|
8
|
+
* code, its poll, the authorization-code exchange, the refresh-token grant, the revocation and the claims of the
|
|
9
|
+
* tokens. Every failure is a fixed message: no response body, token or header crosses an error.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com';
|
|
13
|
+
export const OPENAI_AUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
14
|
+
|
|
15
|
+
/** How long a device code lives: Codex polls it for fifteen minutes and tells the person it expires then. */
|
|
16
|
+
export const OPENAI_DEVICE_CODE_LIFETIME_MS = 15 * 60 * 1000;
|
|
17
|
+
const DEVICE_CODE_TIMEOUT_MS = OPENAI_DEVICE_CODE_LIFETIME_MS;
|
|
18
|
+
/** Codex's `REVOKE_HTTP_TIMEOUT`. */
|
|
19
|
+
const REVOKE_TIMEOUT_MS = 10_000;
|
|
20
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_TOKEN_BYTES = 16 * 1024;
|
|
22
|
+
const MAX_AUTHORIZATION_CODE_BYTES = 8 * 1024;
|
|
23
|
+
const MAX_IDENTIFIER_BYTES = 1024;
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
25
|
+
const textEncoder = new TextEncoder();
|
|
26
|
+
const textDecoder = new TextDecoder();
|
|
27
|
+
const utf8ByteLength = (valueArg: string): number => textEncoder.encode(valueArg).byteLength;
|
|
28
|
+
|
|
29
|
+
export class OpenAiAuthError extends Error {
|
|
30
|
+
public readonly status?: number;
|
|
31
|
+
public readonly requestOutcome?: TOpenAiRequestOutcome;
|
|
32
|
+
public readonly retryAfterMs?: number;
|
|
33
|
+
|
|
34
|
+
constructor(message: string, options: { status?: number; requestOutcome?: TOpenAiRequestOutcome;
|
|
35
|
+
retryAfterMs?: number } = {}) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'OpenAiAuthError';
|
|
38
|
+
this.status = options.status;
|
|
39
|
+
this.requestOutcome = options.requestOutcome;
|
|
40
|
+
this.retryAfterMs = options.retryAfterMs;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface IOpenAiAuthOptions {
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
/** Aborts network requests and device-code polling. */
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface IOpenAiDeviceCodePollOptions extends IOpenAiAuthOptions {
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
sleep?: (ms: number) => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What the claims of a ChatGPT token say about its account; never the token itself. */
|
|
56
|
+
export interface IOpenAiTokenClaims {
|
|
57
|
+
email?: string;
|
|
58
|
+
chatgptPlanType?: string;
|
|
59
|
+
chatgptUserId?: string;
|
|
60
|
+
chatgptAccountId?: string;
|
|
61
|
+
chatgptAccountIsFedramp: boolean;
|
|
62
|
+
expiresAt?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface IOpenAiDeviceCode {
|
|
66
|
+
verificationUrl: string;
|
|
67
|
+
userCode: string;
|
|
68
|
+
deviceAuthId: string;
|
|
69
|
+
intervalSeconds: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface IOpenAiDeviceAuthorization {
|
|
73
|
+
authorizationCode: string;
|
|
74
|
+
codeChallenge: string;
|
|
75
|
+
codeVerifier: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The tokens one exchange or refresh produced, with the claims of the ID token (or access token). */
|
|
79
|
+
export interface IOpenAiTokenSet {
|
|
80
|
+
accessToken: string;
|
|
81
|
+
refreshToken: string;
|
|
82
|
+
idToken?: string;
|
|
83
|
+
claims: IOpenAiTokenClaims;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ITokenResponse {
|
|
87
|
+
id_token?: unknown;
|
|
88
|
+
access_token?: unknown;
|
|
89
|
+
refresh_token?: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const getFetch = (options: IOpenAiAuthOptions): typeof fetch => {
|
|
93
|
+
const fetchFunction = options.fetch ?? globalThis.fetch;
|
|
94
|
+
if (!fetchFunction) throw new OpenAiAuthError('fetch is not available for OpenAI authentication.');
|
|
95
|
+
return fetchFunction;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const throwIfAborted = (options: IOpenAiAuthOptions): void => {
|
|
99
|
+
if (options.signal?.aborted) throw new OpenAiAuthError('OpenAI authentication was aborted.');
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const asString = (value: unknown, name: string, maximumBytes = MAX_IDENTIFIER_BYTES): string => {
|
|
103
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
104
|
+
throw new OpenAiAuthError(`OpenAI auth response is missing ${name}.`);
|
|
105
|
+
}
|
|
106
|
+
if (utf8ByteLength(value) > maximumBytes) throw new OpenAiAuthError(`OpenAI auth response contains an invalid ${name}.`);
|
|
107
|
+
return value;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const asOptionalString = (value: unknown, maximumBytes = MAX_IDENTIFIER_BYTES): string | undefined => {
|
|
111
|
+
if (typeof value !== 'string' || value.length === 0) return undefined;
|
|
112
|
+
if (utf8ByteLength(value) > maximumBytes) throw new OpenAiAuthError('OpenAI auth response contains an oversized string.');
|
|
113
|
+
return value;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Codex reads the interval as a string (`deserialize_interval`); both a number and a numeric string are taken. */
|
|
117
|
+
const asIntervalSeconds = (value: unknown): number => {
|
|
118
|
+
const interval = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
|
|
119
|
+
if (!Number.isFinite(interval) || interval <= 0 || interval > 300) {
|
|
120
|
+
throw new OpenAiAuthError('OpenAI device-code response has an invalid interval.');
|
|
121
|
+
}
|
|
122
|
+
return interval;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const readResponseText = async (response: Response, options: IOpenAiAuthOptions): Promise<string> => {
|
|
126
|
+
const contentLength = response.headers.get('content-length');
|
|
127
|
+
if (contentLength !== null && (!/^\d+$/.test(contentLength) || Number(contentLength) > MAX_RESPONSE_BYTES)) {
|
|
128
|
+
await response.body?.cancel().catch(() => undefined);
|
|
129
|
+
throw new OpenAiAuthError('OpenAI auth response exceeded its size limit.', { status: response.status });
|
|
130
|
+
}
|
|
131
|
+
if (!response.body) return '';
|
|
132
|
+
const reader = response.body.getReader();
|
|
133
|
+
const chunks: Uint8Array[] = [];
|
|
134
|
+
let byteLength = 0;
|
|
135
|
+
let completed = false;
|
|
136
|
+
const onAbort = (): void => { void reader.cancel().catch(() => undefined); };
|
|
137
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
138
|
+
if (options.signal?.aborted) onAbort();
|
|
139
|
+
try {
|
|
140
|
+
while (true) {
|
|
141
|
+
throwIfAborted(options);
|
|
142
|
+
const { done, value } = await reader.read();
|
|
143
|
+
throwIfAborted(options);
|
|
144
|
+
if (done) break;
|
|
145
|
+
byteLength += value.byteLength;
|
|
146
|
+
if (byteLength > MAX_RESPONSE_BYTES) {
|
|
147
|
+
await reader.cancel().catch(() => undefined);
|
|
148
|
+
throw new OpenAiAuthError('OpenAI auth response exceeded its size limit.', { status: response.status });
|
|
149
|
+
}
|
|
150
|
+
chunks.push(value);
|
|
151
|
+
}
|
|
152
|
+
completed = true;
|
|
153
|
+
} finally {
|
|
154
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
155
|
+
if (!completed) void reader.cancel().catch(() => undefined);
|
|
156
|
+
reader.releaseLock();
|
|
157
|
+
}
|
|
158
|
+
const bytes = new Uint8Array(byteLength);
|
|
159
|
+
let offset = 0;
|
|
160
|
+
for (const chunk of chunks) {
|
|
161
|
+
bytes.set(chunk, offset);
|
|
162
|
+
offset += chunk.byteLength;
|
|
163
|
+
}
|
|
164
|
+
return textDecoder.decode(bytes);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const readJson = async (response: Response, context: string, options: IOpenAiAuthOptions): Promise<unknown> => {
|
|
168
|
+
const body = await readResponseText(response, options);
|
|
169
|
+
if (!response.ok) throw new OpenAiAuthError(`${context} failed with status ${response.status}.`, { status: response.status });
|
|
170
|
+
try { return body ? JSON.parse(body) : {}; }
|
|
171
|
+
catch { throw new OpenAiAuthError(`${context} returned invalid JSON.`, { status: response.status }); }
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const parseRetryAfterMs = (header: string | null): number | undefined => {
|
|
175
|
+
if (!header || header.length > 128) return undefined;
|
|
176
|
+
const maximumDelayMs = 24 * 60 * 60 * 1000;
|
|
177
|
+
if (/^\d{1,10}$/.test(header)) return Math.min(Number(header) * 1000, maximumDelayMs);
|
|
178
|
+
const retryAt = Date.parse(header);
|
|
179
|
+
if (!Number.isFinite(retryAt)) return undefined;
|
|
180
|
+
return Math.min(Math.max(0, retryAt - Date.now()), maximumDelayMs);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One bounded request. Its failure says whether the request was handed to fetch at all: a refresh whose outcome
|
|
185
|
+
* is `outcomeUnknown` may have rotated the grant and is never sent again.
|
|
186
|
+
*/
|
|
187
|
+
const runFetch = async <TResult>(url: string, init: RequestInit, options: IOpenAiAuthOptions,
|
|
188
|
+
handle: (response: Response, requestOptions: IOpenAiAuthOptions) => Promise<TResult>,
|
|
189
|
+
timeoutMs = REQUEST_TIMEOUT_MS): Promise<TResult> => {
|
|
190
|
+
if (options.signal?.aborted) {
|
|
191
|
+
throw new OpenAiAuthError('OpenAI authentication was aborted.', { requestOutcome: 'notSent' });
|
|
192
|
+
}
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
let response: Response | undefined;
|
|
195
|
+
let requestStarted = false;
|
|
196
|
+
const onExternalAbort = (): void => controller.abort();
|
|
197
|
+
const onRequestAbort = (): void => { void response?.body?.cancel().catch(() => undefined); };
|
|
198
|
+
options.signal?.addEventListener('abort', onExternalAbort, { once: true });
|
|
199
|
+
let rejectOnAbort!: () => void;
|
|
200
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
201
|
+
rejectOnAbort = () => reject(new Error('OpenAI authentication request aborted.'));
|
|
202
|
+
controller.signal.addEventListener('abort', rejectOnAbort, { once: true });
|
|
203
|
+
});
|
|
204
|
+
controller.signal.addEventListener('abort', onRequestAbort, { once: true });
|
|
205
|
+
if (options.signal?.aborted) controller.abort();
|
|
206
|
+
const timer = setTimeout(() => controller.abort(), Math.min(timeoutMs, REQUEST_TIMEOUT_MS));
|
|
207
|
+
const requestOptions = { ...options, signal: controller.signal };
|
|
208
|
+
try {
|
|
209
|
+
return await Promise.race([
|
|
210
|
+
(async () => {
|
|
211
|
+
const fetchImplementation = getFetch(options);
|
|
212
|
+
requestStarted = true;
|
|
213
|
+
response = await fetchImplementation(url, { ...init, redirect: 'error', signal: controller.signal });
|
|
214
|
+
if (controller.signal.aborted) await response.body?.cancel().catch(() => undefined);
|
|
215
|
+
throwIfAborted(requestOptions);
|
|
216
|
+
return handle(response, requestOptions);
|
|
217
|
+
})(),
|
|
218
|
+
aborted,
|
|
219
|
+
]);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const requestOutcome: TOpenAiRequestOutcome = requestStarted ? 'outcomeUnknown' : 'notSent';
|
|
222
|
+
const retryAfterMs = response ? parseRetryAfterMs(response.headers.get('retry-after')) : undefined;
|
|
223
|
+
const status = error instanceof OpenAiAuthError && error.status !== undefined ? error.status : response?.status;
|
|
224
|
+
throw new OpenAiAuthError(error instanceof OpenAiAuthError ? error.message
|
|
225
|
+
: options.signal?.aborted ? 'OpenAI authentication was aborted.' : 'OpenAI authentication request failed.', {
|
|
226
|
+
requestOutcome,
|
|
227
|
+
...(status !== undefined ? { status } : {}),
|
|
228
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
229
|
+
});
|
|
230
|
+
} finally {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
options.signal?.removeEventListener('abort', onExternalAbort);
|
|
233
|
+
controller.signal.removeEventListener('abort', rejectOnAbort);
|
|
234
|
+
controller.signal.removeEventListener('abort', onRequestAbort);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const postJson = (url: string, body: unknown, options: IOpenAiAuthOptions): Promise<unknown> =>
|
|
239
|
+
runFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) },
|
|
240
|
+
options, (response, requestOptions) => readJson(response, `POST ${url}`, requestOptions));
|
|
241
|
+
|
|
242
|
+
const postForm = (url: string, body: URLSearchParams, options: IOpenAiAuthOptions): Promise<unknown> =>
|
|
243
|
+
runFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() },
|
|
244
|
+
options, (response, requestOptions) => readJson(response, `POST ${url}`, requestOptions));
|
|
245
|
+
|
|
246
|
+
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
|
247
|
+
|
|
248
|
+
const sleepWithAbort = async (milliseconds: number, options: IOpenAiDeviceCodePollOptions): Promise<void> => {
|
|
249
|
+
throwIfAborted(options);
|
|
250
|
+
const signal = options.signal;
|
|
251
|
+
if (!signal) {
|
|
252
|
+
await (options.sleep ?? sleep)(milliseconds);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
let abortListener: (() => void) | undefined;
|
|
256
|
+
let timer: NodeJS.Timeout | undefined;
|
|
257
|
+
try {
|
|
258
|
+
await Promise.race([
|
|
259
|
+
options.sleep ? options.sleep(milliseconds) : new Promise<void>(resolve => { timer = setTimeout(resolve, milliseconds); }),
|
|
260
|
+
new Promise<never>((_resolve, reject) => {
|
|
261
|
+
abortListener = () => reject(new OpenAiAuthError('OpenAI authentication was aborted.'));
|
|
262
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
263
|
+
if (signal.aborted) abortListener();
|
|
264
|
+
}),
|
|
265
|
+
]);
|
|
266
|
+
} finally {
|
|
267
|
+
if (timer) clearTimeout(timer);
|
|
268
|
+
if (abortListener) signal.removeEventListener('abort', abortListener);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const parseJwtPayload = (jwt: string): Record<string, unknown> => {
|
|
273
|
+
const parts = jwt.split('.');
|
|
274
|
+
if (parts.length !== 3 || !parts[1]) throw new OpenAiAuthError('OpenAI auth returned an invalid token.');
|
|
275
|
+
try {
|
|
276
|
+
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
277
|
+
const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, '='));
|
|
278
|
+
const value: unknown = JSON.parse(textDecoder.decode(Uint8Array.from(binary, character => character.charCodeAt(0))));
|
|
279
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error();
|
|
280
|
+
return value as Record<string, unknown>;
|
|
281
|
+
} catch { throw new OpenAiAuthError('OpenAI token claims could not be parsed.'); }
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const claimRecord = (value: unknown): Record<string, unknown> | undefined =>
|
|
285
|
+
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
286
|
+
|
|
287
|
+
/** Reads the account claims of a ChatGPT ID or access token. Throws `OpenAiAuthError` for anything but a JWT. */
|
|
288
|
+
export const parseOpenAiTokenClaims = (token: string): IOpenAiTokenClaims => {
|
|
289
|
+
const claims = parseJwtPayload(token);
|
|
290
|
+
const profile = claimRecord(claims['https://api.openai.com/profile']);
|
|
291
|
+
const auth = claimRecord(claims['https://api.openai.com/auth']);
|
|
292
|
+
const expiresAtSeconds = typeof claims.exp === 'number' ? claims.exp : undefined;
|
|
293
|
+
return {
|
|
294
|
+
email: asOptionalString(claims.email) ?? asOptionalString(profile?.email),
|
|
295
|
+
chatgptPlanType: asOptionalString(auth?.chatgpt_plan_type),
|
|
296
|
+
chatgptUserId: asOptionalString(auth?.chatgpt_user_id) ?? asOptionalString(auth?.user_id),
|
|
297
|
+
chatgptAccountId: asOptionalString(auth?.chatgpt_account_id),
|
|
298
|
+
chatgptAccountIsFedramp: auth?.chatgpt_account_is_fedramp === true,
|
|
299
|
+
expiresAt: expiresAtSeconds !== undefined ? new Date(expiresAtSeconds * 1000).toISOString() : undefined,
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const createTokenSet = (response: ITokenResponse, existing?: IOpenAiTokenSet): IOpenAiTokenSet => {
|
|
304
|
+
const accessToken = asOptionalString(response.access_token, MAX_TOKEN_BYTES) ?? existing?.accessToken;
|
|
305
|
+
const refreshToken = asOptionalString(response.refresh_token, MAX_TOKEN_BYTES) ?? existing?.refreshToken;
|
|
306
|
+
const idToken = asOptionalString(response.id_token, MAX_TOKEN_BYTES) ?? existing?.idToken;
|
|
307
|
+
if (!accessToken) throw new OpenAiAuthError('OpenAI auth response is missing access_token.');
|
|
308
|
+
if (!refreshToken) throw new OpenAiAuthError('OpenAI auth response is missing refresh_token.');
|
|
309
|
+
let claims: IOpenAiTokenClaims;
|
|
310
|
+
try { claims = parseOpenAiTokenClaims(idToken ?? accessToken); }
|
|
311
|
+
catch { throw new OpenAiAuthError('OpenAI auth returned an invalid token.'); }
|
|
312
|
+
return { accessToken, refreshToken, ...(idToken ? { idToken } : {}), claims };
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** Starts a device sign-in (`POST {issuer}/api/accounts/deviceauth/usercode`). */
|
|
316
|
+
export const requestOpenAiDeviceCode = async (options: IOpenAiAuthOptions = {}): Promise<IOpenAiDeviceCode> => {
|
|
317
|
+
const response = await postJson(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/usercode`,
|
|
318
|
+
{ client_id: OPENAI_AUTH_CLIENT_ID }, options) as Record<string, unknown>;
|
|
319
|
+
return {
|
|
320
|
+
verificationUrl: `${OPENAI_AUTH_ISSUER}/codex/device`,
|
|
321
|
+
userCode: asString(response.user_code ?? response.usercode, 'user_code'),
|
|
322
|
+
deviceAuthId: asString(response.device_auth_id, 'device_auth_id'),
|
|
323
|
+
intervalSeconds: asIntervalSeconds(response.interval),
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** What one poll of a device sign-in found: the person has not approved it yet, or has, with its authorization. */
|
|
328
|
+
export type TOpenAiDeviceCodePoll =
|
|
329
|
+
| { status: 'pending' }
|
|
330
|
+
| { status: 'approved'; authorization: IOpenAiDeviceAuthorization };
|
|
331
|
+
|
|
332
|
+
const assertDeviceCode = (deviceCode: IOpenAiDeviceCode): void => {
|
|
333
|
+
if (deviceCode === null || typeof deviceCode !== 'object'
|
|
334
|
+
|| typeof deviceCode.deviceAuthId !== 'string' || !deviceCode.deviceAuthId
|
|
335
|
+
|| typeof deviceCode.userCode !== 'string' || !deviceCode.userCode
|
|
336
|
+
|| utf8ByteLength(deviceCode.deviceAuthId) > MAX_IDENTIFIER_BYTES
|
|
337
|
+
|| utf8ByteLength(deviceCode.userCode) > MAX_IDENTIFIER_BYTES
|
|
338
|
+
|| !Number.isSafeInteger(deviceCode.intervalSeconds) || deviceCode.intervalSeconds < 1
|
|
339
|
+
|| deviceCode.intervalSeconds > 300) {
|
|
340
|
+
throw new OpenAiAuthError('OpenAI device-code input is invalid.', { requestOutcome: 'notSent' });
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Asks once whether the person approved the sign-in (`POST {issuer}/api/accounts/deviceauth/token`): 403 and 404
|
|
346
|
+
* mean pending, as in Codex. `timeoutMs` bounds the one request (at most thirty seconds). The authorization it
|
|
347
|
+
* answers is single-use, so one sign-in is polled by one caller at a time.
|
|
348
|
+
*/
|
|
349
|
+
export const pollOpenAiDeviceCodeOnce = async (deviceCode: IOpenAiDeviceCode,
|
|
350
|
+
options: IOpenAiAuthOptions & { timeoutMs?: number } = {}): Promise<TOpenAiDeviceCodePoll> => {
|
|
351
|
+
assertDeviceCode(deviceCode);
|
|
352
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
353
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
|
354
|
+
throw new OpenAiAuthError('OpenAI device-code timeout is invalid.', { requestOutcome: 'notSent' });
|
|
355
|
+
}
|
|
356
|
+
const pollUrl = `${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/token`;
|
|
357
|
+
const pollResult = await runFetch(pollUrl, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: { 'Content-Type': 'application/json' },
|
|
360
|
+
body: JSON.stringify({ device_auth_id: deviceCode.deviceAuthId, user_code: deviceCode.userCode }),
|
|
361
|
+
}, options, async (response, requestOptions) => {
|
|
362
|
+
if (response.ok) {
|
|
363
|
+
return { status: 'complete' as const,
|
|
364
|
+
body: await readJson(response, `POST ${pollUrl}`, requestOptions) as Record<string, unknown> };
|
|
365
|
+
}
|
|
366
|
+
await readResponseText(response, requestOptions);
|
|
367
|
+
if (response.status !== 403 && response.status !== 404) {
|
|
368
|
+
throw new OpenAiAuthError(`OpenAI device-code polling failed with status ${response.status}.`,
|
|
369
|
+
{ status: response.status });
|
|
370
|
+
}
|
|
371
|
+
return { status: 'pending' as const };
|
|
372
|
+
}, timeoutMs);
|
|
373
|
+
if (pollResult.status === 'pending') return { status: 'pending' };
|
|
374
|
+
return {
|
|
375
|
+
status: 'approved',
|
|
376
|
+
authorization: {
|
|
377
|
+
authorizationCode: asString(pollResult.body.authorization_code, 'authorization_code', MAX_AUTHORIZATION_CODE_BYTES),
|
|
378
|
+
codeChallenge: asString(pollResult.body.code_challenge, 'code_challenge'),
|
|
379
|
+
codeVerifier: asString(pollResult.body.code_verifier, 'code_verifier'),
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Polls until the person approved the sign-in, at the interval the service asked for, and gives up after at most
|
|
386
|
+
* fifteen minutes, as Codex does.
|
|
387
|
+
*/
|
|
388
|
+
export const pollOpenAiDeviceCode = async (deviceCode: IOpenAiDeviceCode,
|
|
389
|
+
options: IOpenAiDeviceCodePollOptions = {}): Promise<IOpenAiDeviceAuthorization> => {
|
|
390
|
+
const timeoutMs = options.timeoutMs ?? DEVICE_CODE_TIMEOUT_MS;
|
|
391
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > DEVICE_CODE_TIMEOUT_MS) {
|
|
392
|
+
throw new OpenAiAuthError('OpenAI device-code timeout is invalid.');
|
|
393
|
+
}
|
|
394
|
+
assertDeviceCode(deviceCode);
|
|
395
|
+
const startedAt = Date.now();
|
|
396
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
397
|
+
const remainingBeforeRequest = timeoutMs - (Date.now() - startedAt);
|
|
398
|
+
const poll = await pollOpenAiDeviceCodeOnce(deviceCode, { ...options, timeoutMs: Math.max(remainingBeforeRequest, 1) });
|
|
399
|
+
if (poll.status === 'approved') return poll.authorization;
|
|
400
|
+
const remaining = timeoutMs - (Date.now() - startedAt);
|
|
401
|
+
await sleepWithAbort(Math.min(deviceCode.intervalSeconds * 1000, Math.max(remaining, 0)), options);
|
|
402
|
+
}
|
|
403
|
+
throw new OpenAiAuthError('OpenAI device-code login timed out.');
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/** Exchanges an approved device sign-in for tokens (`POST {issuer}/oauth/token`, PKCE, form-encoded). */
|
|
407
|
+
export const exchangeOpenAiDeviceAuthorization = async (authorization: IOpenAiDeviceAuthorization,
|
|
408
|
+
options: IOpenAiAuthOptions = {}): Promise<IOpenAiTokenSet> => {
|
|
409
|
+
const response = await postForm(`${OPENAI_AUTH_ISSUER}/oauth/token`, new URLSearchParams({
|
|
410
|
+
grant_type: 'authorization_code',
|
|
411
|
+
code: authorization.authorizationCode,
|
|
412
|
+
redirect_uri: `${OPENAI_AUTH_ISSUER}/deviceauth/callback`,
|
|
413
|
+
client_id: OPENAI_AUTH_CLIENT_ID,
|
|
414
|
+
code_verifier: authorization.codeVerifier,
|
|
415
|
+
}), options) as ITokenResponse;
|
|
416
|
+
return createTokenSet(response);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Sends one refresh-token grant (`POST {issuer}/oauth/token`, JSON). A response without a new refresh token keeps
|
|
421
|
+
* the one that was sent, as Codex does.
|
|
422
|
+
*/
|
|
423
|
+
export const refreshOpenAiTokens = async (tokens: IOpenAiTokenSet,
|
|
424
|
+
options: IOpenAiAuthOptions = {}): Promise<IOpenAiTokenSet> => {
|
|
425
|
+
const response = await postJson(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
|
426
|
+
client_id: OPENAI_AUTH_CLIENT_ID,
|
|
427
|
+
grant_type: 'refresh_token',
|
|
428
|
+
refresh_token: tokens.refreshToken,
|
|
429
|
+
}, options) as ITokenResponse;
|
|
430
|
+
return createTokenSet({
|
|
431
|
+
id_token: response.id_token ?? tokens.idToken,
|
|
432
|
+
access_token: response.access_token ?? tokens.accessToken,
|
|
433
|
+
refresh_token: response.refresh_token ?? tokens.refreshToken,
|
|
434
|
+
}, tokens);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Revokes a refresh token (`POST {issuer}/oauth/revoke`, JSON), as Codex does when it logs out: the grant then
|
|
439
|
+
* refreshes nothing anywhere. It resolves only once the service accepted the revocation.
|
|
440
|
+
*/
|
|
441
|
+
export const revokeOpenAiRefreshToken = async (refreshToken: string, options: IOpenAiAuthOptions = {}): Promise<void> => {
|
|
442
|
+
if (typeof refreshToken !== 'string' || !refreshToken || utf8ByteLength(refreshToken) > MAX_TOKEN_BYTES) {
|
|
443
|
+
throw new OpenAiAuthError('OpenAI revocation input is invalid.', { requestOutcome: 'notSent' });
|
|
444
|
+
}
|
|
445
|
+
const url = `${OPENAI_AUTH_ISSUER}/oauth/revoke`;
|
|
446
|
+
await runFetch(url, {
|
|
447
|
+
method: 'POST',
|
|
448
|
+
headers: { 'Content-Type': 'application/json' },
|
|
449
|
+
body: JSON.stringify({ token: refreshToken, token_type_hint: 'refresh_token', client_id: OPENAI_AUTH_CLIENT_ID }),
|
|
450
|
+
}, options, async (response, requestOptions) => {
|
|
451
|
+
await readResponseText(response, requestOptions);
|
|
452
|
+
if (!response.ok) throw new OpenAiAuthError(`POST ${url} failed with status ${response.status}.`, { status: response.status });
|
|
453
|
+
}, REVOKE_TIMEOUT_MS);
|
|
454
|
+
};
|