@modelprofile.com/authswitch 6.5.0 → 7.0.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/00_commitinfo_data.js +1 -1
- package/dist_ts/authority-contract.d.ts +302 -6
- package/dist_ts/authority-contract.js +1 -1
- package/dist_ts/authority-runtime-contract.d.ts +13 -0
- package/dist_ts/authorityrehearsal.child.d.ts +1 -0
- package/dist_ts/authorityrehearsal.child.js +395 -0
- package/dist_ts/classes.accountlist.js +4 -3
- package/dist_ts/classes.authoritybackup.d.ts +134 -0
- package/dist_ts/classes.authoritybackup.js +769 -0
- package/dist_ts/classes.authoritybroker.d.ts +25 -5
- package/dist_ts/classes.authoritybroker.js +392 -123
- package/dist_ts/classes.authoritycli.d.ts +24 -0
- package/dist_ts/classes.authoritycli.js +675 -0
- package/dist_ts/classes.authorityclient.d.ts +19 -7
- package/dist_ts/classes.authorityclient.js +51 -14
- package/dist_ts/classes.authoritydaemon.d.ts +28 -1
- package/dist_ts/classes.authoritydaemon.js +482 -36
- package/dist_ts/classes.authoritydatabase.d.ts +175 -10
- package/dist_ts/classes.authoritydatabase.js +1100 -52
- package/dist_ts/classes.authoritymodels.d.ts +223 -1
- package/dist_ts/classes.authoritymodels.js +798 -9
- package/dist_ts/classes.authoritypreuse.d.ts +47 -0
- package/dist_ts/classes.authoritypreuse.js +278 -0
- package/dist_ts/classes.authorityregistry.d.ts +24 -0
- package/dist_ts/classes.authorityregistry.js +88 -0
- package/dist_ts/classes.authorityrehearsal.d.ts +127 -0
- package/dist_ts/classes.authorityrehearsal.js +632 -0
- package/dist_ts/classes.authorityusage.d.ts +160 -0
- package/dist_ts/classes.authorityusage.js +429 -0
- package/dist_ts/classes.claudeauthority.d.ts +66 -0
- package/dist_ts/classes.claudeauthority.js +532 -0
- package/dist_ts/classes.claudecodeharness.js +3 -2
- package/dist_ts/classes.claudecodelocks.d.ts +13 -0
- package/dist_ts/classes.claudecodelocks.js +75 -3
- package/dist_ts/classes.claudenative.d.ts +137 -0
- package/dist_ts/classes.claudenative.js +731 -0
- package/dist_ts/classes.claudestatus.d.ts +9 -0
- package/dist_ts/classes.claudestatus.js +26 -9
- package/dist_ts/classes.claudetokenrefresh.d.ts +15 -1
- package/dist_ts/classes.claudetokenrefresh.js +74 -21
- package/dist_ts/classes.codexharness.js +4 -2
- package/dist_ts/classes.codexmanaged.d.ts +11 -0
- package/dist_ts/classes.codexmanaged.js +75 -14
- package/dist_ts/classes.codexpreuse.d.ts +8 -0
- package/dist_ts/classes.codexpreuse.js +24 -6
- package/dist_ts/classes.fileharness.d.ts +2 -0
- package/dist_ts/classes.fileharness.js +6 -4
- package/dist_ts/classes.limits.js +12 -7
- package/dist_ts/classes.opencodeharness.js +2 -2
- package/dist_ts/claudehttp.d.ts +10 -0
- package/dist_ts/claudehttp.js +29 -3
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +7 -1
- package/dist_ts/interfaces.harness.d.ts +6 -0
- package/dist_ts/interfaces.list.d.ts +9 -3
- package/dist_ts/plugins.d.ts +5 -2
- package/dist_ts/plugins.js +7 -3
- package/dist_ts/ts_migration/0001_authority_meta.d.ts +3 -0
- package/dist_ts/ts_migration/0001_authority_meta.js +32 -0
- package/dist_ts/ts_migration/index.d.ts +3 -0
- package/dist_ts/ts_migration/index.js +6 -0
- package/package.json +4 -1
- package/readme.md +181 -14
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +264 -9
- package/ts/authority-runtime-contract.ts +14 -0
- package/ts/authorityrehearsal.child.ts +330 -0
- package/ts/classes.accountlist.ts +3 -2
- package/ts/classes.authoritybackup.ts +780 -0
- package/ts/classes.authoritybroker.ts +389 -121
- package/ts/classes.authoritycli.ts +719 -0
- package/ts/classes.authorityclient.ts +75 -18
- package/ts/classes.authoritydaemon.ts +449 -36
- package/ts/classes.authoritydatabase.ts +1147 -55
- package/ts/classes.authoritymodels.ts +541 -6
- package/ts/classes.authoritypreuse.ts +297 -0
- package/ts/classes.authorityregistry.ts +118 -0
- package/ts/classes.authorityrehearsal.ts +625 -0
- package/ts/classes.authorityusage.ts +527 -0
- package/ts/classes.claudeauthority.ts +541 -0
- package/ts/classes.claudecodeharness.ts +2 -1
- package/ts/classes.claudecodelocks.ts +70 -1
- package/ts/classes.claudenative.ts +816 -0
- package/ts/classes.claudestatus.ts +38 -9
- package/ts/classes.claudetokenrefresh.ts +82 -19
- package/ts/classes.codexharness.ts +3 -1
- package/ts/classes.codexmanaged.ts +75 -12
- package/ts/classes.codexpreuse.ts +33 -5
- package/ts/classes.fileharness.ts +7 -3
- package/ts/classes.limits.ts +11 -6
- package/ts/classes.opencodeharness.ts +1 -1
- package/ts/claudehttp.ts +31 -2
- package/ts/index.ts +6 -0
- package/ts/interfaces.harness.ts +6 -0
- package/ts/interfaces.list.ts +9 -3
- package/ts/plugins.ts +7 -2
- package/ts/ts_migration/0001_authority_meta.ts +33 -0
- package/ts/ts_migration/index.ts +7 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
-
import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding,
|
|
2
|
+
import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
|
|
3
|
+
IAuthSwitchOperation, IAuthSwitchSnapshot } from './authority-contract.js';
|
|
3
4
|
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
4
|
-
import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant
|
|
5
|
+
import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
|
|
6
|
+
IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
|
|
7
|
+
import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
|
|
5
8
|
import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
6
9
|
|
|
7
10
|
type TOpenAiCredential = plugins.flexAccounts.IOpenAiChatGptOAuthCredential;
|
|
8
|
-
type TOpenAiProvider = Pick<plugins.flexAccounts.ISmartAiProviderAdapter,
|
|
11
|
+
type TOpenAiProvider = Pick<plugins.flexAccounts.ISmartAiProviderAdapter,
|
|
12
|
+
'beginLogin' | 'refreshCredential' | 'inspectCredential' | 'getAccountRateLimits' | 'dispose'>;
|
|
9
13
|
|
|
10
14
|
export interface IAuthSwitchAuthorityBrokerOptions {
|
|
11
15
|
provider?: TOpenAiProvider;
|
|
@@ -23,8 +27,19 @@ export interface IAuthSwitchResolvedAccess {
|
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
const idHash = (...values: string[]): string => plugins.crypto.createHash('sha256').update(JSON.stringify(values)).digest('hex');
|
|
30
|
+
export const authSwitchBindingCapabilityHash = (capability: string): string => idHash(capability);
|
|
26
31
|
const isId = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
32
|
+
const sameUsageAuthority = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsageContext): boolean =>
|
|
33
|
+
left.epoch === right.epoch && left.accountId === right.accountId && left.loginId === right.loginId
|
|
34
|
+
&& left.subjectId === right.subjectId && left.workspaceId === right.workspaceId
|
|
35
|
+
&& left.providerId === right.providerId
|
|
36
|
+
&& left.providerId === 'openai' && left.purpose === 'openai_managed'
|
|
37
|
+
&& left.owner === 'daemon' && left.purpose === right.purpose && left.owner === right.owner
|
|
38
|
+
&& !left.removed && !right.removed
|
|
39
|
+
&& left.authorizationGeneration === right.authorizationGeneration;
|
|
27
40
|
const isUuid = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
|
|
41
|
+
const deviceOperationTerminal = (operation: IStoredAuthorityDeviceOperation): boolean =>
|
|
42
|
+
!['starting', 'pending', 'committing'].includes(operation.state);
|
|
28
43
|
const safeLabel = (value: unknown): value is string => typeof value === 'string' && value.trim() === value
|
|
29
44
|
&& value.length > 0 && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
30
45
|
const safeScope = (value: unknown, maximum: number): value is string => typeof value === 'string'
|
|
@@ -35,26 +50,54 @@ const tokenExpiry = (credential: TOpenAiCredential): string | null => {
|
|
|
35
50
|
catch { return null; }
|
|
36
51
|
};
|
|
37
52
|
const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
|
|
38
|
-
const publicAccount = (account: IStoredAuthorityAccount
|
|
53
|
+
const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
|
|
39
54
|
id: account.id, providerId: account.providerId, label: account.label, email: account.email,
|
|
40
|
-
plan: account.plan,
|
|
41
|
-
: grant?.state === 'exchange_may_have_been_sent' ? 'refreshing' : grant?.state ?? 'needs_reauth',
|
|
42
|
-
owner: grant?.owner ?? 'none', grantGeneration: grant?.grantGeneration ?? 0, revision: account.revision,
|
|
43
|
-
accessExpiresAt: grant?.accessExpiresAt ?? null, retryAt: grant?.retryAt ?? null, problem: grant?.problem ?? 'none',
|
|
55
|
+
plan: account.plan, removed: account.removed, revision: account.revision,
|
|
44
56
|
statusObservedAt: account.statusObservedAt,
|
|
45
57
|
});
|
|
58
|
+
const publicLogin = (grant: IStoredAuthorityGrant): IAuthSwitchLogin => ({
|
|
59
|
+
id: grant.id, accountId: grant.accountId, providerId: grant.providerId, purpose: grant.purpose,
|
|
60
|
+
owner: grant.owner,
|
|
61
|
+
health: grant.state === 'exchange_may_have_been_sent' ? 'refreshing'
|
|
62
|
+
: grant.state === 'native' ? 'unverified'
|
|
63
|
+
: grant.state === 'legacy_native_pending' || grant.state === 'handoff_pending' ? 'pending_handoff' : grant.state,
|
|
64
|
+
problem: grant.problem, grantGeneration: grant.grantGeneration, revision: grant.revision,
|
|
65
|
+
accessExpiresAt: grant.accessExpiresAt, retryAt: grant.retryAt,
|
|
66
|
+
statusObservedAt: grant.statusObservedAt,
|
|
67
|
+
capabilities: {
|
|
68
|
+
canReauthenticate: grant.purpose === 'openai_managed' && grant.owner === 'daemon'
|
|
69
|
+
&& grant.id === openAiGrantId(grant.accountId)
|
|
70
|
+
&& !['removed', 'exchange_may_have_been_sent'].includes(grant.state),
|
|
71
|
+
canBind: grant.purpose === 'openai_managed' && grant.owner === 'daemon'
|
|
72
|
+
&& grant.id === openAiGrantId(grant.accountId) && grant.state === 'ready',
|
|
73
|
+
canPreuse: grant.providerId === 'openai' && grant.purpose === 'openai_managed' && grant.owner === 'daemon'
|
|
74
|
+
&& grant.id === openAiGrantId(grant.accountId) && ['ready', 'retry_wait'].includes(grant.state),
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const publicOperation = (stored: IStoredAuthorityDeviceOperation): IAuthSwitchOperation => ({
|
|
78
|
+
id: stored.id, kind: stored.kind, purpose: stored.purpose,
|
|
79
|
+
targetAccountId: stored.accountId, targetLoginId: stored.grantId, state: stored.state,
|
|
80
|
+
prompt: stored.prompt, result: stored.result ? {
|
|
81
|
+
accountId: stored.result.accountId, loginId: stored.result.grantId,
|
|
82
|
+
accountRevision: stored.result.accountRevision, loginGeneration: stored.result.grantGeneration,
|
|
83
|
+
} : null,
|
|
84
|
+
error: stored.error, createdAt: stored.createdAt, updatedAt: stored.updatedAt,
|
|
85
|
+
finishedAt: stored.finishedAt, revision: stored.revision,
|
|
86
|
+
});
|
|
46
87
|
const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding => ({
|
|
47
88
|
id: binding.id, accountId: binding.accountId, runtime: binding.runtime,
|
|
48
89
|
scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
|
|
49
90
|
});
|
|
50
91
|
|
|
51
92
|
interface IManagedOperation {
|
|
52
|
-
|
|
93
|
+
id: string;
|
|
53
94
|
handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
|
|
54
95
|
task: Promise<void>;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface IStartingOperation {
|
|
99
|
+
requestKey: string;
|
|
100
|
+
promise: Promise<IAuthSwitchOperation>;
|
|
58
101
|
}
|
|
59
102
|
|
|
60
103
|
/** One daemon-owned authority. No management DTO contains a refresh grant. */
|
|
@@ -63,11 +106,15 @@ export class AuthSwitchAuthorityBroker {
|
|
|
63
106
|
private readonly secrets: IAuthSwitchSecretCodec;
|
|
64
107
|
private readonly now: () => number;
|
|
65
108
|
private readonly proactive: boolean;
|
|
109
|
+
private readonly starts = new Map<string, IStartingOperation>();
|
|
66
110
|
private readonly operations = new Map<string, IManagedOperation>();
|
|
67
111
|
private readonly refreshes = new Map<string, Promise<void>>();
|
|
68
112
|
private readonly listeners = new Set<() => void>();
|
|
113
|
+
private claudeRefresh?: (grantId: string) => Promise<void>;
|
|
114
|
+
private maintenance?: Promise<void>;
|
|
69
115
|
private timer?: NodeJS.Timeout;
|
|
70
116
|
private closed = false;
|
|
117
|
+
private closing?: Promise<void>;
|
|
71
118
|
|
|
72
119
|
constructor(private readonly database: AuthSwitchAuthorityDatabase, options: IAuthSwitchAuthorityBrokerOptions = {}) {
|
|
73
120
|
this.provider = options.provider ?? new plugins.flexAccounts.OpenAiProviderAdapter();
|
|
@@ -76,7 +123,15 @@ export class AuthSwitchAuthorityBroker {
|
|
|
76
123
|
this.proactive = options.proactive ?? true;
|
|
77
124
|
}
|
|
78
125
|
|
|
126
|
+
public setClaudeRefresher(refresh: (grantId: string) => Promise<void>): void {
|
|
127
|
+
if (this.claudeRefresh) throw new Error('Claude refresh owner is already configured.');
|
|
128
|
+
this.claudeRefresh = refresh;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public notifyChanged(): void { this.publish(); }
|
|
132
|
+
|
|
79
133
|
public async start(): Promise<void> {
|
|
134
|
+
await this.database.assertOpenAdmission();
|
|
80
135
|
const probe = plugins.crypto.randomBytes(32);
|
|
81
136
|
const probeId = idHash('authswitch-custody-probe-v1');
|
|
82
137
|
try {
|
|
@@ -88,6 +143,28 @@ export class AuthSwitchAuthorityBroker {
|
|
|
88
143
|
}
|
|
89
144
|
} finally { opened.fill(0); }
|
|
90
145
|
} finally { probe.fill(0); }
|
|
146
|
+
for (const state of ['starting', 'pending', 'committing'] as const) {
|
|
147
|
+
let after: string | null = null;
|
|
148
|
+
while (true) {
|
|
149
|
+
const page = await this.database.operationsByState(state, after);
|
|
150
|
+
if (!page.length) break;
|
|
151
|
+
for (const operation of page) {
|
|
152
|
+
const now = new Date(this.now()).toISOString();
|
|
153
|
+
const updateId = plugins.crypto.randomUUID();
|
|
154
|
+
await this.database.changeOperation(updateId, operation.id, current => {
|
|
155
|
+
if (current.state !== state) throw new Error('Device operation recovery changed concurrently.');
|
|
156
|
+
return { ...current, state: state === 'committing' ? 'outcome_unknown' : 'interrupted',
|
|
157
|
+
prompt: null, result: null,
|
|
158
|
+
error: state === 'committing'
|
|
159
|
+
? 'Sign-in completed but its account result could not be confirmed. Check the login before retrying.'
|
|
160
|
+
: 'Sign-in was interrupted by authority restart; no account change was committed.',
|
|
161
|
+
updatedAt: now, finishedAt: now, revision: current.revision + 1,
|
|
162
|
+
updateId };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
after = page.at(-1)!.id;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
91
168
|
let after: string | null = null;
|
|
92
169
|
while (true) {
|
|
93
170
|
const page = await this.database.grantsByState('exchange_may_have_been_sent', after);
|
|
@@ -109,24 +186,31 @@ export class AuthSwitchAuthorityBroker {
|
|
|
109
186
|
private schedule(): void {
|
|
110
187
|
if (this.closed) return;
|
|
111
188
|
this.timer = setTimeout(() => {
|
|
112
|
-
|
|
189
|
+
const task = this.refreshDueAccounts().catch(() => {
|
|
113
190
|
process.stderr.write('Authswitch proactive refresh scan failed; account status may be stale.\n');
|
|
114
|
-
}).finally(() =>
|
|
191
|
+
}).finally(() => {
|
|
192
|
+
if (this.maintenance === task) this.maintenance = undefined;
|
|
193
|
+
this.schedule();
|
|
194
|
+
});
|
|
195
|
+
this.maintenance = task;
|
|
115
196
|
}, 30_000);
|
|
116
197
|
this.timer.unref();
|
|
117
198
|
}
|
|
118
199
|
|
|
119
200
|
public async refreshDueAccounts(): Promise<void> {
|
|
120
201
|
if (this.closed) return;
|
|
202
|
+
await this.database.assertOpenAdmission();
|
|
121
203
|
for (const state of ['ready', 'retry_wait'] as const) {
|
|
122
204
|
let after: string | null = null;
|
|
123
205
|
while (true) {
|
|
124
|
-
|
|
206
|
+
const page = await this.database.grantsByState(state, after);
|
|
125
207
|
if (!page.length) break;
|
|
126
|
-
await Promise.allSettled(page.filter(grant => grant.
|
|
127
|
-
&& grant.owner === 'daemon' && this.isDue(grant, 0)
|
|
208
|
+
await Promise.allSettled(page.filter(grant => grant.owner === 'daemon' && this.isDue(grant, 0)
|
|
128
209
|
&& (grant.state !== 'retry_wait' || (grant.retryAt !== null && Date.parse(grant.retryAt) <= this.now())))
|
|
129
|
-
.
|
|
210
|
+
.flatMap(grant => grant.purpose === 'openai_managed'
|
|
211
|
+
? [this.refreshAccount(grant.accountId, 0)]
|
|
212
|
+
: grant.purpose === 'claude_host_native' && this.claudeRefresh
|
|
213
|
+
? [this.claudeRefresh(grant.id)] : []));
|
|
130
214
|
after = page.at(-1)!.id;
|
|
131
215
|
}
|
|
132
216
|
}
|
|
@@ -141,16 +225,19 @@ export class AuthSwitchAuthorityBroker {
|
|
|
141
225
|
for (const listener of this.listeners) listener();
|
|
142
226
|
}
|
|
143
227
|
|
|
144
|
-
public async snapshot(options: { accountAfter?: string; bindingAfter?: string; limit?: number } = {}): Promise<IAuthSwitchSnapshot> {
|
|
228
|
+
public async snapshot(options: { accountAfter?: string; loginAfter?: string; bindingAfter?: string; limit?: number } = {}): Promise<IAuthSwitchSnapshot> {
|
|
145
229
|
if ((options.accountAfter !== undefined && !isId(options.accountAfter))
|
|
230
|
+
|| (options.loginAfter !== undefined && !isId(options.loginAfter))
|
|
146
231
|
|| (options.bindingAfter !== undefined && !isId(options.bindingAfter))) throw new Error('Invalid account snapshot cursor.');
|
|
147
|
-
const page = await this.database.page(options.accountAfter ?? null, options.
|
|
148
|
-
|
|
232
|
+
const page = await this.database.page(options.accountAfter ?? null, options.loginAfter ?? null,
|
|
233
|
+
options.bindingAfter ?? null, options.limit ?? 128);
|
|
234
|
+
return { schemaVersion: 2, epoch: page.meta.epoch, revision: page.meta.revision,
|
|
149
235
|
generatedAt: new Date(this.now()).toISOString(),
|
|
150
|
-
accounts: page.accounts.filter(
|
|
151
|
-
|
|
236
|
+
accounts: page.accounts.filter(account => !account.removed).map(publicAccount),
|
|
237
|
+
logins: page.grants.filter(grant => grant.state !== 'removed').map(publicLogin),
|
|
152
238
|
bindings: page.bindings.map(publicBinding),
|
|
153
|
-
nextAccountCursor: page.nextAccountCursor,
|
|
239
|
+
nextAccountCursor: page.nextAccountCursor, nextLoginCursor: page.nextGrantCursor,
|
|
240
|
+
nextBindingCursor: page.nextBindingCursor };
|
|
154
241
|
}
|
|
155
242
|
|
|
156
243
|
public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
|
|
@@ -233,113 +320,236 @@ export class AuthSwitchAuthorityBroker {
|
|
|
233
320
|
finally { bytes.fill(0); }
|
|
234
321
|
}
|
|
235
322
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (!
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
323
|
+
private startDeviceOperation(operationId: string, requestKey: string,
|
|
324
|
+
start: () => Promise<IAuthSwitchOperation>): Promise<IAuthSwitchOperation> {
|
|
325
|
+
if (!isUuid(operationId)) return Promise.reject(new Error('Invalid operation ID.'));
|
|
326
|
+
if (this.closed) return Promise.reject(new Error('Authswitch authority is closing.'));
|
|
327
|
+
const existing = this.starts.get(operationId);
|
|
328
|
+
if (existing) {
|
|
329
|
+
if (existing.requestKey !== requestKey) {
|
|
330
|
+
return Promise.reject(new Error('Account operation ID belongs to a different request.'));
|
|
331
|
+
}
|
|
332
|
+
return existing.promise;
|
|
333
|
+
}
|
|
334
|
+
const promise = start();
|
|
335
|
+
this.starts.set(operationId, { requestKey, promise });
|
|
336
|
+
void promise.finally(() => {
|
|
337
|
+
if (this.starts.get(operationId)?.promise === promise) this.starts.delete(operationId);
|
|
338
|
+
}).catch(() => {});
|
|
339
|
+
return promise;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async finishStartingOperation(operationId: string, state: 'failed' | 'interrupted',
|
|
343
|
+
error: string): Promise<IStoredAuthorityDeviceOperation> {
|
|
344
|
+
const current = await this.database.readOperation(operationId);
|
|
345
|
+
if (!current || current.kind === 'preuse_openai') throw new Error('Account operation was not found.');
|
|
346
|
+
if (deviceOperationTerminal(current)) return current;
|
|
347
|
+
if (current.state !== 'starting') throw new Error('Account operation already started its provider login.');
|
|
348
|
+
const updateId = plugins.crypto.randomUUID();
|
|
349
|
+
const now = new Date(this.now()).toISOString();
|
|
350
|
+
try {
|
|
351
|
+
return await this.database.changeOperation(updateId, operationId, operation => ({ ...operation,
|
|
352
|
+
state, error, updatedAt: now, finishedAt: now, revision: operation.revision + 1, updateId }));
|
|
353
|
+
} catch (changeError) {
|
|
354
|
+
const changed = await this.database.readOperation(operationId);
|
|
355
|
+
if (changed && changed.kind !== 'preuse_openai' && deviceOperationTerminal(changed)) return changed;
|
|
356
|
+
throw changeError;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
public beginAddOpenAi(operationId: string): Promise<IAuthSwitchOperation> {
|
|
361
|
+
return this.startDeviceOperation(operationId, 'add_openai', () => this.beginLogin(operationId, null));
|
|
243
362
|
}
|
|
244
363
|
|
|
245
|
-
|
|
364
|
+
public beginReauthOpenAi(operationId: string, accountId: string, loginId: string,
|
|
365
|
+
purpose: 'openai_managed', beforeNew?: () => Promise<void>): Promise<IAuthSwitchOperation> {
|
|
366
|
+
if (!isId(accountId) || !isId(loginId) || purpose !== 'openai_managed') {
|
|
367
|
+
return Promise.reject(new Error('Invalid login target.'));
|
|
368
|
+
}
|
|
369
|
+
const requestKey = JSON.stringify(['reauth_openai', accountId, loginId, purpose]);
|
|
370
|
+
return this.startDeviceOperation(operationId, requestKey,
|
|
371
|
+
() => this.beginLogin(operationId, { accountId, grantId: loginId }, beforeNew));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async beginLogin(operationId: string, target: { accountId: string; grantId: string } | null,
|
|
375
|
+
beforeNew?: () => Promise<void>): Promise<IAuthSwitchOperation> {
|
|
246
376
|
if (this.closed) throw new Error('Authswitch authority is closing.');
|
|
247
|
-
this.
|
|
248
|
-
|
|
249
|
-
|
|
377
|
+
const now = new Date(this.now()).toISOString();
|
|
378
|
+
const admitted = await this.database.createOperation({ id: operationId, kind: target ? 'reauth_openai' : 'add_openai',
|
|
379
|
+
purpose: 'openai_managed', accountId: target?.accountId ?? null, grantId: target?.grantId ?? null,
|
|
380
|
+
state: 'starting', prompt: null, result: null, error: null, commitId: null,
|
|
381
|
+
createdAt: now, updatedAt: now, finishedAt: null,
|
|
382
|
+
revision: 1, updateId: plugins.crypto.randomUUID() });
|
|
383
|
+
if (!admitted.created) return publicOperation(admitted.operation);
|
|
384
|
+
const id = operationId;
|
|
385
|
+
try {
|
|
386
|
+
if (this.closed) throw new Error('Authswitch authority is closing.');
|
|
387
|
+
if (this.operations.size >= 32) throw new Error('Too many pending device logins.');
|
|
388
|
+
await beforeNew?.();
|
|
389
|
+
if (this.closed) throw new Error('Authswitch authority is closing.');
|
|
390
|
+
await this.database.confirmOperationMayStart(id);
|
|
391
|
+
if (this.closed) throw new Error('Authswitch authority is closing.');
|
|
392
|
+
} catch (error) {
|
|
393
|
+
const terminal = await this.finishStartingOperation(id, this.closed ? 'interrupted' : 'failed',
|
|
394
|
+
this.closed ? 'Sign-in was interrupted; no account change was committed.'
|
|
395
|
+
: 'Device sign-in preparation failed; no provider login was started.');
|
|
396
|
+
if (terminal.state === 'cancelled') return publicOperation(terminal);
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
let handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
|
|
400
|
+
try {
|
|
401
|
+
handle = await this.provider.beginLogin({ flow: 'device' });
|
|
402
|
+
} catch {
|
|
403
|
+
return publicOperation(await this.finishStartingOperation(id, this.closed ? 'interrupted' : 'failed',
|
|
404
|
+
this.closed ? 'Sign-in was interrupted; no account change was committed.'
|
|
405
|
+
: 'Device sign-in could not start.'));
|
|
406
|
+
}
|
|
407
|
+
void handle.completion.catch(() => {});
|
|
408
|
+
let current: IStoredAuthorityDeviceOperation;
|
|
409
|
+
try {
|
|
410
|
+
const stored = await this.database.readOperation(id);
|
|
411
|
+
if (!stored || stored.kind === 'preuse_openai') throw new Error('Account operation was not found.');
|
|
412
|
+
current = stored;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
await Promise.allSettled([handle.cancel(), handle.close()]);
|
|
415
|
+
throw error;
|
|
250
416
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
417
|
+
if (this.closed || current.state !== 'starting') {
|
|
418
|
+
await Promise.allSettled([handle.cancel(), handle.close()]);
|
|
419
|
+
const terminal = this.closed && current.state === 'starting'
|
|
420
|
+
? await this.finishStartingOperation(id, 'interrupted',
|
|
421
|
+
'Sign-in was interrupted; no account change was committed.') : current;
|
|
422
|
+
return publicOperation(terminal);
|
|
423
|
+
}
|
|
424
|
+
const prompt = handle.prompt;
|
|
425
|
+
if (prompt.flow !== 'device') {
|
|
426
|
+
await Promise.allSettled([handle.cancel(), handle.close()]);
|
|
427
|
+
return publicOperation(await this.finishStartingOperation(id, 'failed', 'Device sign-in could not start.'));
|
|
428
|
+
}
|
|
429
|
+
const operation: IManagedOperation = { id, handle, task: Promise.resolve() };
|
|
430
|
+
this.operations.set(id, operation);
|
|
431
|
+
let pending: IStoredAuthorityDeviceOperation;
|
|
432
|
+
try {
|
|
433
|
+
const updateId = plugins.crypto.randomUUID();
|
|
434
|
+
pending = await this.database.changeOperation(updateId, id, current => ({ ...current,
|
|
435
|
+
state: 'pending', prompt: { flow: 'device', verificationUrl: prompt.verificationUrl,
|
|
436
|
+
userCode: prompt.userCode }, updatedAt: new Date(this.now()).toISOString(),
|
|
437
|
+
revision: current.revision + 1, updateId }));
|
|
438
|
+
} catch (error) {
|
|
439
|
+
await Promise.allSettled([handle.cancel(), handle.close()]);
|
|
440
|
+
if (this.operations.get(id) === operation) this.operations.delete(id);
|
|
441
|
+
const current = await this.database.readOperation(id);
|
|
442
|
+
if (current && current.kind !== 'preuse_openai' && deviceOperationTerminal(current)) return publicOperation(current);
|
|
443
|
+
throw error;
|
|
255
444
|
}
|
|
256
|
-
const publicOperation: IAuthSwitchOperation = { id: plugins.crypto.randomUUID(), state: 'pending',
|
|
257
|
-
prompt: { flow: 'device', verificationUrl: handle.prompt.verificationUrl, userCode: handle.prompt.userCode },
|
|
258
|
-
account: null, error: null };
|
|
259
|
-
const operation: IManagedOperation = { public: publicOperation, handle, task: Promise.resolve(),
|
|
260
|
-
cancelled: false, committing: false, finishedAt: null };
|
|
261
|
-
this.operations.set(publicOperation.id, operation);
|
|
262
445
|
operation.task = handle.completion.then(async result => {
|
|
263
|
-
if (
|
|
264
|
-
operation.committing = true;
|
|
446
|
+
if (this.closed) throw new Error('Authority closed during sign-in.');
|
|
265
447
|
const identity = this.identity(result.credential);
|
|
266
|
-
if (
|
|
448
|
+
if (target && identity.id !== target.accountId) throw new Error('The device login selected a different OpenAI account.');
|
|
267
449
|
const grantId = openAiGrantId(identity.id);
|
|
450
|
+
if (target && grantId !== target.grantId) throw new Error('The device login selected a different OpenAI login.');
|
|
451
|
+
const commitId = plugins.crypto.randomUUID();
|
|
452
|
+
try {
|
|
453
|
+
const markerUpdateId = plugins.crypto.randomUUID();
|
|
454
|
+
await this.database.changeOperation(markerUpdateId, id, current => {
|
|
455
|
+
if (current.state !== 'pending') throw new Error('Device operation is no longer pending.');
|
|
456
|
+
return { ...current, state: 'committing', prompt: null, commitId,
|
|
457
|
+
updatedAt: new Date(this.now()).toISOString(), revision: current.revision + 1,
|
|
458
|
+
updateId: markerUpdateId };
|
|
459
|
+
});
|
|
460
|
+
} catch (error) {
|
|
461
|
+
const current = await this.database.readOperation(id);
|
|
462
|
+
if (current?.state === 'cancelled' || current?.state === 'interrupted') return;
|
|
463
|
+
throw error;
|
|
464
|
+
}
|
|
268
465
|
const ciphertext = await this.sealCredential(grantId, result.credential);
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (
|
|
273
|
-
if (targetId !== null && (!existing || existing.removed)) throw new Error('Target account was removed during login.');
|
|
466
|
+
if (this.closed) throw new Error('Authority closed before device sign-in commit.');
|
|
467
|
+
await this.database.commitLoginOperation(commitId, id, identity.id, grantId, (existing, oldGrant) => {
|
|
468
|
+
if (!target && existing && !existing.removed) throw new Error('Account already exists; reauthenticate that account instead.');
|
|
469
|
+
if (target && (!existing || existing.removed)) throw new Error('Target account was removed during login.');
|
|
274
470
|
if (oldGrant?.owner === 'legacy_native') throw new Error('The legacy native owner still holds this grant.');
|
|
275
471
|
const now = new Date(this.now()).toISOString();
|
|
276
472
|
const account: IStoredAuthorityAccount = existing
|
|
277
473
|
? { ...existing, label: existing.label || identity.email || `OpenAI ${identity.workspaceId}`,
|
|
278
474
|
email: identity.email, plan: identity.plan, primaryGrantId: grantId, removed: false,
|
|
279
|
-
revision: existing.revision + 1, statusObservedAt: now, updateId }
|
|
475
|
+
revision: existing.revision + 1, statusObservedAt: now, updateId: commitId }
|
|
280
476
|
: { id: identity.id, providerId: 'openai', issuer: 'openai', subject: identity.subject,
|
|
281
477
|
workspaceId: identity.workspaceId, label: identity.email ?? `OpenAI ${identity.workspaceId}`,
|
|
282
478
|
email: identity.email, plan: identity.plan, primaryGrantId: grantId, removed: false,
|
|
283
|
-
revision: 1, statusObservedAt: now, updateId };
|
|
479
|
+
revision: 1, statusObservedAt: now, updateId: commitId };
|
|
284
480
|
const grant: IStoredAuthorityGrant = oldGrant
|
|
285
481
|
? { ...oldGrant, state: 'ready', owner: 'daemon', ciphertext,
|
|
286
482
|
grantGeneration: oldGrant.grantGeneration + 1,
|
|
287
483
|
authorizationGeneration: oldGrant.authorizationGeneration + 1,
|
|
288
484
|
revision: oldGrant.revision + 1, accessExpiresAt: tokenExpiry(result.credential),
|
|
289
|
-
attemptId: null, retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId }
|
|
485
|
+
attemptId: null, retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId: commitId }
|
|
290
486
|
: { id: grantId, accountId: identity.id, providerId: 'openai', purpose: 'openai_managed',
|
|
291
487
|
audience: 'chatgpt', state: 'ready', owner: 'daemon', grantGeneration: 1,
|
|
292
488
|
authorizationGeneration: 1, revision: 1, ciphertext,
|
|
293
489
|
accessExpiresAt: tokenExpiry(result.credential), attemptId: null,
|
|
294
|
-
retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId };
|
|
490
|
+
retryAt: null, retryCount: 0, problem: 'none', statusObservedAt: now, updateId: commitId };
|
|
295
491
|
return { account, grant };
|
|
296
492
|
});
|
|
297
493
|
this.publish();
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
:
|
|
494
|
+
}).catch(async () => {
|
|
495
|
+
const current = await this.database.readOperation(id);
|
|
496
|
+
if (!current || ['complete', 'failed', 'cancelled', 'interrupted', 'outcome_unknown'].includes(current.state)) return;
|
|
497
|
+
const state = current.state === 'committing' ? 'outcome_unknown' : this.closed ? 'interrupted' : 'failed';
|
|
498
|
+
const updateId = plugins.crypto.randomUUID();
|
|
499
|
+
await this.database.changeOperation(updateId, id, prior => ({ ...prior, state,
|
|
500
|
+
prompt: null, result: null, error: state === 'outcome_unknown'
|
|
501
|
+
? 'Sign-in completed but its account result could not be confirmed. Check the login before retrying.'
|
|
502
|
+
: state === 'interrupted' ? 'Sign-in was interrupted; no account change was committed.'
|
|
503
|
+
: target ? 'Reauthentication failed or selected a different identity. The existing login was preserved.'
|
|
504
|
+
: 'Device sign-in failed. No account was added.',
|
|
505
|
+
updatedAt: new Date(this.now()).toISOString(), finishedAt: new Date(this.now()).toISOString(),
|
|
506
|
+
revision: prior.revision + 1, updateId }));
|
|
305
507
|
}).finally(async () => {
|
|
306
|
-
operation.finishedAt = this.now();
|
|
307
508
|
try { await handle.close(); } catch { /* A completed operation remains inspectable. */ }
|
|
509
|
+
if (this.operations.get(id) === operation) this.operations.delete(id);
|
|
308
510
|
});
|
|
309
|
-
|
|
511
|
+
void operation.task.catch(() => {});
|
|
512
|
+
return publicOperation(pending);
|
|
310
513
|
}
|
|
311
514
|
|
|
312
|
-
public getOperation(operationId: string): IAuthSwitchOperation {
|
|
515
|
+
public async getOperation(operationId: string): Promise<IAuthSwitchOperation> {
|
|
313
516
|
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
314
|
-
this.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
517
|
+
const operation = await this.database.readOperation(operationId);
|
|
518
|
+
if (!operation || operation.kind === 'preuse_openai') throw new Error('Account operation was not found.');
|
|
519
|
+
return publicOperation(operation);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
public async listOperations(after: string | null, limit = 128): Promise<{ operations: IAuthSwitchOperation[]; nextCursor: string | null }> {
|
|
523
|
+
if (after !== null && !isUuid(after)) throw new Error('Invalid operation cursor.');
|
|
524
|
+
const page = await this.database.operationsPage(after, limit);
|
|
525
|
+
return { operations: page.operations.map(publicOperation), nextCursor: page.nextCursor };
|
|
318
526
|
}
|
|
319
527
|
|
|
320
528
|
/** Trusted daemon lifecycle hook: hold new managed runtimes out until targeted reauth settles. */
|
|
321
529
|
public async waitOperation(operationId: string): Promise<void> {
|
|
322
530
|
const operation = this.operations.get(operationId);
|
|
323
|
-
if (!isUuid(operationId)
|
|
324
|
-
await operation.task;
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
for (const [id, operation] of this.operations) {
|
|
329
|
-
if (operation.finishedAt !== null && this.now() - operation.finishedAt > 600_000) this.operations.delete(id);
|
|
531
|
+
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
532
|
+
if (operation) await operation.task;
|
|
533
|
+
else if (['starting', 'pending', 'committing'].includes(
|
|
534
|
+
(await this.database.readOperation(operationId))?.state ?? '')) {
|
|
535
|
+
throw new Error('Unfinished device operation has no active provider owner.');
|
|
330
536
|
}
|
|
331
537
|
}
|
|
332
538
|
|
|
333
539
|
public async cancelOperation(operationId: string): Promise<IAuthSwitchOperation> {
|
|
540
|
+
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
541
|
+
const current = await this.database.readOperation(operationId);
|
|
542
|
+
if (!current || current.kind === 'preuse_openai') throw new Error('Account operation was not found.');
|
|
543
|
+
if (current.state === 'committing') throw new Error('Login is completing; check its operation status.');
|
|
544
|
+
if (current.state !== 'starting' && current.state !== 'pending') return publicOperation(current);
|
|
545
|
+
const updateId = plugins.crypto.randomUUID();
|
|
546
|
+
const cancelled = await this.database.changeOperation(updateId, operationId, prior => ({ ...prior,
|
|
547
|
+
state: 'cancelled', prompt: null, result: null, error: 'Device sign-in was cancelled.',
|
|
548
|
+
updatedAt: new Date(this.now()).toISOString(), finishedAt: new Date(this.now()).toISOString(),
|
|
549
|
+
revision: prior.revision + 1, updateId }));
|
|
334
550
|
const operation = this.operations.get(operationId);
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
if (operation.committing) throw new Error('Login is completing; check its operation status.');
|
|
338
|
-
operation.cancelled = true;
|
|
339
|
-
await operation.handle.cancel();
|
|
340
|
-
operation.public.state = 'cancelled';
|
|
341
|
-
operation.public.error = null;
|
|
342
|
-
return structuredClone(operation.public);
|
|
551
|
+
if (operation) await operation.handle.cancel();
|
|
552
|
+
return publicOperation(cancelled);
|
|
343
553
|
}
|
|
344
554
|
|
|
345
555
|
public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
|
|
@@ -351,8 +561,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
351
561
|
statusObservedAt: new Date(this.now()).toISOString() };
|
|
352
562
|
});
|
|
353
563
|
this.publish();
|
|
354
|
-
|
|
355
|
-
return publicAccount(updated.account, grant);
|
|
564
|
+
return publicAccount(updated.account);
|
|
356
565
|
}
|
|
357
566
|
|
|
358
567
|
public async removeAccount(accountId: string, expectedRevision: number): Promise<IAuthSwitchAccount> {
|
|
@@ -361,20 +570,22 @@ export class AuthSwitchAuthorityBroker {
|
|
|
361
570
|
const account = await this.database.removeAccountAndGrants(updateId, accountId,
|
|
362
571
|
expectedRevision, new Date(this.now()).toISOString());
|
|
363
572
|
this.publish();
|
|
364
|
-
|
|
365
|
-
return publicAccount(account, grant);
|
|
573
|
+
return publicAccount(account);
|
|
366
574
|
}
|
|
367
575
|
|
|
368
|
-
public async bindAccount(input: { accountId: string;
|
|
369
|
-
|
|
576
|
+
public async bindAccount(input: { accountId: string; loginId: string; purpose: 'openai_managed';
|
|
577
|
+
runtime: IAuthSwitchBinding['runtime']; scopeId: string; incarnationId: string }): Promise<{ binding: IAuthSwitchBinding; capability: string }> {
|
|
578
|
+
if (!isId(input.accountId) || !isId(input.loginId) || input.purpose !== 'openai_managed'
|
|
579
|
+
|| !['flex', 'codex', 'opencode', 'claude'].includes(input.runtime)
|
|
370
580
|
|| !safeScope(input.scopeId, 512) || !safeScope(input.incarnationId, 256)) throw new Error('Invalid account binding.');
|
|
371
581
|
const id = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
|
|
372
582
|
const capability = plugins.crypto.randomBytes(32).toString('base64url');
|
|
373
583
|
const capabilityHash = idHash(capability);
|
|
374
584
|
const updateId = plugins.crypto.randomUUID();
|
|
375
|
-
const updated = await this.database.changeBinding(updateId, id, input.accountId, (existing, account, grant) => {
|
|
376
|
-
if (account.removed || grant?.state !== 'ready' || grant.
|
|
377
|
-
|| grant.
|
|
585
|
+
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (existing, account, grant) => {
|
|
586
|
+
if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
|
|
587
|
+
|| grant.owner !== 'daemon'
|
|
588
|
+
|| grant.purpose !== input.purpose || account.providerId !== 'openai'
|
|
378
589
|
|| input.runtime === 'claude') {
|
|
379
590
|
throw new Error('Account is not available for this runtime.');
|
|
380
591
|
}
|
|
@@ -388,11 +599,20 @@ export class AuthSwitchAuthorityBroker {
|
|
|
388
599
|
|
|
389
600
|
public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
|
|
390
601
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
|
|
391
|
-
const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
|
|
602
|
+
const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
|
|
603
|
+
authSwitchBindingCapabilityHash(capability));
|
|
392
604
|
if (revoked) this.publish();
|
|
393
605
|
return revoked;
|
|
394
606
|
}
|
|
395
607
|
|
|
608
|
+
public async releaseExternalBinding(bindingId: string, capability: string): Promise<'released' | 'inactive'> {
|
|
609
|
+
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
|
|
610
|
+
const released = await this.database.releaseExternalBinding(plugins.crypto.randomUUID(), bindingId,
|
|
611
|
+
authSwitchBindingCapabilityHash(capability));
|
|
612
|
+
if (released) this.publish();
|
|
613
|
+
return released ? 'released' : 'inactive';
|
|
614
|
+
}
|
|
615
|
+
|
|
396
616
|
public async resolveAccess(bindingId: string, capability: string, minValidityMs: number,
|
|
397
617
|
rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
398
618
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)
|
|
@@ -400,16 +620,55 @@ export class AuthSwitchAuthorityBroker {
|
|
|
400
620
|
|| (rejectedGrantGeneration !== undefined && (!Number.isSafeInteger(rejectedGrantGeneration)
|
|
401
621
|
|| rejectedGrantGeneration < 1))) throw new Error('Invalid runtime credential request.');
|
|
402
622
|
const supplied = Buffer.from(idHash(capability), 'hex');
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const
|
|
623
|
+
let originalBinding: IStoredAuthorityBinding | null = null;
|
|
624
|
+
return this.resolveManagedAccess(async () => {
|
|
625
|
+
const current = await this.database.readBindingContext(bindingId);
|
|
626
|
+
const binding = current.binding;
|
|
406
627
|
const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
|
|
407
628
|
if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
|
|
408
629
|
throw new Error('Runtime binding is not authorized.');
|
|
409
630
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|| binding.
|
|
631
|
+
if (originalBinding && (binding.id !== originalBinding.id
|
|
632
|
+
|| binding.capabilityHash !== originalBinding.capabilityHash
|
|
633
|
+
|| binding.accountId !== originalBinding.accountId || binding.grantId !== originalBinding.grantId
|
|
634
|
+
|| binding.incarnationId !== originalBinding.incarnationId
|
|
635
|
+
|| binding.grantAuthorizationGeneration !== originalBinding.grantAuthorizationGeneration)) {
|
|
636
|
+
throw new Error('Account binding changed during credential resolution.');
|
|
637
|
+
}
|
|
638
|
+
originalBinding ??= binding;
|
|
639
|
+
if (!current.account || current.account.removed || !current.grant
|
|
640
|
+
|| current.grant.id !== binding.grantId
|
|
641
|
+
|| current.grant.authorizationGeneration !== binding.grantAuthorizationGeneration) {
|
|
642
|
+
throw new Error('Account needs reauthentication.');
|
|
643
|
+
}
|
|
644
|
+
return { account: current.account, grant: current.grant };
|
|
645
|
+
}, minValidityMs, rejectedGrantGeneration);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Backend-only access for usage; it never creates or bypasses a runtime binding. */
|
|
649
|
+
public async resolveUsageAccess(context: IAuthSwitchUsageContext,
|
|
650
|
+
rejectedGrantGeneration: number | undefined, minValidityMs: number): Promise<IAuthSwitchResolvedAccess> {
|
|
651
|
+
if (!isId(context.accountId) || !isId(context.loginId)
|
|
652
|
+
|| !Number.isSafeInteger(minValidityMs) || minValidityMs < 0 || minValidityMs > 3_600_000
|
|
653
|
+
|| (rejectedGrantGeneration !== undefined && (!Number.isSafeInteger(rejectedGrantGeneration)
|
|
654
|
+
|| rejectedGrantGeneration < 1))) throw new Error('Invalid authority usage access request.');
|
|
655
|
+
return this.resolveManagedAccess(async () => {
|
|
656
|
+
const current = await this.database.readUsageAccessView(context.accountId, context.loginId);
|
|
657
|
+
if (!current || !sameUsageAuthority(context, current.context)) {
|
|
658
|
+
throw new Error('Account usage authority changed.');
|
|
659
|
+
}
|
|
660
|
+
return { account: current.account, grant: current.grant };
|
|
661
|
+
}, minValidityMs, rejectedGrantGeneration);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private async resolveManagedAccess(readView: () => Promise<{
|
|
665
|
+
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
666
|
+
}>, minValidityMs: number, rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
667
|
+
await this.database.assertOpenAdmission();
|
|
668
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
669
|
+
const { account, grant } = await readView();
|
|
670
|
+
if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
|
|
671
|
+
|| account.providerId !== 'openai' || grant.providerId !== 'openai') {
|
|
413
672
|
throw new Error('Account needs reauthentication.');
|
|
414
673
|
}
|
|
415
674
|
if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
|
|
@@ -428,7 +687,8 @@ export class AuthSwitchAuthorityBroker {
|
|
|
428
687
|
const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
|
|
429
688
|
if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
|
|
430
689
|
if (grant.state === 'ready' || (grant.retryAt !== null && Date.parse(grant.retryAt) <= this.now())) {
|
|
431
|
-
await this.refreshAccount(account.id,
|
|
690
|
+
await this.refreshAccount(account.id, minValidityMs,
|
|
691
|
+
rejectedCurrent ? rejectedGrantGeneration : undefined);
|
|
432
692
|
continue;
|
|
433
693
|
}
|
|
434
694
|
throw new Error('Account access credential is not fresh enough.');
|
|
@@ -439,18 +699,15 @@ export class AuthSwitchAuthorityBroker {
|
|
|
439
699
|
if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
|
|
440
700
|
throw new Error('Account access identity changed.');
|
|
441
701
|
}
|
|
442
|
-
const latest = await
|
|
443
|
-
if (
|
|
444
|
-
|| latest.
|
|
445
|
-
|| latest.
|
|
446
|
-
|| latest.
|
|
447
|
-
|| latest.binding.grantAuthorizationGeneration !== binding.grantAuthorizationGeneration
|
|
448
|
-
|| latest.account?.removed || !latest.grant
|
|
449
|
-
|| latest.grant.authorizationGeneration !== grant.authorizationGeneration
|
|
702
|
+
const latest = await readView();
|
|
703
|
+
if (latest.account.removed || latest.account.id !== account.id
|
|
704
|
+
|| latest.account.primaryGrantId !== latest.grant.id
|
|
705
|
+
|| latest.account.workspaceId !== account.workspaceId || latest.account.subject !== account.subject
|
|
706
|
+
|| latest.grant.id !== grant.id || latest.grant.authorizationGeneration !== grant.authorizationGeneration
|
|
450
707
|
|| latest.grant.owner !== 'daemon') {
|
|
451
708
|
throw new Error('Account binding changed during credential resolution.');
|
|
452
709
|
}
|
|
453
|
-
if (latest.grant.grantGeneration !== grant.grantGeneration
|
|
710
|
+
if (latest.grant.grantGeneration !== grant.grantGeneration || latest.grant.revision !== grant.revision
|
|
454
711
|
|| latest.grant.state === 'exchange_may_have_been_sent') continue;
|
|
455
712
|
if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
|
|
456
713
|
|| (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
|
|
@@ -463,20 +720,24 @@ export class AuthSwitchAuthorityBroker {
|
|
|
463
720
|
throw new Error('Account credential rotated repeatedly during resolution; retry the request.');
|
|
464
721
|
}
|
|
465
722
|
|
|
466
|
-
private async refreshAccount(accountId: string,
|
|
723
|
+
private async refreshAccount(accountId: string, minValidityMs: number,
|
|
724
|
+
rejectedGrantGeneration?: number): Promise<void> {
|
|
467
725
|
const prior = this.refreshes.get(accountId);
|
|
468
726
|
if (prior) return prior;
|
|
469
|
-
const task = this.performRefresh(accountId, rejectedGrantGeneration)
|
|
727
|
+
const task = this.performRefresh(accountId, minValidityMs, rejectedGrantGeneration)
|
|
728
|
+
.finally(() => { if (this.refreshes.get(accountId) === task) this.refreshes.delete(accountId); });
|
|
470
729
|
this.refreshes.set(accountId, task);
|
|
471
730
|
return task;
|
|
472
731
|
}
|
|
473
732
|
|
|
474
|
-
private async performRefresh(accountId: string,
|
|
733
|
+
private async performRefresh(accountId: string, minValidityMs: number,
|
|
734
|
+
rejectedGrantGeneration?: number): Promise<void> {
|
|
735
|
+
await this.database.assertOpenAdmission();
|
|
475
736
|
const initial = await this.database.readAccountAndPrimaryGrant(accountId);
|
|
476
737
|
const initialGrant = initial.grant;
|
|
477
738
|
if (!initial.account || initial.account.removed || !initialGrant || initialGrant.purpose !== 'openai_managed'
|
|
478
739
|
|| !['ready', 'retry_wait'].includes(initialGrant.state) || initialGrant.owner !== 'daemon'
|
|
479
|
-
|| (!this.isDue(initialGrant,
|
|
740
|
+
|| (!this.isDue(initialGrant, minValidityMs) && initialGrant.grantGeneration !== rejectedGrantGeneration)
|
|
480
741
|
|| (initialGrant.state === 'retry_wait'
|
|
481
742
|
&& (initialGrant.retryAt === null || Date.parse(initialGrant.retryAt) > this.now()))) return;
|
|
482
743
|
const credential = await this.unsealCredential(initial.account, initialGrant);
|
|
@@ -540,13 +801,20 @@ export class AuthSwitchAuthorityBroker {
|
|
|
540
801
|
}
|
|
541
802
|
}
|
|
542
803
|
|
|
543
|
-
public
|
|
804
|
+
public close(): Promise<void> {
|
|
805
|
+
if (this.closing) return this.closing;
|
|
544
806
|
this.closed = true;
|
|
545
807
|
if (this.timer) clearTimeout(this.timer);
|
|
546
808
|
for (const wake of this.listeners) wake();
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
809
|
+
this.closing = (async () => {
|
|
810
|
+
if (this.maintenance) await this.maintenance;
|
|
811
|
+
await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
|
|
812
|
+
await Promise.allSettled([...this.starts.values()].map(item => item.promise));
|
|
813
|
+
await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
|
|
814
|
+
await Promise.allSettled([...this.operations.values()].map(item => item.task));
|
|
815
|
+
await Promise.allSettled([...this.refreshes.values()]);
|
|
816
|
+
await this.provider.dispose();
|
|
817
|
+
})();
|
|
818
|
+
return this.closing;
|
|
551
819
|
}
|
|
552
820
|
}
|