@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,518 @@
|
|
|
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 * as plugins from './plugins.js';
|
|
4
|
+
import { parseOpenAiTokenClaims, revokeOpenAiRefreshToken } from './signin.protocol.js';
|
|
5
|
+
import { openAiTokensOfCredential, refreshOpenAiCredential } from './signin.credential.js';
|
|
6
|
+
import type { IOpenAiLoginResult, IOpenAiOAuthCredential } from './signin.interfaces.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The state of a stored grant:
|
|
10
|
+
* - `active`: its tokens are usable and refreshed when due. `retryAt` is set after a refresh that was never sent.
|
|
11
|
+
* - `refresh_uncertain`: a refresh may have been sent. The refresh token may already be spent, so it is never
|
|
12
|
+
* sent again; the attempt either commits the rotated tokens or, once `attempt.expiresAt` passed, the grant
|
|
13
|
+
* needs a new sign-in.
|
|
14
|
+
* - `reauth_required`: only a new sign-in brings the grant back.
|
|
15
|
+
*/
|
|
16
|
+
export type TOpenAiGrantState = 'active' | 'refresh_uncertain' | 'reauth_required';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Why a grant needs a new sign-in:
|
|
20
|
+
* - `refresh_failed`: a refresh was sent and did not produce tokens (refused, or its outcome is unknown);
|
|
21
|
+
* - `refresh_interrupted`: a refresh may have been sent and its attempt ended without an answer;
|
|
22
|
+
* - `identity_changed`: a refresh answered for another workspace or user.
|
|
23
|
+
*/
|
|
24
|
+
export type TOpenAiGrantReauthReason = 'refresh_failed' | 'refresh_interrupted' | 'identity_changed';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One grant as the store keeps it. The store seals `credential` at rest and hands the record back unchanged;
|
|
28
|
+
* everything else is credential-free.
|
|
29
|
+
*/
|
|
30
|
+
export interface IOpenAiGrantRecord {
|
|
31
|
+
/** Rises by one with every change of the record; `compareAndSet` and `remove` compare it. */
|
|
32
|
+
revision: number;
|
|
33
|
+
/** Rises by one with every new access token; a provider's 401 names the generation it rejected. */
|
|
34
|
+
generation: number;
|
|
35
|
+
state: TOpenAiGrantState;
|
|
36
|
+
credential: IOpenAiOAuthCredential;
|
|
37
|
+
/** The ChatGPT workspace the grant acts in; a refresh that states another is refused. */
|
|
38
|
+
accountId: string;
|
|
39
|
+
/** The ChatGPT user the grant was signed in as; a refresh that states another is refused. */
|
|
40
|
+
userId: string;
|
|
41
|
+
isFedrampAccount: boolean;
|
|
42
|
+
/** When the current access token expires, ISO 8601. */
|
|
43
|
+
accessExpiresAt: string;
|
|
44
|
+
/** When the current tokens were issued, by the sign-in or the last refresh, ISO 8601. */
|
|
45
|
+
refreshedAt: string;
|
|
46
|
+
/** After a refresh that was never sent: the earliest time to try again. */
|
|
47
|
+
retryAt: string | null;
|
|
48
|
+
retryCount: number;
|
|
49
|
+
/** While `refresh_uncertain`: the attempt that may have sent the refresh token, and when it is given up. */
|
|
50
|
+
attempt: { id: string; expiresAt: string } | null;
|
|
51
|
+
/** While `reauth_required`: why. */
|
|
52
|
+
reauthReason: TOpenAiGrantReauthReason | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where grants live, supplied by the caller. Every write names the revision it read, so two processes that act on
|
|
57
|
+
* one grant cannot both win: `compareAndSet` and `remove` must compare and write in one atomic step, as a
|
|
58
|
+
* database's conditional update does. A store keeps `credential` sealed at rest and never logs it.
|
|
59
|
+
*/
|
|
60
|
+
export interface IOpenAiGrantStore {
|
|
61
|
+
load(grantId: string): Promise<IOpenAiGrantRecord | null>;
|
|
62
|
+
/** Stores a new grant; `conflict` when one exists under the id. */
|
|
63
|
+
create(grantId: string, record: IOpenAiGrantRecord): Promise<'stored' | 'conflict'>;
|
|
64
|
+
/** Replaces the grant only while it is still at `expectedRevision`. */
|
|
65
|
+
compareAndSet(grantId: string, expectedRevision: number, next: IOpenAiGrantRecord): Promise<'stored' | 'conflict'>;
|
|
66
|
+
/** Deletes the grant only while it is still at `expectedRevision`; `conflict` when it moved or is gone. */
|
|
67
|
+
remove(grantId: string, expectedRevision: number): Promise<'removed' | 'conflict'>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Access for one request: what FlexHarness's ChatGPT connection takes, with the generation it belongs to. */
|
|
71
|
+
export interface IOpenAiGrantAccess {
|
|
72
|
+
accessToken: string;
|
|
73
|
+
accountId: string;
|
|
74
|
+
isFedrampAccount: boolean;
|
|
75
|
+
expiresAt: string;
|
|
76
|
+
generation: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface IOpenAiGrantResolveOptions {
|
|
80
|
+
/** Stops waiting. A refresh already sent keeps running to its end, so its outcome is recorded. */
|
|
81
|
+
signal?: AbortSignal;
|
|
82
|
+
/** The generation the provider just rejected with 401; the grant is refreshed if it is still the current one. */
|
|
83
|
+
rejectedGeneration?: number;
|
|
84
|
+
/** How long the access must stay valid, in milliseconds (at most one hour). Defaults to one minute. */
|
|
85
|
+
minValidityMs?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What a caller may show about a grant: no token. */
|
|
89
|
+
export interface IOpenAiGrantStatus {
|
|
90
|
+
revision: number;
|
|
91
|
+
generation: number;
|
|
92
|
+
state: TOpenAiGrantState;
|
|
93
|
+
reauthReason: TOpenAiGrantReauthReason | null;
|
|
94
|
+
accountId: string;
|
|
95
|
+
isFedrampAccount: boolean;
|
|
96
|
+
plan: string | null;
|
|
97
|
+
accessExpiresAt: string;
|
|
98
|
+
refreshedAt: string;
|
|
99
|
+
retryAt: string | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the service said about revoking a removed grant:
|
|
104
|
+
* - `confirmed`: it revoked the grant's current refresh token;
|
|
105
|
+
* - `unconfirmed`: it revoked the stored token, but a refresh may have replaced it at the service before;
|
|
106
|
+
* - `failed`: it did not confirm the revocation. The sign-in may still be valid at the service.
|
|
107
|
+
*/
|
|
108
|
+
export type TOpenAiGrantRevocation = 'confirmed' | 'unconfirmed' | 'failed';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Why a grant cannot answer:
|
|
112
|
+
* - `not_found`: no grant under the id;
|
|
113
|
+
* - `reauth_required`: only a new sign-in brings it back (`reauthReason` says why);
|
|
114
|
+
* - `access_not_fresh`: the grant is intact, but the service could not renew it yet (`retryAt`);
|
|
115
|
+
* - `invalid_argument`, `invalid_record`: the caller's input or the stored record;
|
|
116
|
+
* - `aborted`: the caller's signal;
|
|
117
|
+
* - `contended`: the record kept changing under this call; try again.
|
|
118
|
+
*/
|
|
119
|
+
export type TOpenAiGrantErrorCode = 'not_found' | 'reauth_required' | 'access_not_fresh' | 'invalid_argument'
|
|
120
|
+
| 'invalid_record' | 'aborted' | 'contended';
|
|
121
|
+
|
|
122
|
+
/** A grant failure. Its message is fixed and names no token. */
|
|
123
|
+
export class OpenAiGrantError extends Error {
|
|
124
|
+
constructor(
|
|
125
|
+
public readonly code: TOpenAiGrantErrorCode,
|
|
126
|
+
public readonly details: { reauthReason?: TOpenAiGrantReauthReason; retryAt?: string } = {},
|
|
127
|
+
) {
|
|
128
|
+
super(`OpenAI grant operation failed (${code}).`);
|
|
129
|
+
this.name = 'OpenAiGrantError';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface IOpenAiGrantManagerOptions {
|
|
134
|
+
store: IOpenAiGrantStore;
|
|
135
|
+
fetch?: typeof fetch;
|
|
136
|
+
now?: () => number;
|
|
137
|
+
/** How often a process polls a grant another process is refreshing. Defaults to 250 ms. */
|
|
138
|
+
waitIntervalMs?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Codex refreshes an access token that expires within five minutes (`CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES`). */
|
|
142
|
+
const ACCESS_REFRESH_WINDOW_MS = 5 * 60 * 1000;
|
|
143
|
+
/** Codex refreshes tokens older than eight days even while the access token is valid (`TOKEN_REFRESH_INTERVAL`). */
|
|
144
|
+
const TOKEN_REFRESH_INTERVAL_MS = 8 * 24 * 60 * 60 * 1000;
|
|
145
|
+
/** One refresh request is bounded by thirty seconds; an attempt is given up thirty seconds after that. */
|
|
146
|
+
const REFRESH_REQUEST_TIMEOUT_MS = 30_000;
|
|
147
|
+
const REFRESH_ATTEMPT_LEASE_MS = 60_000;
|
|
148
|
+
const DEFAULT_MIN_VALIDITY_MS = 60_000;
|
|
149
|
+
const MAX_MIN_VALIDITY_MS = 3_600_000;
|
|
150
|
+
const MAX_RESOLVE_ROUNDS = 8;
|
|
151
|
+
const MAX_WRITE_ROUNDS = 4;
|
|
152
|
+
const MAX_GRANT_ID_BYTES = 512;
|
|
153
|
+
|
|
154
|
+
const isoTime = (value: unknown): number => {
|
|
155
|
+
if (typeof value !== 'string') return NaN;
|
|
156
|
+
const time = Date.parse(value);
|
|
157
|
+
return Number.isFinite(time) && new Date(time).toISOString() === value ? time : NaN;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const isCount = (value: unknown, minimum: number): value is number =>
|
|
161
|
+
typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum;
|
|
162
|
+
|
|
163
|
+
const requireGrantId = (grantId: string): string => {
|
|
164
|
+
if (typeof grantId !== 'string' || !grantId || Buffer.byteLength(grantId, 'utf8') > MAX_GRANT_ID_BYTES) {
|
|
165
|
+
throw new OpenAiGrantError('invalid_argument');
|
|
166
|
+
}
|
|
167
|
+
return grantId;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** The identity and expiry a credential's access token states; `null` when it states none of them usable. */
|
|
171
|
+
const accessFactsOf = (credential: IOpenAiOAuthCredential): {
|
|
172
|
+
accountId: string; userId: string; isFedrampAccount: boolean; expiresAt: string;
|
|
173
|
+
} | null => {
|
|
174
|
+
try {
|
|
175
|
+
openAiTokensOfCredential(credential);
|
|
176
|
+
const access = parseOpenAiTokenClaims(credential.accessToken);
|
|
177
|
+
const identity = parseOpenAiTokenClaims(credential.idToken);
|
|
178
|
+
const accountId = access.chatgptAccountId ?? identity.chatgptAccountId;
|
|
179
|
+
const userId = access.chatgptUserId ?? identity.chatgptUserId;
|
|
180
|
+
if (!accountId || !userId || !access.expiresAt) return null;
|
|
181
|
+
if (identity.chatgptAccountId !== undefined && identity.chatgptAccountId !== accountId) return null;
|
|
182
|
+
return { accountId, userId, isFedrampAccount: access.chatgptAccountIsFedramp, expiresAt: access.expiresAt };
|
|
183
|
+
} catch { return null; }
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** A stored record this manager would have written; anything else is refused before it is used. */
|
|
187
|
+
const checkRecord = (record: IOpenAiGrantRecord): IOpenAiGrantRecord => {
|
|
188
|
+
const valid = record !== null && typeof record === 'object'
|
|
189
|
+
&& isCount(record.revision, 1) && isCount(record.generation, 1) && isCount(record.retryCount, 0)
|
|
190
|
+
&& ['active', 'refresh_uncertain', 'reauth_required'].includes(record.state)
|
|
191
|
+
&& typeof record.accountId === 'string' && record.accountId.length > 0
|
|
192
|
+
&& typeof record.userId === 'string' && record.userId.length > 0
|
|
193
|
+
&& typeof record.isFedrampAccount === 'boolean'
|
|
194
|
+
&& Number.isFinite(isoTime(record.accessExpiresAt)) && Number.isFinite(isoTime(record.refreshedAt))
|
|
195
|
+
&& (record.retryAt === null || Number.isFinite(isoTime(record.retryAt)))
|
|
196
|
+
&& (record.state === 'refresh_uncertain'
|
|
197
|
+
? record.attempt !== null && typeof record.attempt === 'object' && typeof record.attempt.id === 'string'
|
|
198
|
+
&& record.attempt.id.length > 0 && Number.isFinite(isoTime(record.attempt.expiresAt))
|
|
199
|
+
: record.attempt === null)
|
|
200
|
+
&& (record.state === 'reauth_required'
|
|
201
|
+
? ['refresh_failed', 'refresh_interrupted', 'identity_changed'].includes(record.reauthReason ?? '')
|
|
202
|
+
: record.reauthReason === null);
|
|
203
|
+
if (!valid) throw new OpenAiGrantError('invalid_record');
|
|
204
|
+
const facts = accessFactsOf(record.credential);
|
|
205
|
+
if (!facts || facts.accountId !== record.accountId || facts.userId !== record.userId
|
|
206
|
+
|| facts.isFedrampAccount !== record.isFedrampAccount || facts.expiresAt !== record.accessExpiresAt) {
|
|
207
|
+
throw new OpenAiGrantError('invalid_record');
|
|
208
|
+
}
|
|
209
|
+
return record;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Keeps ChatGPT sign-ins usable for any number of processes that share one store: it answers current access for
|
|
214
|
+
* each request, refreshes the tokens when they are due or the provider rejected exactly the current generation,
|
|
215
|
+
* and never sends a refresh token whose outcome is unknown a second time. Within a process one refresh per grant
|
|
216
|
+
* runs at a time; across processes the store's compare-and-set on the revision decides, and a process that finds
|
|
217
|
+
* another's refresh in flight waits for its outcome. It holds no credential of its own: every call reads the
|
|
218
|
+
* store, and no error, status or return value carries a refresh or ID token.
|
|
219
|
+
*/
|
|
220
|
+
export class OpenAiGrantManager {
|
|
221
|
+
private readonly store: IOpenAiGrantStore;
|
|
222
|
+
private readonly fetcher: typeof fetch | undefined;
|
|
223
|
+
private readonly now: () => number;
|
|
224
|
+
private readonly waitIntervalMs: number;
|
|
225
|
+
private readonly refreshes = new Map<string, Promise<void>>();
|
|
226
|
+
|
|
227
|
+
constructor(options: IOpenAiGrantManagerOptions) {
|
|
228
|
+
if (!options || typeof options.store !== 'object' || options.store === null) {
|
|
229
|
+
throw new OpenAiGrantError('invalid_argument');
|
|
230
|
+
}
|
|
231
|
+
const waitIntervalMs = options.waitIntervalMs ?? 250;
|
|
232
|
+
if (!Number.isSafeInteger(waitIntervalMs) || waitIntervalMs < 1 || waitIntervalMs > 10_000) {
|
|
233
|
+
throw new OpenAiGrantError('invalid_argument');
|
|
234
|
+
}
|
|
235
|
+
this.store = options.store;
|
|
236
|
+
this.fetcher = options.fetch;
|
|
237
|
+
this.now = options.now ?? (() => Date.now());
|
|
238
|
+
this.waitIntervalMs = waitIntervalMs;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The record a new sign-in starts: generation one, active. */
|
|
242
|
+
private recordOf(result: IOpenAiLoginResult, revision: number, generation: number): IOpenAiGrantRecord {
|
|
243
|
+
const facts = result && typeof result === 'object' ? accessFactsOf(result.credential) : null;
|
|
244
|
+
if (!facts) throw new OpenAiGrantError('invalid_argument');
|
|
245
|
+
return {
|
|
246
|
+
revision, generation, state: 'active', credential: result.credential,
|
|
247
|
+
accountId: facts.accountId, userId: facts.userId, isFedrampAccount: facts.isFedrampAccount,
|
|
248
|
+
accessExpiresAt: facts.expiresAt, refreshedAt: new Date(this.now()).toISOString(),
|
|
249
|
+
retryAt: null, retryCount: 0, attempt: null, reauthReason: null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private statusOf(record: IOpenAiGrantRecord): IOpenAiGrantStatus {
|
|
254
|
+
let plan: string | null = null;
|
|
255
|
+
try { plan = parseOpenAiTokenClaims(record.credential.idToken).chatgptPlanType ?? null; } catch { plan = null; }
|
|
256
|
+
return {
|
|
257
|
+
revision: record.revision, generation: record.generation, state: record.state,
|
|
258
|
+
reauthReason: record.reauthReason, accountId: record.accountId, isFedrampAccount: record.isFedrampAccount,
|
|
259
|
+
plan, accessExpiresAt: record.accessExpiresAt, refreshedAt: record.refreshedAt, retryAt: record.retryAt,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async load(grantId: string): Promise<IOpenAiGrantRecord | null> {
|
|
264
|
+
const record = await this.store.load(grantId);
|
|
265
|
+
return record === null ? null : checkRecord(record);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Stores the grant a device sign-in produced. Throws `OpenAiGrantError` `contended` when a grant exists under the id. */
|
|
269
|
+
public async create(grantId: string, result: IOpenAiLoginResult): Promise<IOpenAiGrantStatus> {
|
|
270
|
+
requireGrantId(grantId);
|
|
271
|
+
const record = this.recordOf(result, 1, 1);
|
|
272
|
+
if (await this.store.create(grantId, record) !== 'stored') throw new OpenAiGrantError('contended');
|
|
273
|
+
return this.statusOf(record);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Replaces a grant with a new sign-in, over the revision the caller read: a grant that needs a new sign-in is
|
|
278
|
+
* repaired this way. The generation keeps rising, so a 401 of the old tokens cannot be mistaken for the new.
|
|
279
|
+
* The replaced tokens are not revoked.
|
|
280
|
+
*/
|
|
281
|
+
public async replace(grantId: string, expectedRevision: number, result: IOpenAiLoginResult): Promise<IOpenAiGrantStatus> {
|
|
282
|
+
requireGrantId(grantId);
|
|
283
|
+
if (!isCount(expectedRevision, 1)) throw new OpenAiGrantError('invalid_argument');
|
|
284
|
+
const current = await this.load(grantId);
|
|
285
|
+
if (!current) throw new OpenAiGrantError('not_found');
|
|
286
|
+
if (current.revision !== expectedRevision) throw new OpenAiGrantError('contended');
|
|
287
|
+
const record = this.recordOf(result, current.revision + 1, current.generation + 1);
|
|
288
|
+
if (await this.store.compareAndSet(grantId, expectedRevision, record) !== 'stored') {
|
|
289
|
+
throw new OpenAiGrantError('contended');
|
|
290
|
+
}
|
|
291
|
+
return this.statusOf(record);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The grant's state without any token; `null` when there is none. */
|
|
295
|
+
public async status(grantId: string): Promise<IOpenAiGrantStatus | null> {
|
|
296
|
+
requireGrantId(grantId);
|
|
297
|
+
const record = await this.load(grantId);
|
|
298
|
+
return record ? this.statusOf(record) : null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private isDue(record: IOpenAiGrantRecord, minValidityMs: number): boolean {
|
|
302
|
+
const now = this.now();
|
|
303
|
+
return Date.parse(record.accessExpiresAt) <= now + Math.max(minValidityMs, ACCESS_REFRESH_WINDOW_MS)
|
|
304
|
+
|| Date.parse(record.refreshedAt) <= now - TOKEN_REFRESH_INTERVAL_MS;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private async wait(signal: AbortSignal | undefined): Promise<void> {
|
|
308
|
+
if (signal?.aborted) throw new OpenAiGrantError('aborted');
|
|
309
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
310
|
+
let onAbort: (() => void) | undefined;
|
|
311
|
+
try {
|
|
312
|
+
await new Promise<void>((resolve, reject) => {
|
|
313
|
+
timer = setTimeout(resolve, this.waitIntervalMs);
|
|
314
|
+
if (signal) {
|
|
315
|
+
onAbort = () => reject(new OpenAiGrantError('aborted'));
|
|
316
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
} finally {
|
|
320
|
+
if (timer) clearTimeout(timer);
|
|
321
|
+
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Waits for a promise, or rejects `aborted` as soon as the signal fires; the promise runs on regardless. */
|
|
326
|
+
private async awaitOrAbort(task: Promise<void>, signal: AbortSignal | undefined): Promise<void> {
|
|
327
|
+
if (!signal) return task;
|
|
328
|
+
if (signal.aborted) throw new OpenAiGrantError('aborted');
|
|
329
|
+
let onAbort: (() => void) | undefined;
|
|
330
|
+
try {
|
|
331
|
+
await Promise.race([task, new Promise<never>((_resolve, reject) => {
|
|
332
|
+
onAbort = () => reject(new OpenAiGrantError('aborted'));
|
|
333
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
334
|
+
})]);
|
|
335
|
+
} finally {
|
|
336
|
+
if (onAbort) signal.removeEventListener('abort', onAbort);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Moves a record on over the revision it was read at; `false` when it moved meanwhile. */
|
|
341
|
+
private async write(grantId: string, current: IOpenAiGrantRecord,
|
|
342
|
+
change: Partial<IOpenAiGrantRecord>): Promise<boolean> {
|
|
343
|
+
const next: IOpenAiGrantRecord = { ...current, ...change, revision: current.revision + 1 };
|
|
344
|
+
return await this.store.compareAndSet(grantId, current.revision, next) === 'stored';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Current access for one provider request. The access stays valid for `minValidityMs` at least; tokens that
|
|
349
|
+
* are due, or whose generation the provider just rejected, are refreshed first.
|
|
350
|
+
* Throws `OpenAiGrantError`: `reauth_required` when only a new sign-in helps, `access_not_fresh` when the
|
|
351
|
+
* service could not renew the tokens yet.
|
|
352
|
+
*/
|
|
353
|
+
public async resolveAccess(grantId: string, options: IOpenAiGrantResolveOptions = {}): Promise<IOpenAiGrantAccess> {
|
|
354
|
+
requireGrantId(grantId);
|
|
355
|
+
const minValidityMs = options.minValidityMs ?? DEFAULT_MIN_VALIDITY_MS;
|
|
356
|
+
const rejected = options.rejectedGeneration;
|
|
357
|
+
if (!Number.isSafeInteger(minValidityMs) || minValidityMs < 0 || minValidityMs > MAX_MIN_VALIDITY_MS
|
|
358
|
+
|| (rejected !== undefined && !isCount(rejected, 1))) {
|
|
359
|
+
throw new OpenAiGrantError('invalid_argument');
|
|
360
|
+
}
|
|
361
|
+
let refreshedOnce = false;
|
|
362
|
+
for (let round = 0; round < MAX_RESOLVE_ROUNDS; round++) {
|
|
363
|
+
if (options.signal?.aborted) throw new OpenAiGrantError('aborted');
|
|
364
|
+
const record = await this.load(grantId);
|
|
365
|
+
if (!record) throw new OpenAiGrantError('not_found');
|
|
366
|
+
if (rejected !== undefined && rejected > record.generation) throw new OpenAiGrantError('invalid_argument');
|
|
367
|
+
if (record.state === 'reauth_required') {
|
|
368
|
+
throw new OpenAiGrantError('reauth_required', { reauthReason: record.reauthReason ?? 'refresh_failed' });
|
|
369
|
+
}
|
|
370
|
+
if (record.state === 'refresh_uncertain') {
|
|
371
|
+
await this.settleAttempt(grantId, record, options.signal);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const rejectedCurrent = rejected === record.generation;
|
|
375
|
+
const expiresAt = Date.parse(record.accessExpiresAt);
|
|
376
|
+
const usable = !rejectedCurrent && expiresAt > this.now() + minValidityMs;
|
|
377
|
+
if (!this.isDue(record, minValidityMs) && usable) {
|
|
378
|
+
return {
|
|
379
|
+
accessToken: record.credential.accessToken, accountId: record.accountId,
|
|
380
|
+
isFedrampAccount: record.isFedrampAccount, expiresAt: record.accessExpiresAt, generation: record.generation,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const retryAt = record.retryAt === null ? null : Date.parse(record.retryAt);
|
|
384
|
+
if (refreshedOnce || (retryAt !== null && retryAt > this.now())) {
|
|
385
|
+
// The service was asked and could not renew the tokens yet. Access that is still good enough serves.
|
|
386
|
+
if (usable) {
|
|
387
|
+
return {
|
|
388
|
+
accessToken: record.credential.accessToken, accountId: record.accountId,
|
|
389
|
+
isFedrampAccount: record.isFedrampAccount, expiresAt: record.accessExpiresAt, generation: record.generation,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
throw new OpenAiGrantError('access_not_fresh', record.retryAt === null ? {} : { retryAt: record.retryAt });
|
|
393
|
+
}
|
|
394
|
+
await this.awaitOrAbort(this.refresh(grantId, record), options.signal);
|
|
395
|
+
refreshedOnce = true;
|
|
396
|
+
}
|
|
397
|
+
throw new OpenAiGrantError('contended');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Refreshes a grant that is due (by its access expiry or its age) and answers its state; one that is not due is
|
|
402
|
+
* left as it is. For a periodic job, so a sign-in that no request uses stays alive.
|
|
403
|
+
*/
|
|
404
|
+
public async keepFresh(grantId: string, options: { signal?: AbortSignal } = {}): Promise<IOpenAiGrantStatus | null> {
|
|
405
|
+
requireGrantId(grantId);
|
|
406
|
+
try {
|
|
407
|
+
await this.resolveAccess(grantId, { signal: options.signal, minValidityMs: 0 });
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (!(error instanceof OpenAiGrantError) || !['reauth_required', 'access_not_fresh', 'not_found'].includes(error.code)) {
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return this.status(grantId);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A record whose refresh may have been sent: this process waits for its own attempt; another process's attempt
|
|
418
|
+
* is waited for until the record moves on or the attempt's lease passes, and then the grant needs a new sign-in.
|
|
419
|
+
*/
|
|
420
|
+
private async settleAttempt(grantId: string, record: IOpenAiGrantRecord, signal: AbortSignal | undefined): Promise<void> {
|
|
421
|
+
const own = this.refreshes.get(grantId);
|
|
422
|
+
if (own) {
|
|
423
|
+
await this.awaitOrAbort(own, signal);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
let current: IOpenAiGrantRecord | null = record;
|
|
427
|
+
while (current && current.revision === record.revision) {
|
|
428
|
+
if (!current.attempt || Date.parse(current.attempt.expiresAt) <= this.now()) {
|
|
429
|
+
await this.write(grantId, current, {
|
|
430
|
+
state: 'reauth_required', reauthReason: 'refresh_interrupted', attempt: null, retryAt: null,
|
|
431
|
+
});
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
await this.wait(signal);
|
|
435
|
+
current = await this.load(grantId);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** One refresh per grant in this process; a caller that stops waiting leaves it running to its end. */
|
|
440
|
+
private refresh(grantId: string, record: IOpenAiGrantRecord): Promise<void> {
|
|
441
|
+
const prior = this.refreshes.get(grantId);
|
|
442
|
+
if (prior) return prior;
|
|
443
|
+
const task = this.performRefresh(grantId, record)
|
|
444
|
+
.finally(() => { if (this.refreshes.get(grantId) === task) this.refreshes.delete(grantId); });
|
|
445
|
+
this.refreshes.set(grantId, task);
|
|
446
|
+
return task;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private async performRefresh(grantId: string, initial: IOpenAiGrantRecord): Promise<void> {
|
|
450
|
+
const attemptId = plugins.crypto.randomUUID();
|
|
451
|
+
const attempt = { id: attemptId, expiresAt: new Date(this.now() + REFRESH_ATTEMPT_LEASE_MS).toISOString() };
|
|
452
|
+
// The marker is stored before the refresh token is sent: a process that dies after this point leaves a
|
|
453
|
+
// record that can only lead to a new sign-in, never to a second use of the same refresh token.
|
|
454
|
+
if (!await this.write(grantId, initial, { state: 'refresh_uncertain', attempt, retryAt: null })) return;
|
|
455
|
+
const marked: IOpenAiGrantRecord = { ...initial, state: 'refresh_uncertain', attempt, retryAt: null,
|
|
456
|
+
revision: initial.revision + 1 };
|
|
457
|
+
const result = await refreshOpenAiCredential(initial.credential, {
|
|
458
|
+
fetch: this.fetcher, signal: AbortSignal.timeout(REFRESH_REQUEST_TIMEOUT_MS), now: () => new Date(this.now()),
|
|
459
|
+
});
|
|
460
|
+
const current = await this.load(grantId);
|
|
461
|
+
if (!current || current.revision !== marked.revision || current.attempt?.id !== attemptId) return;
|
|
462
|
+
if (result.success) {
|
|
463
|
+
const facts = accessFactsOf(result.credential);
|
|
464
|
+
if (!facts || facts.accountId !== initial.accountId || facts.userId !== initial.userId) {
|
|
465
|
+
await this.write(grantId, current, {
|
|
466
|
+
state: 'reauth_required', reauthReason: 'identity_changed', attempt: null, retryAt: null,
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
await this.write(grantId, current, {
|
|
471
|
+
state: 'active', credential: result.credential, generation: current.generation + 1,
|
|
472
|
+
isFedrampAccount: facts.isFedrampAccount, accessExpiresAt: facts.expiresAt,
|
|
473
|
+
refreshedAt: new Date(this.now()).toISOString(), retryAt: null, retryCount: 0, attempt: null,
|
|
474
|
+
reauthReason: null,
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (result.requestOutcome === 'notSent') {
|
|
479
|
+
const backoffMs = Math.min(30_000 * 2 ** Math.min(current.retryCount, 10), 1_800_000);
|
|
480
|
+
const retryMs = Math.max(backoffMs, Math.min(result.retryAfterMs ?? 0, 3_600_000));
|
|
481
|
+
await this.write(grantId, current, {
|
|
482
|
+
state: 'active', attempt: null, retryAt: new Date(this.now() + retryMs).toISOString(),
|
|
483
|
+
retryCount: current.retryCount + 1,
|
|
484
|
+
});
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
await this.write(grantId, current, {
|
|
488
|
+
state: 'reauth_required', reauthReason: 'refresh_failed', attempt: null, retryAt: null,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Removes a grant and then asks the service to revoke its refresh token. The grant is removed whatever the
|
|
494
|
+
* service answers; `revocation` says what it answered. `removed: false` when there was no grant.
|
|
495
|
+
*/
|
|
496
|
+
public async remove(grantId: string, options: { signal?: AbortSignal } = {}): Promise<{
|
|
497
|
+
removed: boolean; revocation: TOpenAiGrantRevocation | null;
|
|
498
|
+
}> {
|
|
499
|
+
requireGrantId(grantId);
|
|
500
|
+
for (let round = 0; round < MAX_WRITE_ROUNDS; round++) {
|
|
501
|
+
const record = await this.store.load(grantId);
|
|
502
|
+
if (!record) return { removed: false, revocation: null };
|
|
503
|
+
if (!isCount(record.revision, 1)) throw new OpenAiGrantError('invalid_record');
|
|
504
|
+
if (await this.store.remove(grantId, record.revision) !== 'removed') continue;
|
|
505
|
+
let token: string | null = null;
|
|
506
|
+
try { token = openAiTokensOfCredential(record.credential).refreshToken; } catch { token = null; }
|
|
507
|
+
if (token === null) return { removed: true, revocation: 'failed' };
|
|
508
|
+
try {
|
|
509
|
+
await revokeOpenAiRefreshToken(token, { fetch: this.fetcher, signal: options.signal });
|
|
510
|
+
} catch {
|
|
511
|
+
return { removed: true, revocation: 'failed' };
|
|
512
|
+
}
|
|
513
|
+
const current = record.state === 'active' && record.attempt === null;
|
|
514
|
+
return { removed: true, revocation: current ? 'confirmed' : 'unconfirmed' };
|
|
515
|
+
}
|
|
516
|
+
throw new OpenAiGrantError('contended');
|
|
517
|
+
}
|
|
518
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
/**
|
|
4
|
+
* The shapes of one ChatGPT sign-in: the credential it produces, the access that credential grants, what its
|
|
5
|
+
* claims say about the account, and the outcome of a refresh. Inference with an access token is FlexHarness's
|
|
6
|
+
* `/openai`, not this.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Whether a failed request reached the provider: `notSent` never did, `outcomeUnknown` may have. */
|
|
10
|
+
export type TOpenAiRequestOutcome = 'notSent' | 'outcomeUnknown';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A ChatGPT sign-in with its rotating refresh token. This exact shape is what the authority seals, so a stored
|
|
14
|
+
* credential keeps reading back across releases.
|
|
15
|
+
*/
|
|
16
|
+
export interface IOpenAiOAuthCredential {
|
|
17
|
+
kind: 'chatgptOAuth';
|
|
18
|
+
providerId: 'openai';
|
|
19
|
+
accessToken: string;
|
|
20
|
+
refreshToken: string;
|
|
21
|
+
idToken: string;
|
|
22
|
+
/** When this credential was last refreshed; strictly increasing across refreshes. */
|
|
23
|
+
lastRefreshAt?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Access to one ChatGPT workspace, with the workspace and residency its issuer states. */
|
|
27
|
+
export interface IOpenAiAccess {
|
|
28
|
+
accessToken: string;
|
|
29
|
+
accountId: string;
|
|
30
|
+
isFedrampAccount: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a sign-in's claims say about its account; no token. */
|
|
34
|
+
export interface IOpenAiAccountSummary {
|
|
35
|
+
providerId: 'openai';
|
|
36
|
+
authKind: 'chatgptOAuth';
|
|
37
|
+
accountId?: string;
|
|
38
|
+
email?: string;
|
|
39
|
+
plan?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What the person signing in does: open the URL and enter the code. */
|
|
43
|
+
export interface IOpenAiDevicePrompt {
|
|
44
|
+
flow: 'device';
|
|
45
|
+
verificationUrl: string;
|
|
46
|
+
userCode: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface IOpenAiLoginResult {
|
|
50
|
+
credential: IOpenAiOAuthCredential;
|
|
51
|
+
account: IOpenAiAccountSummary;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type TOpenAiOperationErrorCode = 'ABORTED' | 'TIMEOUT';
|
|
55
|
+
|
|
56
|
+
export interface IOpenAiRefreshSuccess {
|
|
57
|
+
success: true;
|
|
58
|
+
credential: IOpenAiOAuthCredential;
|
|
59
|
+
account: IOpenAiAccountSummary;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface IOpenAiRefreshFailure {
|
|
63
|
+
success: false;
|
|
64
|
+
credential: IOpenAiOAuthCredential;
|
|
65
|
+
errorCode: TOpenAiOperationErrorCode | 'REFRESH_FAILED';
|
|
66
|
+
/** Never replay a rotating grant when this is `outcomeUnknown`. */
|
|
67
|
+
requestOutcome: TOpenAiRequestOutcome;
|
|
68
|
+
/** The response status, when an HTTP response was observed. */
|
|
69
|
+
responseStatus?: number;
|
|
70
|
+
/** Bounded Retry-After delay from the observed response. */
|
|
71
|
+
retryAfterMs?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type TOpenAiRefreshResult = IOpenAiRefreshSuccess | IOpenAiRefreshFailure;
|
|
75
|
+
|