@modelprofile.com/authswitch 6.4.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 +15 -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 +29 -6
- package/dist_ts/classes.authoritybroker.js +413 -122
- package/dist_ts/classes.authoritycli.d.ts +24 -0
- package/dist_ts/classes.authoritycli.js +675 -0
- package/dist_ts/classes.authorityclient.d.ts +34 -9
- package/dist_ts/classes.authorityclient.js +136 -38
- package/dist_ts/classes.authoritydaemon.d.ts +47 -0
- package/dist_ts/classes.authoritydaemon.js +705 -16
- package/dist_ts/classes.authoritydatabase.d.ts +182 -8
- package/dist_ts/classes.authoritydatabase.js +1188 -41
- package/dist_ts/classes.authoritymodels.d.ts +247 -1
- package/dist_ts/classes.authoritymodels.js +870 -6
- 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 +91 -0
- package/dist_ts/classes.codexmanaged.js +404 -0
- 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 +2 -0
- package/dist_ts/index.js +8 -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 +7 -3
- package/dist_ts/plugins.js +9 -4
- 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 +12 -2
- package/readme.md +205 -13
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +264 -9
- package/ts/authority-runtime-contract.ts +21 -1
- 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 +410 -120
- package/ts/classes.authoritycli.ts +719 -0
- package/ts/classes.authorityclient.ts +164 -38
- package/ts/classes.authoritydaemon.ts +614 -17
- package/ts/classes.authoritydatabase.ts +1229 -44
- package/ts/classes.authoritymodels.ts +579 -4
- 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 +402 -0
- 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 +7 -0
- package/ts/interfaces.harness.ts +6 -0
- package/ts/interfaces.list.ts +9 -3
- package/ts/plugins.ts +9 -3
- 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,106 +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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
528
|
+
/** Trusted daemon lifecycle hook: hold new managed runtimes out until targeted reauth settles. */
|
|
529
|
+
public async waitOperation(operationId: string): Promise<void> {
|
|
530
|
+
const operation = this.operations.get(operationId);
|
|
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.');
|
|
323
536
|
}
|
|
324
537
|
}
|
|
325
538
|
|
|
326
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 }));
|
|
327
550
|
const operation = this.operations.get(operationId);
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
-
if (operation.committing) throw new Error('Login is completing; check its operation status.');
|
|
331
|
-
operation.cancelled = true;
|
|
332
|
-
await operation.handle.cancel();
|
|
333
|
-
operation.public.state = 'cancelled';
|
|
334
|
-
operation.public.error = null;
|
|
335
|
-
return structuredClone(operation.public);
|
|
551
|
+
if (operation) await operation.handle.cancel();
|
|
552
|
+
return publicOperation(cancelled);
|
|
336
553
|
}
|
|
337
554
|
|
|
338
555
|
public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
|
|
@@ -344,8 +561,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
344
561
|
statusObservedAt: new Date(this.now()).toISOString() };
|
|
345
562
|
});
|
|
346
563
|
this.publish();
|
|
347
|
-
|
|
348
|
-
return publicAccount(updated.account, grant);
|
|
564
|
+
return publicAccount(updated.account);
|
|
349
565
|
}
|
|
350
566
|
|
|
351
567
|
public async removeAccount(accountId: string, expectedRevision: number): Promise<IAuthSwitchAccount> {
|
|
@@ -354,20 +570,22 @@ export class AuthSwitchAuthorityBroker {
|
|
|
354
570
|
const account = await this.database.removeAccountAndGrants(updateId, accountId,
|
|
355
571
|
expectedRevision, new Date(this.now()).toISOString());
|
|
356
572
|
this.publish();
|
|
357
|
-
|
|
358
|
-
return publicAccount(account, grant);
|
|
573
|
+
return publicAccount(account);
|
|
359
574
|
}
|
|
360
575
|
|
|
361
|
-
public async bindAccount(input: { accountId: string;
|
|
362
|
-
|
|
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)
|
|
363
580
|
|| !safeScope(input.scopeId, 512) || !safeScope(input.incarnationId, 256)) throw new Error('Invalid account binding.');
|
|
364
581
|
const id = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
|
|
365
582
|
const capability = plugins.crypto.randomBytes(32).toString('base64url');
|
|
366
583
|
const capabilityHash = idHash(capability);
|
|
367
584
|
const updateId = plugins.crypto.randomUUID();
|
|
368
|
-
const updated = await this.database.changeBinding(updateId, id, input.accountId, (existing, account, grant) => {
|
|
369
|
-
if (account.removed || grant?.state !== 'ready' || grant.
|
|
370
|
-
|| 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'
|
|
371
589
|
|| input.runtime === 'claude') {
|
|
372
590
|
throw new Error('Account is not available for this runtime.');
|
|
373
591
|
}
|
|
@@ -379,22 +597,83 @@ export class AuthSwitchAuthorityBroker {
|
|
|
379
597
|
return { binding: publicBinding(updated.binding), capability };
|
|
380
598
|
}
|
|
381
599
|
|
|
382
|
-
public async
|
|
600
|
+
public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
|
|
601
|
+
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
|
|
602
|
+
const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
|
|
603
|
+
authSwitchBindingCapabilityHash(capability));
|
|
604
|
+
if (revoked) this.publish();
|
|
605
|
+
return revoked;
|
|
606
|
+
}
|
|
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
|
+
|
|
616
|
+
public async resolveAccess(bindingId: string, capability: string, minValidityMs: number,
|
|
617
|
+
rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
383
618
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)
|
|
384
|
-
|| !Number.isSafeInteger(minValidityMs) || minValidityMs < 0 || minValidityMs > 3_600_000
|
|
619
|
+
|| !Number.isSafeInteger(minValidityMs) || minValidityMs < 0 || minValidityMs > 3_600_000
|
|
620
|
+
|| (rejectedGrantGeneration !== undefined && (!Number.isSafeInteger(rejectedGrantGeneration)
|
|
621
|
+
|| rejectedGrantGeneration < 1))) throw new Error('Invalid runtime credential request.');
|
|
385
622
|
const supplied = Buffer.from(idHash(capability), 'hex');
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
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;
|
|
389
627
|
const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
|
|
390
628
|
if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
|
|
391
629
|
throw new Error('Runtime binding is not authorized.');
|
|
392
630
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|| 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') {
|
|
396
672
|
throw new Error('Account needs reauthentication.');
|
|
397
673
|
}
|
|
674
|
+
if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
|
|
675
|
+
throw new Error('Provider rejected a future account grant generation.');
|
|
676
|
+
}
|
|
398
677
|
if (grant.state === 'exchange_may_have_been_sent') {
|
|
399
678
|
const inFlight = this.refreshes.get(account.id);
|
|
400
679
|
if (!inFlight) throw new Error('Account refresh is unresolved; reauthentication is required.');
|
|
@@ -405,9 +684,11 @@ export class AuthSwitchAuthorityBroker {
|
|
|
405
684
|
|| !['ready', 'retry_wait'].includes(grant.state)) {
|
|
406
685
|
throw new Error('Account needs reauthentication.');
|
|
407
686
|
}
|
|
408
|
-
|
|
687
|
+
const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
|
|
688
|
+
if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
|
|
409
689
|
if (grant.state === 'ready' || (grant.retryAt !== null && Date.parse(grant.retryAt) <= this.now())) {
|
|
410
|
-
await this.refreshAccount(account.id
|
|
690
|
+
await this.refreshAccount(account.id, minValidityMs,
|
|
691
|
+
rejectedCurrent ? rejectedGrantGeneration : undefined);
|
|
411
692
|
continue;
|
|
412
693
|
}
|
|
413
694
|
throw new Error('Account access credential is not fresh enough.');
|
|
@@ -418,20 +699,18 @@ export class AuthSwitchAuthorityBroker {
|
|
|
418
699
|
if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
|
|
419
700
|
throw new Error('Account access identity changed.');
|
|
420
701
|
}
|
|
421
|
-
const latest = await
|
|
422
|
-
if (
|
|
423
|
-
|| latest.
|
|
424
|
-
|| latest.
|
|
425
|
-
|| latest.
|
|
426
|
-
|| latest.binding.grantAuthorizationGeneration !== binding.grantAuthorizationGeneration
|
|
427
|
-
|| latest.account?.removed || !latest.grant
|
|
428
|
-
|| 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
|
|
429
707
|
|| latest.grant.owner !== 'daemon') {
|
|
430
708
|
throw new Error('Account binding changed during credential resolution.');
|
|
431
709
|
}
|
|
432
|
-
if (latest.grant.grantGeneration !== grant.grantGeneration
|
|
710
|
+
if (latest.grant.grantGeneration !== grant.grantGeneration || latest.grant.revision !== grant.revision
|
|
433
711
|
|| latest.grant.state === 'exchange_may_have_been_sent') continue;
|
|
434
|
-
if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
|
|
712
|
+
if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
|
|
713
|
+
|| (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
|
|
435
714
|
throw new Error('Account access credential is not fresh enough.');
|
|
436
715
|
}
|
|
437
716
|
return { accessToken: credential.accessToken, accountId: account.workspaceId,
|
|
@@ -441,20 +720,24 @@ export class AuthSwitchAuthorityBroker {
|
|
|
441
720
|
throw new Error('Account credential rotated repeatedly during resolution; retry the request.');
|
|
442
721
|
}
|
|
443
722
|
|
|
444
|
-
private async refreshAccount(accountId: string
|
|
723
|
+
private async refreshAccount(accountId: string, minValidityMs: number,
|
|
724
|
+
rejectedGrantGeneration?: number): Promise<void> {
|
|
445
725
|
const prior = this.refreshes.get(accountId);
|
|
446
726
|
if (prior) return prior;
|
|
447
|
-
const task = this.performRefresh(accountId
|
|
727
|
+
const task = this.performRefresh(accountId, minValidityMs, rejectedGrantGeneration)
|
|
728
|
+
.finally(() => { if (this.refreshes.get(accountId) === task) this.refreshes.delete(accountId); });
|
|
448
729
|
this.refreshes.set(accountId, task);
|
|
449
730
|
return task;
|
|
450
731
|
}
|
|
451
732
|
|
|
452
|
-
private async performRefresh(accountId: string
|
|
733
|
+
private async performRefresh(accountId: string, minValidityMs: number,
|
|
734
|
+
rejectedGrantGeneration?: number): Promise<void> {
|
|
735
|
+
await this.database.assertOpenAdmission();
|
|
453
736
|
const initial = await this.database.readAccountAndPrimaryGrant(accountId);
|
|
454
737
|
const initialGrant = initial.grant;
|
|
455
738
|
if (!initial.account || initial.account.removed || !initialGrant || initialGrant.purpose !== 'openai_managed'
|
|
456
739
|
|| !['ready', 'retry_wait'].includes(initialGrant.state) || initialGrant.owner !== 'daemon'
|
|
457
|
-
|| !this.isDue(initialGrant,
|
|
740
|
+
|| (!this.isDue(initialGrant, minValidityMs) && initialGrant.grantGeneration !== rejectedGrantGeneration)
|
|
458
741
|
|| (initialGrant.state === 'retry_wait'
|
|
459
742
|
&& (initialGrant.retryAt === null || Date.parse(initialGrant.retryAt) > this.now()))) return;
|
|
460
743
|
const credential = await this.unsealCredential(initial.account, initialGrant);
|
|
@@ -518,13 +801,20 @@ export class AuthSwitchAuthorityBroker {
|
|
|
518
801
|
}
|
|
519
802
|
}
|
|
520
803
|
|
|
521
|
-
public
|
|
804
|
+
public close(): Promise<void> {
|
|
805
|
+
if (this.closing) return this.closing;
|
|
522
806
|
this.closed = true;
|
|
523
807
|
if (this.timer) clearTimeout(this.timer);
|
|
524
808
|
for (const wake of this.listeners) wake();
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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;
|
|
529
819
|
}
|
|
530
820
|
}
|