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