@pure01fx/dsh-openai-codex-auth 0.7.3 → 0.9.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/CHANGELOG.md +19 -0
- package/README.md +16 -5
- package/client.js +147 -4
- package/lib/catalog.d.ts +5 -0
- package/lib/catalog.js +19 -0
- package/lib/index.d.ts +42 -3
- package/lib/index.js +771 -158
- package/lib/native-adapter.d.ts +7 -0
- package/lib/native-adapter.js +22 -10
- package/lib/native-http.js +8 -0
- package/lib/native-websocket.js +18 -5
- package/lib/replay.d.ts +1 -0
- package/lib/replay.js +8 -2
- package/lib/response-usage.d.ts +4 -2
- package/lib/response-usage.js +26 -6
- package/lib/responses.d.ts +8 -1
- package/lib/responses.js +71 -9
- package/lib/upstream.d.ts +4 -4
- package/lib/upstream.js +4 -4
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +10 -3
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -39,6 +39,10 @@ const TOKEN_REF = credentialRef('DSH_OPENAI_CODEX_TOKEN');
|
|
|
39
39
|
const USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
|
40
40
|
const MAX_ERROR_BODY_LENGTH = 1_024;
|
|
41
41
|
const MAX_REQUEST_BODY_LENGTH = 8_192;
|
|
42
|
+
const ACCOUNT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
|
|
43
|
+
const TOKEN_REFRESH_REQUEST_TIMEOUT_MS = 30_000;
|
|
44
|
+
const ACCOUNT_REFRESH_LOCK_WAIT_MS = 35_000;
|
|
45
|
+
const CREDENTIAL_REFRESH_PERSIST_WAIT_MS = 500;
|
|
42
46
|
class DeviceCodeUnavailableError extends Error {
|
|
43
47
|
code = 'device_code_unavailable';
|
|
44
48
|
}
|
|
@@ -46,12 +50,32 @@ class LoginConflictError extends Error {
|
|
|
46
50
|
}
|
|
47
51
|
class CredentialNotWritableError extends Error {
|
|
48
52
|
}
|
|
53
|
+
class AccountNotFoundError extends Error {
|
|
54
|
+
}
|
|
55
|
+
class CredentialRefreshUncertainError extends Error {
|
|
56
|
+
constructor(cause) {
|
|
57
|
+
super('OpenAI credential refresh outcome is uncertain; sign in to this account again', { cause });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
49
60
|
const PERMANENT_REFRESH_CODES = new Set([
|
|
50
61
|
'refresh_token_expired',
|
|
51
62
|
'refresh_token_reused',
|
|
52
63
|
'refresh_token_invalidated',
|
|
53
64
|
'invalid_grant',
|
|
54
65
|
]);
|
|
66
|
+
const DEFINITIVE_OAUTH_REFRESH_CODES = new Set([
|
|
67
|
+
...PERMANENT_REFRESH_CODES,
|
|
68
|
+
'access_denied',
|
|
69
|
+
'invalid_client',
|
|
70
|
+
'invalid_request',
|
|
71
|
+
'invalid_scope',
|
|
72
|
+
'server_error',
|
|
73
|
+
'slow_down',
|
|
74
|
+
'temporarily_unavailable',
|
|
75
|
+
'temporary_failure',
|
|
76
|
+
'unauthorized_client',
|
|
77
|
+
'unsupported_grant_type',
|
|
78
|
+
]);
|
|
55
79
|
class OAuthEndpointError extends Error {
|
|
56
80
|
status;
|
|
57
81
|
oauthCode;
|
|
@@ -68,6 +92,33 @@ function base64Url(value) {
|
|
|
68
92
|
function messageOf(error) {
|
|
69
93
|
return error instanceof Error ? error.message : String(error);
|
|
70
94
|
}
|
|
95
|
+
function isTimeoutAbort(signal) {
|
|
96
|
+
return signal?.aborted === true
|
|
97
|
+
&& signal.reason instanceof DOMException
|
|
98
|
+
&& signal.reason.name === 'TimeoutError';
|
|
99
|
+
}
|
|
100
|
+
function boundedAccountUsageSignal(signal) {
|
|
101
|
+
const timeout = AbortSignal.timeout(ACCOUNT_USAGE_REQUEST_TIMEOUT_MS);
|
|
102
|
+
return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
|
|
103
|
+
}
|
|
104
|
+
function isFileLockTimeout(error) {
|
|
105
|
+
return error instanceof Error
|
|
106
|
+
&& error.message.startsWith('atomic-write: timed out waiting for the writer lock at ');
|
|
107
|
+
}
|
|
108
|
+
function waitForSignal(promise, signal) {
|
|
109
|
+
if (signal === undefined)
|
|
110
|
+
return promise;
|
|
111
|
+
if (signal.aborted)
|
|
112
|
+
return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('Request cancelled'));
|
|
113
|
+
return new Promise((resolveWait, rejectWait) => {
|
|
114
|
+
const onAbort = () => {
|
|
115
|
+
signal.removeEventListener('abort', onAbort);
|
|
116
|
+
rejectWait(signal.reason instanceof Error ? signal.reason : new Error('Request cancelled'));
|
|
117
|
+
};
|
|
118
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
119
|
+
promise.then((value) => { signal.removeEventListener('abort', onAbort); resolveWait(value); }, (error) => { signal.removeEventListener('abort', onAbort); rejectWait(error); });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
71
122
|
function throwIfCancelled(signal) {
|
|
72
123
|
if (!signal?.aborted)
|
|
73
124
|
return;
|
|
@@ -124,28 +175,76 @@ function parseTokenResponse(value, previous) {
|
|
|
124
175
|
: chatGptAccountId(idToken, 'ID token');
|
|
125
176
|
if (resolvedAccountId === undefined)
|
|
126
177
|
throw new Error('OpenAI token response has no ID token');
|
|
127
|
-
|
|
178
|
+
const emailClaim = idToken === undefined ? undefined : jwtPayload(idToken, 'ID token').email;
|
|
179
|
+
const email = typeof emailClaim === 'string' && emailClaim.trim() !== ''
|
|
180
|
+
? emailClaim.trim()
|
|
181
|
+
: previous?.email;
|
|
182
|
+
return {
|
|
183
|
+
access: value.access_token,
|
|
184
|
+
refresh,
|
|
185
|
+
expires,
|
|
186
|
+
accountId: resolvedAccountId,
|
|
187
|
+
...email === undefined ? {} : { email },
|
|
188
|
+
};
|
|
128
189
|
}
|
|
129
190
|
function isPermanentRefreshError(error) {
|
|
130
191
|
return error instanceof OAuthEndpointError
|
|
131
192
|
&& (error.status === 401
|
|
132
193
|
|| (error.oauthCode !== undefined && PERMANENT_REFRESH_CODES.has(error.oauthCode.toLowerCase())));
|
|
133
194
|
}
|
|
134
|
-
function
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
195
|
+
function isDefinitiveOAuthRefreshError(error) {
|
|
196
|
+
return error instanceof OAuthEndpointError
|
|
197
|
+
&& (error.status === 400 || error.status === 401)
|
|
198
|
+
&& error.oauthCode !== undefined
|
|
199
|
+
&& DEFINITIVE_OAUTH_REFRESH_CODES.has(error.oauthCode.toLowerCase());
|
|
200
|
+
}
|
|
201
|
+
function validateCredential(value, filename) {
|
|
202
|
+
const credential = value;
|
|
203
|
+
if (credential === null || typeof credential !== 'object'
|
|
138
204
|
|| typeof credential.access !== 'string' || credential.access.length === 0
|
|
139
205
|
|| typeof credential.refresh !== 'string' || credential.refresh.length === 0
|
|
140
206
|
|| typeof credential.expires !== 'number' || !Number.isFinite(credential.expires)
|
|
141
|
-
|| typeof credential.accountId !== 'string' || credential.accountId.length === 0
|
|
207
|
+
|| typeof credential.accountId !== 'string' || credential.accountId.length === 0
|
|
208
|
+
|| credential.accountId.length > 512
|
|
209
|
+
|| (credential.email !== undefined && (typeof credential.email !== 'string' || credential.email.length > 512))) {
|
|
142
210
|
throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
|
|
143
211
|
}
|
|
144
|
-
return
|
|
212
|
+
return {
|
|
213
|
+
access: credential.access,
|
|
214
|
+
refresh: credential.refresh,
|
|
215
|
+
expires: credential.expires,
|
|
216
|
+
accountId: credential.accountId,
|
|
217
|
+
...credential.email === undefined ? {} : { email: credential.email },
|
|
218
|
+
};
|
|
145
219
|
}
|
|
146
|
-
|
|
220
|
+
function parseCredentialDocument(text, filename) {
|
|
221
|
+
const value = JSON.parse(text);
|
|
222
|
+
if (value.version === 1) {
|
|
223
|
+
const credential = validateCredential(value.credential, filename);
|
|
224
|
+
return {
|
|
225
|
+
document: { version: 2, currentAccountId: credential.accountId, accounts: [credential] },
|
|
226
|
+
migrated: true,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (value.version !== 2 || !Array.isArray(value.accounts)
|
|
230
|
+
|| (value.currentAccountId !== null && typeof value.currentAccountId !== 'string')) {
|
|
231
|
+
throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
|
|
232
|
+
}
|
|
233
|
+
const accounts = value.accounts.map(account => validateCredential(account, filename));
|
|
234
|
+
const ids = new Set(accounts.map(account => account.accountId));
|
|
235
|
+
if (ids.size !== accounts.length
|
|
236
|
+
|| (accounts.length === 0 && value.currentAccountId !== null)
|
|
237
|
+
|| (accounts.length > 0 && (value.currentAccountId === null || !ids.has(value.currentAccountId)))) {
|
|
238
|
+
throw new Error(`openai-codex-auth: invalid credential document ${filename}`);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
document: { version: 2, currentAccountId: value.currentAccountId, accounts },
|
|
242
|
+
migrated: false,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async function readCredentialDocument(filename) {
|
|
147
246
|
try {
|
|
148
|
-
return
|
|
247
|
+
return parseCredentialDocument(await readFile(filename, 'utf8'), filename);
|
|
149
248
|
}
|
|
150
249
|
catch (error) {
|
|
151
250
|
if (error.code === 'ENOENT')
|
|
@@ -153,6 +252,11 @@ async function readCredential(filename) {
|
|
|
153
252
|
throw error;
|
|
154
253
|
}
|
|
155
254
|
}
|
|
255
|
+
function currentCredential(document) {
|
|
256
|
+
if (document?.currentAccountId === null || document === undefined)
|
|
257
|
+
return undefined;
|
|
258
|
+
return document.accounts.find(account => account.accountId === document.currentAccountId);
|
|
259
|
+
}
|
|
156
260
|
async function responseText(response) {
|
|
157
261
|
return (await response.text().catch(() => '')).slice(0, MAX_ERROR_BODY_LENGTH);
|
|
158
262
|
}
|
|
@@ -209,7 +313,7 @@ async function tokenRequest(init, previous, signal) {
|
|
|
209
313
|
}
|
|
210
314
|
catch (error) {
|
|
211
315
|
if (signal?.aborted)
|
|
212
|
-
throw new Error('OpenAI login cancelled');
|
|
316
|
+
throw new Error(isTimeoutAbort(signal) ? 'OpenAI token request timed out' : 'OpenAI login cancelled');
|
|
213
317
|
throw error;
|
|
214
318
|
}
|
|
215
319
|
if (!response.ok) {
|
|
@@ -573,6 +677,8 @@ function resolveCodexRouteStatus(config, llm) {
|
|
|
573
677
|
}
|
|
574
678
|
export const internals = {
|
|
575
679
|
parseTokenResponse,
|
|
680
|
+
parseCredentialDocument,
|
|
681
|
+
currentCredential,
|
|
576
682
|
parseAuthority,
|
|
577
683
|
isLoopbackHostname,
|
|
578
684
|
isTrustedHost,
|
|
@@ -604,6 +710,8 @@ export class OpenAICodexAuth extends Service {
|
|
|
604
710
|
usageError;
|
|
605
711
|
usageRefresh;
|
|
606
712
|
usageGeneration = 0;
|
|
713
|
+
accountUsageRequestGeneration = 0;
|
|
714
|
+
accountUsageCredentialRefreshes = new Map();
|
|
607
715
|
directUsageSequence = 0;
|
|
608
716
|
directUsageAccountId;
|
|
609
717
|
usageHasDirectDefault = false;
|
|
@@ -703,6 +811,9 @@ export class OpenAICodexAuth extends Service {
|
|
|
703
811
|
register('/openai-codex/browser/prepare', (req, res) => this.handleBrowserPrepare(req, res));
|
|
704
812
|
register('/openai-codex/browser/complete', (req, res) => this.handleBrowserComplete(req, res));
|
|
705
813
|
register('/openai-codex/cancel', (req, res) => this.handleCancel(req, res));
|
|
814
|
+
register('/openai-codex/accounts/current', (req, res) => this.handleSetCurrentAccount(req, res));
|
|
815
|
+
register('/openai-codex/accounts/usage', (req, res) => this.handleAccountUsage(req, res));
|
|
816
|
+
register('/openai-codex/accounts/logout', (req, res) => this.handleAccountLogout(req, res));
|
|
706
817
|
register('/openai-codex/logout', (req, res) => this.handleLogout(req, res));
|
|
707
818
|
}
|
|
708
819
|
catch (error) {
|
|
@@ -747,7 +858,7 @@ export class OpenAICodexAuth extends Service {
|
|
|
747
858
|
return;
|
|
748
859
|
this.responseUsage = {
|
|
749
860
|
accountId: observation.accountId,
|
|
750
|
-
|
|
861
|
+
metadata: observation.metadata,
|
|
751
862
|
observedAt: Date.now(),
|
|
752
863
|
};
|
|
753
864
|
}
|
|
@@ -768,8 +879,7 @@ export class OpenAICodexAuth extends Service {
|
|
|
768
879
|
const sameUsageAccount = this.usageAccountId === accountId;
|
|
769
880
|
if (!sameUsageAccount)
|
|
770
881
|
this.usageHasDirectDefault = false;
|
|
771
|
-
|
|
772
|
-
this.usageGeneration += 1;
|
|
882
|
+
this.usageGeneration += 1;
|
|
773
883
|
this.usageCache = mergeDirectUsage(sameUsageAccount ? this.usageCache : undefined, accepted);
|
|
774
884
|
this.usageAccountId = accountId;
|
|
775
885
|
this.usageError = undefined;
|
|
@@ -802,7 +912,7 @@ export class OpenAICodexAuth extends Service {
|
|
|
802
912
|
}
|
|
803
913
|
async performUsageRefresh(generation) {
|
|
804
914
|
try {
|
|
805
|
-
const credential = await
|
|
915
|
+
const credential = await this.managedCredential();
|
|
806
916
|
if (credential === undefined) {
|
|
807
917
|
if (this.usageGeneration === generation) {
|
|
808
918
|
this.usageCache = undefined;
|
|
@@ -900,72 +1010,124 @@ export class OpenAICodexAuth extends Service {
|
|
|
900
1010
|
}
|
|
901
1011
|
throw failure;
|
|
902
1012
|
}
|
|
903
|
-
async
|
|
1013
|
+
async commitDocument(document, signal) {
|
|
1014
|
+
const nextToken = currentCredential(document)?.access;
|
|
904
1015
|
const previous = await this.ctx.credentials.resolve(TOKEN_REF);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
}
|
|
908
|
-
catch (error) {
|
|
909
|
-
let current;
|
|
1016
|
+
throwIfCancelled(signal);
|
|
1017
|
+
if (previous?.value !== nextToken) {
|
|
910
1018
|
try {
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1019
|
+
if (nextToken === undefined)
|
|
1020
|
+
await this.ctx.credentials.unset(TOKEN_REF);
|
|
1021
|
+
else
|
|
1022
|
+
await this.ctx.credentials.set(TOKEN_REF, nextToken);
|
|
915
1023
|
}
|
|
916
|
-
|
|
917
|
-
|
|
1024
|
+
catch (error) {
|
|
1025
|
+
let current;
|
|
1026
|
+
try {
|
|
1027
|
+
current = await this.ctx.credentials.resolve(TOKEN_REF);
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
return this.failAfterPublicationRollback(previous, nextToken, error);
|
|
1031
|
+
}
|
|
1032
|
+
if (current?.value === nextToken) {
|
|
1033
|
+
return this.failAfterPublicationRollback(previous, nextToken, error);
|
|
1034
|
+
}
|
|
1035
|
+
if (current?.value !== previous?.value)
|
|
1036
|
+
throw this.publicationChangedError(error);
|
|
1037
|
+
throw error;
|
|
918
1038
|
}
|
|
919
|
-
if (current?.value !== previous?.value)
|
|
920
|
-
throw this.publicationChangedError(error);
|
|
921
|
-
throw error;
|
|
922
1039
|
}
|
|
923
1040
|
try {
|
|
924
|
-
await this.write(
|
|
1041
|
+
await this.write(document);
|
|
925
1042
|
}
|
|
926
1043
|
catch (error) {
|
|
927
|
-
|
|
1044
|
+
if (previous?.value === nextToken)
|
|
1045
|
+
throw error;
|
|
1046
|
+
return this.failAfterPublicationRollback(previous, nextToken, error);
|
|
928
1047
|
}
|
|
929
1048
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
const
|
|
1049
|
+
upsertCurrentCredential(document, credential, replacedAccountId = credential.accountId) {
|
|
1050
|
+
const existingIndex = document.accounts.findIndex(account => account.accountId === replacedAccountId);
|
|
1051
|
+
const accounts = document.accounts.filter(account => account.accountId !== credential.accountId);
|
|
1052
|
+
const filteredIndex = accounts.findIndex(account => account.accountId === replacedAccountId);
|
|
1053
|
+
if (filteredIndex >= 0)
|
|
1054
|
+
accounts.splice(filteredIndex, 1);
|
|
1055
|
+
const insertion = existingIndex < 0 ? accounts.length : Math.min(existingIndex, accounts.length);
|
|
1056
|
+
accounts.splice(insertion, 0, credential);
|
|
1057
|
+
return { version: 2, currentAccountId: credential.accountId, accounts };
|
|
1058
|
+
}
|
|
1059
|
+
async commitCredential(credential) {
|
|
1060
|
+
const parsed = await readCredentialDocument(this.filename);
|
|
1061
|
+
const document = parsed?.document ?? { version: 2, currentAccountId: null, accounts: [] };
|
|
1062
|
+
await this.commitDocument(this.upsertCurrentCredential(document, credential));
|
|
1063
|
+
}
|
|
1064
|
+
/** Return the current managed bearer token, refreshing and migrating it when needed. */
|
|
1065
|
+
async bearerToken(signal) {
|
|
933
1066
|
throwIfCancelled(signal);
|
|
934
|
-
|
|
935
|
-
this.
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1067
|
+
while (true) {
|
|
1068
|
+
const prepared = await withFileLock(this.filename, async () => {
|
|
1069
|
+
throwIfCancelled(signal);
|
|
1070
|
+
const parsed = await readCredentialDocument(this.filename);
|
|
1071
|
+
const document = parsed?.document;
|
|
1072
|
+
const current = currentCredential(document);
|
|
1073
|
+
if (current === undefined) {
|
|
1074
|
+
this.setCredentialAccount(undefined);
|
|
1075
|
+
return undefined;
|
|
1076
|
+
}
|
|
1077
|
+
if (current.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS) {
|
|
1078
|
+
if (parsed?.migrated)
|
|
1079
|
+
await this.commitDocument(document, signal);
|
|
1080
|
+
else
|
|
1081
|
+
await this.publishCredentialToken(current.access, signal);
|
|
1082
|
+
this.setCredentialAccount(current.accountId);
|
|
1083
|
+
return { credential: current, ready: true };
|
|
1084
|
+
}
|
|
1085
|
+
return { credential: current, ready: false };
|
|
1086
|
+
});
|
|
1087
|
+
if (prepared === undefined)
|
|
1088
|
+
return undefined;
|
|
1089
|
+
if (prepared.ready)
|
|
1090
|
+
return prepared.credential.access;
|
|
1091
|
+
let refreshed;
|
|
1092
|
+
try {
|
|
1093
|
+
refreshed = await waitForSignal(this.refreshManagedAccount(prepared.credential, true), signal);
|
|
954
1094
|
}
|
|
955
|
-
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
throwIfCancelled(signal);
|
|
1097
|
+
throw error;
|
|
1098
|
+
}
|
|
1099
|
+
if (refreshed === undefined)
|
|
1100
|
+
continue;
|
|
1101
|
+
const access = await withFileLock(this.filename, async () => {
|
|
1102
|
+
throwIfCancelled(signal);
|
|
1103
|
+
const parsed = await readCredentialDocument(this.filename);
|
|
1104
|
+
const document = parsed?.document;
|
|
1105
|
+
const current = currentCredential(document);
|
|
1106
|
+
if (current === undefined || current.accountId !== prepared.credential.accountId)
|
|
1107
|
+
return undefined;
|
|
1108
|
+
if (current.expires <= Date.now())
|
|
1109
|
+
throw new Error('OpenAI Codex access token has expired');
|
|
1110
|
+
if (parsed?.migrated)
|
|
1111
|
+
await this.commitDocument(document, signal);
|
|
1112
|
+
else
|
|
1113
|
+
await this.publishCredentialToken(current.access, signal);
|
|
1114
|
+
this.setCredentialAccount(current.accountId);
|
|
1115
|
+
return current.access;
|
|
1116
|
+
});
|
|
1117
|
+
if (access !== undefined)
|
|
1118
|
+
return access;
|
|
956
1119
|
}
|
|
957
|
-
throwIfCancelled(signal);
|
|
958
|
-
await this.commitCredential(next);
|
|
959
|
-
this.setCredentialAccount(next.accountId);
|
|
960
|
-
return next;
|
|
961
1120
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1121
|
+
async managedCredential(signal) {
|
|
1122
|
+
while (true) {
|
|
1123
|
+
const access = await this.bearerToken(signal);
|
|
1124
|
+
if (access === undefined)
|
|
1125
|
+
return undefined;
|
|
1126
|
+
const current = currentCredential((await readCredentialDocument(this.filename))?.document);
|
|
1127
|
+
if (current?.access === access)
|
|
1128
|
+
return current;
|
|
1129
|
+
throwIfCancelled(signal);
|
|
1130
|
+
}
|
|
969
1131
|
}
|
|
970
1132
|
externalNativeCredential(accessToken) {
|
|
971
1133
|
let accountId;
|
|
@@ -985,23 +1147,20 @@ export class OpenAICodexAuth extends Service {
|
|
|
985
1147
|
async resolveNativeCredential(signal) {
|
|
986
1148
|
try {
|
|
987
1149
|
throwIfCancelled(signal);
|
|
988
|
-
|
|
1150
|
+
const managed = await this.managedCredential(signal);
|
|
1151
|
+
if (managed !== undefined) {
|
|
989
1152
|
throwIfCancelled(signal);
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
throwIfCancelled(signal);
|
|
1002
|
-
this.setCredentialAccount(credential.accountId);
|
|
1003
|
-
return credential;
|
|
1004
|
-
});
|
|
1153
|
+
return { accessToken: managed.access, accountId: managed.accountId };
|
|
1154
|
+
}
|
|
1155
|
+
const external = await this.ctx.credentials.resolve(TOKEN_REF);
|
|
1156
|
+
throwIfCancelled(signal);
|
|
1157
|
+
if (external === undefined) {
|
|
1158
|
+
throw new LlmError('native Codex credential is not configured', 'MISSING_CREDENTIAL');
|
|
1159
|
+
}
|
|
1160
|
+
const credential = this.externalNativeCredential(external.value);
|
|
1161
|
+
throwIfCancelled(signal);
|
|
1162
|
+
this.setCredentialAccount(credential.accountId);
|
|
1163
|
+
return credential;
|
|
1005
1164
|
}
|
|
1006
1165
|
catch (error) {
|
|
1007
1166
|
if (error instanceof LlmError)
|
|
@@ -1013,9 +1172,12 @@ export class OpenAICodexAuth extends Service {
|
|
|
1013
1172
|
}
|
|
1014
1173
|
}
|
|
1015
1174
|
nativeRecoveryError(error) {
|
|
1175
|
+
const cause = error instanceof CredentialRefreshUncertainError && error.cause !== undefined
|
|
1176
|
+
? error.cause
|
|
1177
|
+
: error;
|
|
1016
1178
|
const options = {
|
|
1017
|
-
cause
|
|
1018
|
-
...(
|
|
1179
|
+
cause,
|
|
1180
|
+
...(cause instanceof OAuthEndpointError ? { status: cause.status } : {}),
|
|
1019
1181
|
};
|
|
1020
1182
|
if (error instanceof CredentialNotWritableError || isPermanentRefreshError(error)) {
|
|
1021
1183
|
return new LlmError('native Codex managed credential cannot be refreshed', INVALID_CREDENTIAL_CODE, options);
|
|
@@ -1025,35 +1187,46 @@ export class OpenAICodexAuth extends Service {
|
|
|
1025
1187
|
async recoverNativeCredential(previous, signal) {
|
|
1026
1188
|
try {
|
|
1027
1189
|
throwIfCancelled(signal);
|
|
1028
|
-
|
|
1029
|
-
throwIfCancelled(signal);
|
|
1030
|
-
const current = await readCredential(this.filename);
|
|
1190
|
+
const prepared = await withFileLock(this.filename, async () => {
|
|
1031
1191
|
throwIfCancelled(signal);
|
|
1192
|
+
const current = currentCredential((await readCredentialDocument(this.filename))?.document);
|
|
1032
1193
|
if (current === undefined) {
|
|
1033
1194
|
const external = await this.ctx.credentials.resolve(TOKEN_REF);
|
|
1034
1195
|
throwIfCancelled(signal);
|
|
1035
1196
|
if (external === undefined)
|
|
1036
|
-
return false;
|
|
1197
|
+
return { result: false };
|
|
1037
1198
|
const credential = this.externalNativeCredential(external.value);
|
|
1038
1199
|
throwIfCancelled(signal);
|
|
1200
|
+
if (credential.accountId !== previous.accountId)
|
|
1201
|
+
return { result: false };
|
|
1039
1202
|
this.setCredentialAccount(credential.accountId);
|
|
1040
|
-
return credential.accessToken !== previous.accessToken
|
|
1041
|
-
|| credential.accountId !== previous.accountId;
|
|
1203
|
+
return { result: credential.accessToken !== previous.accessToken };
|
|
1042
1204
|
}
|
|
1043
|
-
|
|
1205
|
+
// A request that started under A must never recover by replaying under B.
|
|
1206
|
+
if (current.accountId !== previous.accountId)
|
|
1207
|
+
return { result: false };
|
|
1208
|
+
if (current.access !== previous.accessToken) {
|
|
1044
1209
|
await this.publishCredentialToken(current.access, signal);
|
|
1045
1210
|
throwIfCancelled(signal);
|
|
1046
1211
|
this.setCredentialAccount(current.accountId);
|
|
1047
|
-
return true;
|
|
1212
|
+
return { result: true };
|
|
1048
1213
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1214
|
+
return { credential: current };
|
|
1215
|
+
});
|
|
1216
|
+
if (prepared.result !== undefined)
|
|
1217
|
+
return prepared.result;
|
|
1218
|
+
const refreshed = await waitForSignal(this.refreshManagedAccount(prepared.credential, true, true), signal);
|
|
1219
|
+
if (refreshed === undefined)
|
|
1220
|
+
return false;
|
|
1221
|
+
return withFileLock(this.filename, async () => {
|
|
1052
1222
|
throwIfCancelled(signal);
|
|
1053
|
-
await this.
|
|
1223
|
+
const current = currentCredential((await readCredentialDocument(this.filename))?.document);
|
|
1224
|
+
if (current === undefined || current.accountId !== previous.accountId)
|
|
1225
|
+
return false;
|
|
1226
|
+
await this.publishCredentialToken(current.access, signal);
|
|
1054
1227
|
throwIfCancelled(signal);
|
|
1055
|
-
this.setCredentialAccount(
|
|
1056
|
-
return
|
|
1228
|
+
this.setCredentialAccount(current.accountId);
|
|
1229
|
+
return current.access !== previous.accessToken;
|
|
1057
1230
|
});
|
|
1058
1231
|
}
|
|
1059
1232
|
catch (error) {
|
|
@@ -1068,18 +1241,22 @@ export class OpenAICodexAuth extends Service {
|
|
|
1068
1241
|
async finishCredential(credential, signal) {
|
|
1069
1242
|
await this.assertCredentialWritable();
|
|
1070
1243
|
throwIfCancelled(signal);
|
|
1071
|
-
await
|
|
1244
|
+
await this.withAccountRefreshLock(credential.accountId, async () => {
|
|
1072
1245
|
throwIfCancelled(signal);
|
|
1073
|
-
await this.
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
this.
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1246
|
+
await withFileLock(this.filename, async () => {
|
|
1247
|
+
throwIfCancelled(signal);
|
|
1248
|
+
await this.commitCredential(credential);
|
|
1249
|
+
this.setCredentialAccount(credential.accountId);
|
|
1250
|
+
this.usageGeneration += 1;
|
|
1251
|
+
if (this.usageRefresh !== undefined)
|
|
1252
|
+
this.usageRefresh.queued = true;
|
|
1253
|
+
this.usageCache = undefined;
|
|
1254
|
+
this.usageAccountId = undefined;
|
|
1255
|
+
this.directUsageAccountId = undefined;
|
|
1256
|
+
this.usageHasDirectDefault = false;
|
|
1257
|
+
this.usageError = undefined;
|
|
1258
|
+
});
|
|
1259
|
+
await this.clearAccountRefreshJournal(credential.accountId);
|
|
1083
1260
|
});
|
|
1084
1261
|
this.lastLoginError = undefined;
|
|
1085
1262
|
}
|
|
@@ -1296,53 +1473,116 @@ export class OpenAICodexAuth extends Service {
|
|
|
1296
1473
|
if (clearError)
|
|
1297
1474
|
this.lastLoginError = undefined;
|
|
1298
1475
|
}
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1476
|
+
resetCurrentAccountState(accountId) {
|
|
1477
|
+
this.setCredentialAccount(accountId);
|
|
1478
|
+
this.usageGeneration += 1;
|
|
1479
|
+
if (this.usageRefresh !== undefined)
|
|
1480
|
+
this.usageRefresh.queued = accountId !== undefined;
|
|
1481
|
+
this.usageCache = undefined;
|
|
1482
|
+
this.usageAccountId = undefined;
|
|
1483
|
+
this.directUsageAccountId = undefined;
|
|
1484
|
+
this.usageHasDirectDefault = false;
|
|
1485
|
+
this.usageError = undefined;
|
|
1486
|
+
this.lastLoginError = undefined;
|
|
1487
|
+
}
|
|
1488
|
+
async setCurrentAccount(accountId) {
|
|
1489
|
+
await this.withAccountRefreshLock(accountId, async () => {
|
|
1490
|
+
await this.reconcileAccountRefreshJournal(accountId);
|
|
1491
|
+
await withFileLock(this.filename, async () => {
|
|
1492
|
+
const document = (await readCredentialDocument(this.filename))?.document;
|
|
1493
|
+
if (document === undefined || !document.accounts.some(account => account.accountId === accountId)) {
|
|
1494
|
+
throw new AccountNotFoundError('OpenAI Codex account was not found');
|
|
1495
|
+
}
|
|
1496
|
+
if (document.currentAccountId !== accountId) {
|
|
1497
|
+
await this.assertCredentialWritable();
|
|
1498
|
+
await this.commitDocument({ ...document, currentAccountId: accountId });
|
|
1499
|
+
}
|
|
1500
|
+
this.resetCurrentAccountState(accountId);
|
|
1501
|
+
});
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
async logoutAttempt(accountId, expectedPromotion) {
|
|
1505
|
+
return withFileLock(this.filename, async () => {
|
|
1506
|
+
let parsed;
|
|
1303
1507
|
try {
|
|
1304
|
-
await this.
|
|
1508
|
+
parsed = await readCredentialDocument(this.filename);
|
|
1305
1509
|
}
|
|
1306
1510
|
catch (error) {
|
|
1307
|
-
|
|
1511
|
+
if (accountId !== undefined)
|
|
1512
|
+
throw error;
|
|
1513
|
+
const previous = await this.ctx.credentials.resolve(TOKEN_REF);
|
|
1308
1514
|
try {
|
|
1309
|
-
|
|
1515
|
+
if (previous !== undefined)
|
|
1516
|
+
await this.ctx.credentials.unset(TOKEN_REF);
|
|
1517
|
+
await unlink(this.filename);
|
|
1310
1518
|
}
|
|
1311
|
-
catch {
|
|
1312
|
-
return this.failAfterPublicationRollback(previous, undefined,
|
|
1519
|
+
catch (failure) {
|
|
1520
|
+
return this.failAfterPublicationRollback(previous, undefined, failure);
|
|
1313
1521
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
}
|
|
1317
|
-
if (current.value !== previous?.value)
|
|
1318
|
-
throw this.publicationChangedError(error);
|
|
1319
|
-
throw error;
|
|
1522
|
+
this.resetCurrentAccountState(undefined);
|
|
1523
|
+
return true;
|
|
1320
1524
|
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1525
|
+
const document = parsed?.document;
|
|
1526
|
+
const target = accountId ?? document?.currentAccountId ?? undefined;
|
|
1527
|
+
if (document === undefined || target === undefined
|
|
1528
|
+
|| !document.accounts.some(account => account.accountId === target)) {
|
|
1529
|
+
if (accountId !== undefined)
|
|
1530
|
+
throw new AccountNotFoundError('OpenAI Codex account was not found');
|
|
1531
|
+
return true;
|
|
1323
1532
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1533
|
+
const removedCurrent = document.currentAccountId === target;
|
|
1534
|
+
const targetIndex = document.accounts.findIndex(account => account.accountId === target);
|
|
1535
|
+
const accounts = document.accounts.filter(account => account.accountId !== target);
|
|
1536
|
+
const currentAccountId = removedCurrent
|
|
1537
|
+
? accounts[targetIndex]?.accountId ?? accounts[0]?.accountId ?? null
|
|
1538
|
+
: document.currentAccountId;
|
|
1539
|
+
const promotion = removedCurrent ? currentAccountId ?? undefined : undefined;
|
|
1540
|
+
if (promotion !== expectedPromotion)
|
|
1541
|
+
return false;
|
|
1542
|
+
if (removedCurrent)
|
|
1543
|
+
await this.assertCredentialWritable();
|
|
1544
|
+
await this.commitDocument({ version: 2, currentAccountId, accounts });
|
|
1545
|
+
await this.clearAccountRefreshJournal(target);
|
|
1546
|
+
if (removedCurrent)
|
|
1547
|
+
this.resetCurrentAccountState(currentAccountId ?? undefined);
|
|
1548
|
+
return true;
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
async logout(accountId) {
|
|
1552
|
+
if (accountId === undefined)
|
|
1553
|
+
await this.cancelLogin(true);
|
|
1554
|
+
while (true) {
|
|
1555
|
+
let target;
|
|
1556
|
+
let expectedPromotion;
|
|
1557
|
+
try {
|
|
1558
|
+
const document = (await readCredentialDocument(this.filename))?.document;
|
|
1559
|
+
target = accountId ?? document?.currentAccountId ?? undefined;
|
|
1560
|
+
if (document !== undefined && target !== undefined && document.currentAccountId === target) {
|
|
1561
|
+
const targetIndex = document.accounts.findIndex(account => account.accountId === target);
|
|
1562
|
+
const accounts = document.accounts.filter(account => account.accountId !== target);
|
|
1563
|
+
expectedPromotion = accounts[targetIndex]?.accountId ?? accounts[0]?.accountId;
|
|
1327
1564
|
}
|
|
1328
1565
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1566
|
+
catch { }
|
|
1567
|
+
const lockIds = [target, expectedPromotion].filter((value) => value !== undefined);
|
|
1568
|
+
const done = lockIds.length === 0
|
|
1569
|
+
? await this.logoutAttempt(accountId, undefined)
|
|
1570
|
+
: await this.withAccountRefreshLocks(lockIds, async () => {
|
|
1571
|
+
if (expectedPromotion !== undefined)
|
|
1572
|
+
await this.reconcileAccountRefreshJournal(expectedPromotion);
|
|
1573
|
+
return this.logoutAttempt(accountId, expectedPromotion);
|
|
1574
|
+
});
|
|
1575
|
+
if (done)
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1340
1578
|
}
|
|
1341
1579
|
async status(refresh, callbackUrl) {
|
|
1580
|
+
let document;
|
|
1342
1581
|
let credential;
|
|
1343
1582
|
let credentialError;
|
|
1344
1583
|
try {
|
|
1345
|
-
|
|
1584
|
+
document = (await readCredentialDocument(this.filename))?.document;
|
|
1585
|
+
credential = currentCredential(document);
|
|
1346
1586
|
}
|
|
1347
1587
|
catch (error) {
|
|
1348
1588
|
credentialError = messageOf(error);
|
|
@@ -1350,7 +1590,8 @@ export class OpenAICodexAuth extends Service {
|
|
|
1350
1590
|
if (credential !== undefined) {
|
|
1351
1591
|
try {
|
|
1352
1592
|
await this.bearerToken();
|
|
1353
|
-
|
|
1593
|
+
document = (await readCredentialDocument(this.filename))?.document ?? document;
|
|
1594
|
+
credential = currentCredential(document) ?? credential;
|
|
1354
1595
|
}
|
|
1355
1596
|
catch (error) {
|
|
1356
1597
|
this.usageError = messageOf(error);
|
|
@@ -1379,6 +1620,13 @@ export class OpenAICodexAuth extends Service {
|
|
|
1379
1620
|
return {
|
|
1380
1621
|
route: resolveCodexRouteStatus(this.routeConfig, this.ctx.get('llm')),
|
|
1381
1622
|
loggedIn: credential !== undefined,
|
|
1623
|
+
currentAccountId: document?.currentAccountId ?? null,
|
|
1624
|
+
accounts: (document?.accounts ?? []).map(account => ({
|
|
1625
|
+
accountId: account.accountId,
|
|
1626
|
+
...account.email === undefined ? {} : { email: account.email },
|
|
1627
|
+
expiresAt: account.expires,
|
|
1628
|
+
current: account.accountId === document?.currentAccountId,
|
|
1629
|
+
})),
|
|
1382
1630
|
loginPending: startingMethod !== undefined || flow !== undefined,
|
|
1383
1631
|
...startingMethod !== undefined ? { loginMethod: startingMethod } : flow === undefined ? {} : { loginMethod: flow.kind },
|
|
1384
1632
|
...this.lastLoginError === undefined ? {} : { loginError: this.lastLoginError },
|
|
@@ -1388,12 +1636,13 @@ export class OpenAICodexAuth extends Service {
|
|
|
1388
1636
|
...browser === undefined ? {} : { browser },
|
|
1389
1637
|
...credential === undefined ? {} : {
|
|
1390
1638
|
accountId: credential.accountId,
|
|
1639
|
+
...credential.email === undefined ? {} : { email: credential.email },
|
|
1391
1640
|
expiresAt: credential.expires,
|
|
1392
1641
|
...this.usageCache === undefined || this.usageAccountId !== credential.accountId
|
|
1393
1642
|
? {} : { usage: this.usageCache },
|
|
1394
1643
|
...this.responseUsage === undefined || this.responseUsage.accountId !== credential.accountId
|
|
1395
1644
|
? {} : { responseUsage: {
|
|
1396
|
-
|
|
1645
|
+
...this.responseUsage.metadata,
|
|
1397
1646
|
observedAt: this.responseUsage.observedAt,
|
|
1398
1647
|
} },
|
|
1399
1648
|
...this.usageError === undefined ? {} : { usageError: this.usageError },
|
|
@@ -1401,25 +1650,316 @@ export class OpenAICodexAuth extends Service {
|
|
|
1401
1650
|
csrf: this.csrf,
|
|
1402
1651
|
};
|
|
1403
1652
|
}
|
|
1404
|
-
|
|
1405
|
-
const
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1653
|
+
accountRefreshJournalFilename(accountId) {
|
|
1654
|
+
const key = createHash('sha256').update(accountId).digest('hex').slice(0, 24);
|
|
1655
|
+
return `${this.filename}.account-${key}-refresh.json`;
|
|
1656
|
+
}
|
|
1657
|
+
async readAccountRefreshJournal(accountId) {
|
|
1658
|
+
const filename = this.accountRefreshJournalFilename(accountId);
|
|
1659
|
+
let value;
|
|
1660
|
+
try {
|
|
1661
|
+
value = JSON.parse(await readFile(filename, 'utf8'));
|
|
1662
|
+
}
|
|
1663
|
+
catch (error) {
|
|
1664
|
+
if (error.code === 'ENOENT')
|
|
1665
|
+
return undefined;
|
|
1666
|
+
throw error;
|
|
1667
|
+
}
|
|
1668
|
+
const journal = value;
|
|
1669
|
+
if (journal === null || typeof journal !== 'object' || journal.version !== 1
|
|
1670
|
+
|| journal.accountId !== accountId || typeof journal.startedAt !== 'number'
|
|
1671
|
+
|| !Number.isFinite(journal.startedAt)
|
|
1672
|
+
|| (journal.phase !== 'pending' && journal.phase !== 'uncertain' && journal.phase !== 'refreshed')) {
|
|
1673
|
+
throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
|
|
1674
|
+
}
|
|
1675
|
+
const stored = validateCredential(journal.stored, filename);
|
|
1676
|
+
if (stored.accountId !== accountId)
|
|
1677
|
+
throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
|
|
1678
|
+
if (journal.phase === 'refreshed') {
|
|
1679
|
+
const refreshed = validateCredential(journal.refreshed, filename);
|
|
1680
|
+
if (refreshed.accountId !== accountId)
|
|
1681
|
+
throw new Error(`openai-codex-auth: invalid credential refresh journal ${filename}`);
|
|
1682
|
+
return { version: 1, accountId, startedAt: journal.startedAt, phase: 'refreshed', stored, refreshed };
|
|
1683
|
+
}
|
|
1684
|
+
return { version: 1, accountId, startedAt: journal.startedAt, phase: journal.phase, stored };
|
|
1685
|
+
}
|
|
1686
|
+
writeAccountRefreshJournal(journal) {
|
|
1687
|
+
return writeFileAtomic(this.accountRefreshJournalFilename(journal.accountId), JSON.stringify(journal), { mode: 0o600 });
|
|
1688
|
+
}
|
|
1689
|
+
async clearAccountRefreshJournal(accountId) {
|
|
1690
|
+
try {
|
|
1691
|
+
await unlink(this.accountRefreshJournalFilename(accountId));
|
|
1692
|
+
}
|
|
1693
|
+
catch (error) {
|
|
1694
|
+
if (error.code !== 'ENOENT')
|
|
1695
|
+
throw error;
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
async removeDeadFileLock(filename) {
|
|
1699
|
+
const lockFilename = `${filename}.lock`;
|
|
1700
|
+
let owner;
|
|
1701
|
+
try {
|
|
1702
|
+
const text = (await readFile(lockFilename, 'utf8')).trim();
|
|
1703
|
+
owner = Number(text);
|
|
1704
|
+
if (!Number.isSafeInteger(owner) || owner <= 0)
|
|
1705
|
+
return false;
|
|
1706
|
+
}
|
|
1707
|
+
catch (error) {
|
|
1708
|
+
return error.code === 'ENOENT';
|
|
1709
|
+
}
|
|
1710
|
+
try {
|
|
1711
|
+
process.kill(owner, 0);
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
catch (error) {
|
|
1715
|
+
if (error.code !== 'ESRCH')
|
|
1716
|
+
return false;
|
|
1717
|
+
try {
|
|
1718
|
+
await unlink(lockFilename);
|
|
1719
|
+
return true;
|
|
1720
|
+
}
|
|
1721
|
+
catch (unlinkError) {
|
|
1722
|
+
return unlinkError.code === 'ENOENT';
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
async withAccountRefreshLock(accountId, operation) {
|
|
1727
|
+
const filename = this.accountRefreshJournalFilename(accountId);
|
|
1728
|
+
await this.removeDeadFileLock(filename);
|
|
1729
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1730
|
+
try {
|
|
1731
|
+
return await withFileLock(filename, operation, { waitMs: ACCOUNT_REFRESH_LOCK_WAIT_MS });
|
|
1732
|
+
}
|
|
1733
|
+
catch (error) {
|
|
1734
|
+
if (!isFileLockTimeout(error) || attempt > 0 || !await this.removeDeadFileLock(filename))
|
|
1735
|
+
throw error;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
throw new Error('OpenAI credential refresh lock could not be acquired');
|
|
1739
|
+
}
|
|
1740
|
+
withAccountRefreshLocks(accountIds, operation) {
|
|
1741
|
+
const ordered = [...new Set(accountIds)].sort();
|
|
1742
|
+
const acquire = (index) => index === ordered.length
|
|
1743
|
+
? operation()
|
|
1744
|
+
: this.withAccountRefreshLock(ordered[index], () => acquire(index + 1));
|
|
1745
|
+
return acquire(0);
|
|
1746
|
+
}
|
|
1747
|
+
async reconcileAccountRefreshJournal(accountId) {
|
|
1748
|
+
const journal = await this.readAccountRefreshJournal(accountId);
|
|
1749
|
+
if (journal === undefined)
|
|
1750
|
+
return undefined;
|
|
1751
|
+
const latest = (await readCredentialDocument(this.filename))?.document.accounts
|
|
1752
|
+
.find(account => account.accountId === accountId);
|
|
1753
|
+
if (latest === undefined
|
|
1754
|
+
|| latest.access !== journal.stored.access
|
|
1755
|
+
|| latest.refresh !== journal.stored.refresh
|
|
1756
|
+
|| latest.expires !== journal.stored.expires) {
|
|
1757
|
+
await this.clearAccountRefreshJournal(accountId);
|
|
1758
|
+
return latest;
|
|
1759
|
+
}
|
|
1760
|
+
if (journal.phase !== 'refreshed')
|
|
1761
|
+
throw new CredentialRefreshUncertainError();
|
|
1762
|
+
try {
|
|
1763
|
+
const credential = await this.persistAccountUsageCredential(journal.stored, journal.refreshed);
|
|
1764
|
+
await this.clearAccountRefreshJournal(accountId);
|
|
1765
|
+
return credential;
|
|
1766
|
+
}
|
|
1767
|
+
catch (error) {
|
|
1768
|
+
if (error instanceof AccountNotFoundError)
|
|
1769
|
+
await this.clearAccountRefreshJournal(accountId);
|
|
1770
|
+
throw error;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
async refreshManagedAccount(stored, requireCurrent, force = false) {
|
|
1774
|
+
return this.withAccountRefreshLock(stored.accountId, async () => {
|
|
1775
|
+
const recovered = await this.reconcileAccountRefreshJournal(stored.accountId);
|
|
1776
|
+
if (recovered !== undefined)
|
|
1777
|
+
return recovered;
|
|
1778
|
+
const prepared = await withFileLock(this.filename, async () => {
|
|
1779
|
+
const document = (await readCredentialDocument(this.filename))?.document;
|
|
1780
|
+
const latest = document?.accounts.find(account => account.accountId === stored.accountId);
|
|
1781
|
+
if (document === undefined || latest === undefined)
|
|
1782
|
+
return undefined;
|
|
1783
|
+
if (requireCurrent && document.currentAccountId !== latest.accountId)
|
|
1784
|
+
return undefined;
|
|
1785
|
+
if (latest.access !== stored.access || latest.refresh !== stored.refresh || latest.expires !== stored.expires) {
|
|
1786
|
+
return { credential: latest, changed: true };
|
|
1787
|
+
}
|
|
1788
|
+
if ((force || latest.expires <= Date.now() + TOKEN_REFRESH_PREEMPT_MS)
|
|
1789
|
+
&& document.currentAccountId === latest.accountId)
|
|
1790
|
+
await this.assertCredentialWritable();
|
|
1791
|
+
return { credential: latest, changed: false };
|
|
1792
|
+
});
|
|
1793
|
+
if (prepared === undefined || prepared.changed
|
|
1794
|
+
|| (!force && prepared.credential.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS))
|
|
1795
|
+
return prepared?.credential;
|
|
1796
|
+
const credential = prepared.credential;
|
|
1797
|
+
const pending = {
|
|
1798
|
+
version: 1,
|
|
1799
|
+
accountId: credential.accountId,
|
|
1800
|
+
startedAt: Date.now(),
|
|
1801
|
+
phase: 'pending',
|
|
1802
|
+
stored: credential,
|
|
1803
|
+
};
|
|
1804
|
+
await this.writeAccountRefreshJournal(pending);
|
|
1805
|
+
let refreshed;
|
|
1806
|
+
try {
|
|
1807
|
+
refreshed = await refreshToken(credential, AbortSignal.timeout(TOKEN_REFRESH_REQUEST_TIMEOUT_MS));
|
|
1808
|
+
}
|
|
1809
|
+
catch (error) {
|
|
1810
|
+
if (isDefinitiveOAuthRefreshError(error)) {
|
|
1811
|
+
await this.clearAccountRefreshJournal(credential.accountId);
|
|
1812
|
+
if (!force && !isPermanentRefreshError(error) && credential.expires > Date.now())
|
|
1813
|
+
return credential;
|
|
1814
|
+
throw error;
|
|
1815
|
+
}
|
|
1816
|
+
await this.writeAccountRefreshJournal({ ...pending, phase: 'uncertain' });
|
|
1817
|
+
throw new CredentialRefreshUncertainError(error);
|
|
1818
|
+
}
|
|
1819
|
+
if (refreshed.accountId !== credential.accountId) {
|
|
1820
|
+
await this.writeAccountRefreshJournal({ ...pending, phase: 'uncertain' });
|
|
1821
|
+
throw new Error('OpenAI refresh changed the ChatGPT account identity');
|
|
1822
|
+
}
|
|
1823
|
+
await this.writeAccountRefreshJournal({ ...pending, phase: 'refreshed', refreshed });
|
|
1824
|
+
try {
|
|
1825
|
+
const persisted = await this.persistAccountUsageCredential(credential, refreshed);
|
|
1826
|
+
await this.clearAccountRefreshJournal(credential.accountId);
|
|
1827
|
+
return persisted;
|
|
1828
|
+
}
|
|
1829
|
+
catch (error) {
|
|
1830
|
+
if (error instanceof AccountNotFoundError)
|
|
1831
|
+
await this.clearAccountRefreshJournal(credential.accountId);
|
|
1832
|
+
throw error;
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
async persistAccountUsageCredential(stored, refreshed) {
|
|
1837
|
+
await this.removeDeadFileLock(this.filename);
|
|
1838
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1839
|
+
try {
|
|
1840
|
+
return await withFileLock(this.filename, async () => {
|
|
1841
|
+
const document = (await readCredentialDocument(this.filename))?.document;
|
|
1842
|
+
const latest = document?.accounts.find(account => account.accountId === stored.accountId);
|
|
1843
|
+
if (document === undefined || latest === undefined) {
|
|
1844
|
+
throw new AccountNotFoundError('OpenAI Codex account was removed while its quota was refreshing');
|
|
1845
|
+
}
|
|
1846
|
+
if (latest.access !== stored.access || latest.refresh !== stored.refresh || latest.expires !== stored.expires) {
|
|
1847
|
+
return latest;
|
|
1848
|
+
}
|
|
1849
|
+
const nextDocument = {
|
|
1850
|
+
...document,
|
|
1851
|
+
accounts: document.accounts.map(account => account.accountId === stored.accountId ? refreshed : account),
|
|
1852
|
+
};
|
|
1853
|
+
if (document.currentAccountId === stored.accountId) {
|
|
1854
|
+
await this.assertCredentialWritable();
|
|
1855
|
+
await this.commitDocument(nextDocument);
|
|
1856
|
+
}
|
|
1857
|
+
else {
|
|
1858
|
+
await this.write(nextDocument);
|
|
1859
|
+
}
|
|
1860
|
+
return refreshed;
|
|
1861
|
+
}, { waitMs: CREDENTIAL_REFRESH_PERSIST_WAIT_MS });
|
|
1862
|
+
}
|
|
1863
|
+
catch (error) {
|
|
1864
|
+
if (!isFileLockTimeout(error) || attempt > 0 || !await this.removeDeadFileLock(this.filename))
|
|
1865
|
+
throw error;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
throw new Error('OpenAI rotated credential could not be persisted');
|
|
1869
|
+
}
|
|
1870
|
+
refreshAccountUsageCredential(stored) {
|
|
1871
|
+
const active = this.accountUsageCredentialRefreshes.get(stored.accountId);
|
|
1872
|
+
if (active !== undefined)
|
|
1873
|
+
return active;
|
|
1874
|
+
const operation = this.refreshManagedAccount(stored, false).then((credential) => {
|
|
1875
|
+
if (credential === undefined) {
|
|
1876
|
+
throw new AccountNotFoundError('OpenAI Codex account was removed while its quota was refreshing');
|
|
1877
|
+
}
|
|
1878
|
+
return credential;
|
|
1416
1879
|
});
|
|
1880
|
+
let tracked;
|
|
1881
|
+
tracked = operation.finally(() => {
|
|
1882
|
+
if (this.accountUsageCredentialRefreshes.get(stored.accountId) === tracked) {
|
|
1883
|
+
this.accountUsageCredentialRefreshes.delete(stored.accountId);
|
|
1884
|
+
}
|
|
1885
|
+
});
|
|
1886
|
+
this.accountUsageCredentialRefreshes.set(stored.accountId, tracked);
|
|
1887
|
+
return tracked;
|
|
1888
|
+
}
|
|
1889
|
+
async accountUsageCredential(stored, signal) {
|
|
1890
|
+
if (stored.expires > Date.now() + TOKEN_REFRESH_PREEMPT_MS)
|
|
1891
|
+
return stored;
|
|
1892
|
+
return waitForSignal(this.refreshAccountUsageCredential(stored), signal);
|
|
1893
|
+
}
|
|
1894
|
+
async accountUsages(signal) {
|
|
1895
|
+
const prepared = await withFileLock(this.filename, async () => {
|
|
1896
|
+
const document = (await readCredentialDocument(this.filename))?.document;
|
|
1897
|
+
if (document === undefined)
|
|
1898
|
+
return undefined;
|
|
1899
|
+
return {
|
|
1900
|
+
cacheGeneration: this.usageGeneration,
|
|
1901
|
+
requestGeneration: ++this.accountUsageRequestGeneration,
|
|
1902
|
+
tasks: document.accounts.map(stored => ({
|
|
1903
|
+
accountId: stored.accountId,
|
|
1904
|
+
credential: this.accountUsageCredential(stored, signal),
|
|
1905
|
+
})),
|
|
1906
|
+
};
|
|
1907
|
+
});
|
|
1908
|
+
if (prepared === undefined)
|
|
1909
|
+
return [];
|
|
1910
|
+
const results = await Promise.all(prepared.tasks.map(async (task) => {
|
|
1911
|
+
try {
|
|
1912
|
+
const credential = await task.credential;
|
|
1913
|
+
return {
|
|
1914
|
+
accountId: task.accountId,
|
|
1915
|
+
usage: await this.fetchUsage(credential, boundedAccountUsageSignal(signal)),
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
catch (error) {
|
|
1919
|
+
return { accountId: task.accountId, error: messageOf(error) };
|
|
1920
|
+
}
|
|
1921
|
+
}));
|
|
1922
|
+
if (!signal?.aborted) {
|
|
1923
|
+
const currentAccountId = (await readCredentialDocument(this.filename))?.document.currentAccountId;
|
|
1924
|
+
const current = results.find(row => row.accountId === currentAccountId);
|
|
1925
|
+
if (current?.usage !== undefined
|
|
1926
|
+
&& this.usageGeneration === prepared.cacheGeneration
|
|
1927
|
+
&& this.accountUsageRequestGeneration === prepared.requestGeneration) {
|
|
1928
|
+
this.usageGeneration += 1;
|
|
1929
|
+
this.usageCache = current.usage;
|
|
1930
|
+
this.usageAccountId = currentAccountId ?? undefined;
|
|
1931
|
+
this.usageHasDirectDefault = false;
|
|
1932
|
+
this.usageError = undefined;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
return results;
|
|
1936
|
+
}
|
|
1937
|
+
async fetchUsage(credential, signal) {
|
|
1938
|
+
let response;
|
|
1939
|
+
try {
|
|
1940
|
+
response = await fetch(USAGE_URL, {
|
|
1941
|
+
redirect: 'error',
|
|
1942
|
+
...signal === undefined ? {} : { signal },
|
|
1943
|
+
headers: {
|
|
1944
|
+
accept: 'application/json',
|
|
1945
|
+
authorization: `Bearer ${credential.access}`,
|
|
1946
|
+
'chatgpt-account-id': credential.accountId,
|
|
1947
|
+
'user-agent': 'dsh-openai-codex-auth/0.5.0',
|
|
1948
|
+
},
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
catch (error) {
|
|
1952
|
+
if (signal?.aborted) {
|
|
1953
|
+
throw new Error(isTimeoutAbort(signal) ? 'Codex usage request timed out' : 'Codex usage request cancelled');
|
|
1954
|
+
}
|
|
1955
|
+
throw error;
|
|
1956
|
+
}
|
|
1417
1957
|
if (!response.ok)
|
|
1418
1958
|
throw new Error(`Codex usage request failed (HTTP ${response.status})`);
|
|
1419
1959
|
return normalizeUsage(await response.json());
|
|
1420
1960
|
}
|
|
1421
|
-
write(
|
|
1422
|
-
return writeFileAtomic(this.filename, `${JSON.stringify(
|
|
1961
|
+
write(document) {
|
|
1962
|
+
return writeFileAtomic(this.filename, `${JSON.stringify(document, null, 2)}\n`, {
|
|
1423
1963
|
mode: 0o600, dirMode: 0o700,
|
|
1424
1964
|
});
|
|
1425
1965
|
}
|
|
@@ -1466,6 +2006,31 @@ export class OpenAICodexAuth extends Service {
|
|
|
1466
2006
|
this.sendJson(res, 500, { error: messageOf(error) });
|
|
1467
2007
|
}
|
|
1468
2008
|
}
|
|
2009
|
+
async handleAccountUsage(req, res) {
|
|
2010
|
+
if (!this.trustedManagementRequest(req, res))
|
|
2011
|
+
return;
|
|
2012
|
+
if (req.method !== 'POST') {
|
|
2013
|
+
this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
if (!this.requireCsrf(req, res))
|
|
2017
|
+
return;
|
|
2018
|
+
const abort = new AbortController();
|
|
2019
|
+
const onAborted = () => { abort.abort(new Error('Account quota request cancelled')); };
|
|
2020
|
+
const observesAbort = typeof req.once === 'function' && typeof req.off === 'function';
|
|
2021
|
+
if (observesAbort)
|
|
2022
|
+
req.once('aborted', onAborted);
|
|
2023
|
+
try {
|
|
2024
|
+
this.sendJson(res, 200, { accounts: await this.accountUsages(abort.signal) });
|
|
2025
|
+
}
|
|
2026
|
+
catch (error) {
|
|
2027
|
+
this.sendJson(res, error instanceof CredentialNotWritableError ? 409 : 500, { error: messageOf(error) });
|
|
2028
|
+
}
|
|
2029
|
+
finally {
|
|
2030
|
+
if (observesAbort)
|
|
2031
|
+
req.off('aborted', onAborted);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
1469
2034
|
async handleDeviceStart(req, res) {
|
|
1470
2035
|
if (!this.trustedManagementRequest(req, res))
|
|
1471
2036
|
return;
|
|
@@ -1642,6 +2207,54 @@ export class OpenAICodexAuth extends Service {
|
|
|
1642
2207
|
this.sendJson(res, 500, { error: messageOf(error) });
|
|
1643
2208
|
}
|
|
1644
2209
|
}
|
|
2210
|
+
async accountIdBody(req) {
|
|
2211
|
+
const body = await readJsonBody(req);
|
|
2212
|
+
if (Object.keys(body).length !== 1 || typeof body.accountId !== 'string'
|
|
2213
|
+
|| body.accountId.length === 0 || body.accountId.length > 512) {
|
|
2214
|
+
throw new Error('The accountId field must identify one OpenAI Codex account.');
|
|
2215
|
+
}
|
|
2216
|
+
return body.accountId;
|
|
2217
|
+
}
|
|
2218
|
+
async handleSetCurrentAccount(req, res) {
|
|
2219
|
+
if (!this.trustedManagementRequest(req, res))
|
|
2220
|
+
return;
|
|
2221
|
+
if (req.method !== 'POST') {
|
|
2222
|
+
this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2225
|
+
if (!this.requireCsrf(req, res))
|
|
2226
|
+
return;
|
|
2227
|
+
try {
|
|
2228
|
+
await this.setCurrentAccount(await this.accountIdBody(req));
|
|
2229
|
+
this.sendJson(res, 200, { ok: true });
|
|
2230
|
+
}
|
|
2231
|
+
catch (error) {
|
|
2232
|
+
const status = error instanceof AccountNotFoundError
|
|
2233
|
+
? 404
|
|
2234
|
+
: error instanceof CredentialNotWritableError ? 409 : 400;
|
|
2235
|
+
this.sendJson(res, status, { error: messageOf(error) });
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
async handleAccountLogout(req, res) {
|
|
2239
|
+
if (!this.trustedManagementRequest(req, res))
|
|
2240
|
+
return;
|
|
2241
|
+
if (req.method !== 'POST') {
|
|
2242
|
+
this.sendJson(res, 405, { error: 'POST only' }, { allow: 'POST' });
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
if (!this.requireCsrf(req, res))
|
|
2246
|
+
return;
|
|
2247
|
+
try {
|
|
2248
|
+
await this.logout(await this.accountIdBody(req));
|
|
2249
|
+
this.sendJson(res, 200, { ok: true });
|
|
2250
|
+
}
|
|
2251
|
+
catch (error) {
|
|
2252
|
+
const status = error instanceof AccountNotFoundError
|
|
2253
|
+
? 404
|
|
2254
|
+
: error instanceof CredentialNotWritableError ? 409 : 400;
|
|
2255
|
+
this.sendJson(res, status, { error: messageOf(error) });
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
1645
2258
|
async handleLogout(req, res) {
|
|
1646
2259
|
if (!this.trustedManagementRequest(req, res))
|
|
1647
2260
|
return;
|
|
@@ -1656,7 +2269,7 @@ export class OpenAICodexAuth extends Service {
|
|
|
1656
2269
|
this.sendJson(res, 200, { ok: true });
|
|
1657
2270
|
}
|
|
1658
2271
|
catch (error) {
|
|
1659
|
-
this.sendJson(res, 500, { error: messageOf(error) });
|
|
2272
|
+
this.sendJson(res, error instanceof CredentialNotWritableError ? 409 : 500, { error: messageOf(error) });
|
|
1660
2273
|
}
|
|
1661
2274
|
}
|
|
1662
2275
|
}
|