@modelprofile.com/authswitch 9.0.0 → 9.2.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 +71 -1
- package/dist_ts/authority-contract.js +13 -2
- package/dist_ts/authority-import-contract.d.ts +6 -0
- package/dist_ts/authority-runtime-contract.d.ts +29 -0
- package/dist_ts/classes.authoritybroker.d.ts +37 -15
- package/dist_ts/classes.authoritybroker.js +121 -35
- package/dist_ts/classes.authorityclient.d.ts +28 -3
- package/dist_ts/classes.authorityclient.js +101 -18
- package/dist_ts/classes.authoritydaemon.d.ts +24 -0
- package/dist_ts/classes.authoritydaemon.js +87 -40
- package/dist_ts/classes.authoritydatabase.d.ts +29 -4
- package/dist_ts/classes.authoritydatabase.js +98 -13
- package/dist_ts/classes.authorityimport.js +2 -2
- package/dist_ts/classes.authoritymodels.js +5 -3
- package/dist_ts/classes.codexmanaged.d.ts +0 -9
- package/dist_ts/classes.codexmanaged.js +8 -28
- package/dist_ts/codexcontract.d.ts +30 -0
- package/dist_ts/codexcontract.js +174 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.d.ts +12 -0
- package/dist_ts/ts_migration/0004_container_setup_owner.js +19 -0
- package/dist_ts/ts_migration/index.js +3 -1
- package/package.json +8 -8
- package/readme.md +102 -25
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +75 -4
- package/ts/authority-import-contract.ts +6 -0
- package/ts/authority-runtime-contract.ts +30 -0
- package/ts/classes.authoritybroker.ts +122 -36
- package/ts/classes.authorityclient.ts +107 -21
- package/ts/classes.authoritydaemon.ts +91 -33
- package/ts/classes.authoritydatabase.ts +100 -14
- package/ts/classes.authorityimport.ts +1 -1
- package/ts/classes.authoritymodels.ts +4 -1
- package/ts/classes.codexmanaged.ts +6 -26
- package/ts/codexcontract.ts +200 -0
- package/ts/ts_migration/0004_container_setup_owner.ts +19 -0
- package/ts/ts_migration/index.ts +2 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type { IAuthSwitchAccount, IAuthSwitchAccountEvent, IAuthSwitchBinding, IAuthSwitchLogin,
|
|
3
|
-
IAuthSwitchOperation, IAuthSwitchSnapshot,
|
|
4
|
-
|
|
3
|
+
IAuthSwitchNativeAssignment, IAuthSwitchOperation, IAuthSwitchSnapshot, IReq_AuthSwitchSnapshot,
|
|
4
|
+
TAuthSwitchLoginOwnerTool } from './authority-contract.js';
|
|
5
|
+
import { AuthSwitchRefusal, isAuthSwitchAccountLabel } from './authority-contract.js';
|
|
5
6
|
import { AuthSwitchAuthorityDatabase } from './classes.authoritydatabase.js';
|
|
6
|
-
import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityGrant,
|
|
7
|
+
import type { IStoredAuthorityAccount, IStoredAuthorityBinding, IStoredAuthorityClaudeHome, IStoredAuthorityGrant,
|
|
7
8
|
IStoredAuthorityDeviceOperation } from './classes.authoritymodels.js';
|
|
8
9
|
import type { IAuthSwitchUsageContext } from './classes.authorityusage.js';
|
|
9
10
|
import { AuthSwitchTpmSecretCodec, type IAuthSwitchSecretCodec } from './classes.authoritysecrets.js';
|
|
@@ -41,8 +42,6 @@ const sameUsageAuthority = (left: IAuthSwitchUsageContext, right: IAuthSwitchUsa
|
|
|
41
42
|
const isUuid = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
|
|
42
43
|
const deviceOperationTerminal = (operation: IStoredAuthorityDeviceOperation): boolean =>
|
|
43
44
|
!['starting', 'pending', 'committing'].includes(operation.state);
|
|
44
|
-
const safeLabel = (value: unknown): value is string => typeof value === 'string' && value.trim() === value
|
|
45
|
-
&& value.length > 0 && value.length <= 128 && !/[\u0000-\u001f\u007f]/.test(value);
|
|
46
45
|
const safeScope = (value: unknown, maximum: number): value is string => typeof value === 'string'
|
|
47
46
|
&& value.length > 0 && value.length <= maximum && !/[\u0000-\u001f\u007f]/.test(value);
|
|
48
47
|
const validRevision = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
|
|
@@ -70,6 +69,10 @@ const bindingMoved = 'This runtime binding changed while its credential was bein
|
|
|
70
69
|
+ 'account again.';
|
|
71
70
|
const bindingNeedsReauth = 'The account no longer holds the login this binding was authorized for. '
|
|
72
71
|
+ 'Reauthenticate the account, then bind it again.';
|
|
72
|
+
const loginNeedsReauth = 'This account\'s sign-in has ended. Reauthenticate the account with a new device '
|
|
73
|
+
+ 'sign-in; a runtime bound to it binds again afterwards.';
|
|
74
|
+
const accessNotFresh = 'The provider could not renew this account\'s access yet. Try the request again '
|
|
75
|
+
+ 'shortly; the authority keeps retrying the renewal.';
|
|
73
76
|
const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
|
|
74
77
|
id: account.id, providerId: account.providerId, label: account.label, email: account.email,
|
|
75
78
|
plan: account.plan, removed: account.removed, revision: account.revision,
|
|
@@ -133,6 +136,12 @@ const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding =>
|
|
|
133
136
|
scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
|
|
134
137
|
});
|
|
135
138
|
|
|
139
|
+
const publicClaudeAssignment = (home: IStoredAuthorityClaudeHome): IAuthSwitchNativeAssignment => ({
|
|
140
|
+
id: home.id, tool: 'claude_code', accountId: home.activeAccountId, loginId: home.activeGrantId,
|
|
141
|
+
state: home.status === 'quarantined' ? 'quarantined' : home.pendingOperationId !== null ? 'switching' : 'ready',
|
|
142
|
+
revision: home.revision,
|
|
143
|
+
});
|
|
144
|
+
|
|
136
145
|
interface IManagedOperation {
|
|
137
146
|
id: string;
|
|
138
147
|
handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
|
|
@@ -154,6 +163,8 @@ export class AuthSwitchAuthorityBroker {
|
|
|
154
163
|
private readonly operations = new Map<string, IManagedOperation>();
|
|
155
164
|
private readonly refreshes = new Map<string, Promise<void>>();
|
|
156
165
|
private readonly listeners = new Set<() => void>();
|
|
166
|
+
/** Operation long polls; woken by their operation's own changes, and all of them on close. */
|
|
167
|
+
private readonly operationWaiters = new Set<() => void>();
|
|
157
168
|
private claudeRefresh?: (grantId: string) => Promise<void>;
|
|
158
169
|
private maintenance?: Promise<void>;
|
|
159
170
|
private timer?: NodeJS.Timeout;
|
|
@@ -267,19 +278,23 @@ export class AuthSwitchAuthorityBroker {
|
|
|
267
278
|
for (const listener of this.listeners) listener();
|
|
268
279
|
}
|
|
269
280
|
|
|
270
|
-
public async snapshot(options:
|
|
281
|
+
public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}): Promise<IAuthSwitchSnapshot> {
|
|
271
282
|
if ((options.accountAfter !== undefined && !isId(options.accountAfter))
|
|
272
283
|
|| (options.loginAfter !== undefined && !isId(options.loginAfter))
|
|
273
|
-
|| (options.bindingAfter !== undefined && !isId(options.bindingAfter))
|
|
284
|
+
|| (options.bindingAfter !== undefined && !isId(options.bindingAfter))
|
|
285
|
+
|| (options.nativeAssignmentAfter !== undefined && !isId(options.nativeAssignmentAfter))) {
|
|
286
|
+
throw new Error('Invalid account snapshot cursor.');
|
|
287
|
+
}
|
|
274
288
|
const page = await this.database.page(options.accountAfter ?? null, options.loginAfter ?? null,
|
|
275
|
-
options.bindingAfter ?? null, options.limit ?? 128);
|
|
289
|
+
options.bindingAfter ?? null, options.nativeAssignmentAfter ?? null, options.limit ?? 128);
|
|
276
290
|
return { schemaVersion: 2, epoch: page.meta.epoch, revision: page.meta.revision,
|
|
277
291
|
generatedAt: new Date(this.now()).toISOString(),
|
|
278
|
-
accounts: page.accounts.filter(account => !account.removed).map(publicAccount),
|
|
279
|
-
logins: page.grants.filter(grant => grant.state !== 'removed').map(publicLogin),
|
|
292
|
+
accounts: page.accounts.filter(account => options.includeRemoved || !account.removed).map(publicAccount),
|
|
293
|
+
logins: page.grants.filter(grant => options.includeRemoved || grant.state !== 'removed').map(publicLogin),
|
|
280
294
|
bindings: page.bindings.map(publicBinding),
|
|
295
|
+
nativeAssignments: page.claudeHomes.map(publicClaudeAssignment),
|
|
281
296
|
nextAccountCursor: page.nextAccountCursor, nextLoginCursor: page.nextGrantCursor,
|
|
282
|
-
nextBindingCursor: page.nextBindingCursor };
|
|
297
|
+
nextBindingCursor: page.nextBindingCursor, nextNativeAssignmentCursor: page.nextClaudeHomeCursor };
|
|
283
298
|
}
|
|
284
299
|
|
|
285
300
|
public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
|
|
@@ -554,11 +569,46 @@ export class AuthSwitchAuthorityBroker {
|
|
|
554
569
|
return publicOperation(pending);
|
|
555
570
|
}
|
|
556
571
|
|
|
557
|
-
|
|
572
|
+
/**
|
|
573
|
+
* One device sign-in. With `wait`, the read is a long poll, the way `events` waits: it answers at once when
|
|
574
|
+
* the operation's revision is past `afterRevision` or the sign-in has finished, and otherwise when the
|
|
575
|
+
* operation next changes or `waitMs` runs out, whichever is first -- so a caller follows the prompt and
|
|
576
|
+
* the outcome without polling. A timed-out wait answers with the unchanged operation.
|
|
577
|
+
*/
|
|
578
|
+
public async getOperation(operationId: string, wait?: { afterRevision: number; waitMs: number },
|
|
579
|
+
signal?: AbortSignal): Promise<IAuthSwitchOperation> {
|
|
558
580
|
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
581
|
+
if (wait !== undefined && (!validRevision(wait.afterRevision) || !Number.isSafeInteger(wait.waitMs)
|
|
582
|
+
|| wait.waitMs < 0 || wait.waitMs > 30_000)) throw new Error('Invalid operation wait.');
|
|
583
|
+
const read = async (): Promise<IStoredAuthorityDeviceOperation> => {
|
|
584
|
+
const operation = await this.database.readOperation(operationId);
|
|
585
|
+
if (!operation || operation.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
586
|
+
return operation;
|
|
587
|
+
};
|
|
588
|
+
const answers = (operation: IStoredAuthorityDeviceOperation): boolean => wait === undefined
|
|
589
|
+
|| operation.revision > wait.afterRevision || deviceOperationTerminal(operation);
|
|
590
|
+
const immediate = await read();
|
|
591
|
+
if (answers(immediate) || wait!.waitMs === 0 || this.closed || signal?.aborted) return publicOperation(immediate);
|
|
592
|
+
return new Promise((resolve, reject) => {
|
|
593
|
+
let finished = false;
|
|
594
|
+
const settle = (outcome: () => void) => {
|
|
595
|
+
if (finished) return;
|
|
596
|
+
finished = true;
|
|
597
|
+
clearTimeout(timer);
|
|
598
|
+
unobserve();
|
|
599
|
+
this.operationWaiters.delete(wake);
|
|
600
|
+
signal?.removeEventListener('abort', wake);
|
|
601
|
+
outcome();
|
|
602
|
+
};
|
|
603
|
+
const wake = () => settle(() => { void read().then(operation => resolve(publicOperation(operation)), reject); });
|
|
604
|
+
const timer = setTimeout(wake, wait!.waitMs);
|
|
605
|
+
const unobserve = this.database.observeDeviceOperations(changed => { if (changed === operationId) wake(); });
|
|
606
|
+
this.operationWaiters.add(wake);
|
|
607
|
+
signal?.addEventListener('abort', wake, { once: true });
|
|
608
|
+
// Close the read/register race without holding a database session for the wait.
|
|
609
|
+
void read().then(operation => { if (answers(operation)) wake(); },
|
|
610
|
+
error => settle(() => reject(error)));
|
|
611
|
+
});
|
|
562
612
|
}
|
|
563
613
|
|
|
564
614
|
public async listOperations(after: string | null, limit = 128): Promise<{ operations: IAuthSwitchOperation[]; nextCursor: string | null }> {
|
|
@@ -595,7 +645,11 @@ export class AuthSwitchAuthorityBroker {
|
|
|
595
645
|
}
|
|
596
646
|
|
|
597
647
|
public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
|
|
598
|
-
if (!isId(accountId) || !validRevision(expectedRevision)
|
|
648
|
+
if (!isId(accountId) || !validRevision(expectedRevision)) throw new Error('Invalid account change.');
|
|
649
|
+
if (!isAuthSwitchAccountLabel(label)) {
|
|
650
|
+
throw new AuthSwitchRefusal('invalid_input', 'An account label has between one and one hundred and twenty-eight '
|
|
651
|
+
+ 'characters, with no space at either end and no control character. Choose another label.');
|
|
652
|
+
}
|
|
599
653
|
const updateId = plugins.crypto.randomUUID();
|
|
600
654
|
const updated = await this.database.changeAccount(updateId, accountId, account => {
|
|
601
655
|
if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
|
|
@@ -624,21 +678,28 @@ export class AuthSwitchAuthorityBroker {
|
|
|
624
678
|
const capability = plugins.crypto.randomBytes(32).toString('base64url');
|
|
625
679
|
const capabilityHash = idHash(capability);
|
|
626
680
|
const updateId = plugins.crypto.randomUUID();
|
|
627
|
-
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (
|
|
681
|
+
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (_existing, account, grant, revision) => {
|
|
682
|
+
// A ChatGPT login backs every runtime, Claude Code included; a Claude account reaches Claude Code
|
|
683
|
+
// through its native switch instead, so it never binds (its provider is not OpenAI).
|
|
628
684
|
if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
|
|
629
685
|
|| grant.owner !== 'daemon'
|
|
630
|
-
|| grant.purpose !== input.purpose || account.providerId !== 'openai'
|
|
631
|
-
|| input.runtime === 'claude') {
|
|
686
|
+
|| grant.purpose !== input.purpose || account.providerId !== 'openai') {
|
|
632
687
|
throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
|
|
633
688
|
}
|
|
634
689
|
return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
|
|
635
|
-
incarnationId: input.incarnationId, revision
|
|
690
|
+
incarnationId: input.incarnationId, revision,
|
|
636
691
|
grantAuthorizationGeneration: grant.authorizationGeneration, capabilityHash, updateId };
|
|
637
692
|
});
|
|
638
693
|
this.publish();
|
|
639
694
|
return { binding: publicBinding(updated.binding), capability };
|
|
640
695
|
}
|
|
641
696
|
|
|
697
|
+
public async getBinding(bindingId: string): Promise<IAuthSwitchBinding | null> {
|
|
698
|
+
if (!isId(bindingId)) throw new Error('Invalid runtime binding.');
|
|
699
|
+
const binding = await this.database.readBinding(bindingId);
|
|
700
|
+
return binding ? publicBinding(binding) : null;
|
|
701
|
+
}
|
|
702
|
+
|
|
642
703
|
public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
|
|
643
704
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
|
|
644
705
|
const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
|
|
@@ -648,13 +709,37 @@ export class AuthSwitchAuthorityBroker {
|
|
|
648
709
|
}
|
|
649
710
|
|
|
650
711
|
public async releaseExternalBinding(bindingId: string, capability: string): Promise<'released' | 'inactive'> {
|
|
651
|
-
if (
|
|
712
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding release.');
|
|
713
|
+
return this.releaseExternalCapability(bindingId, authSwitchBindingCapabilityHash(capability));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Releases a caller-held binding while it still carries exactly this capability, named by its hash. */
|
|
717
|
+
public async releaseExternalCapability(bindingId: string, capabilityHash: string): Promise<'released' | 'inactive'> {
|
|
718
|
+
if (!isId(bindingId) || !isId(capabilityHash)) throw new Error('Invalid runtime binding release.');
|
|
652
719
|
const released = await this.database.releaseExternalBinding(plugins.crypto.randomUUID(), bindingId,
|
|
653
|
-
|
|
720
|
+
capabilityHash);
|
|
654
721
|
if (released) this.publish();
|
|
655
722
|
return released ? 'released' : 'inactive';
|
|
656
723
|
}
|
|
657
724
|
|
|
725
|
+
/**
|
|
726
|
+
* The caller-held binding a holder names by the identity it bound under, while it is still exactly the
|
|
727
|
+
* incarnation and revision the holder read, or `null`. Its capability hash is what a release then fences
|
|
728
|
+
* and compares, so a successor bound after this read is never released in its place.
|
|
729
|
+
*/
|
|
730
|
+
public async findHeldBinding(input: { runtime: Exclude<IAuthSwitchBinding['runtime'], 'codex'>; scopeId: string;
|
|
731
|
+
incarnationId: string; expectedRevision: number }): Promise<{ bindingId: string; capabilityHash: string } | null> {
|
|
732
|
+
if (!['flex', 'opencode', 'claude'].includes(input.runtime) || !safeScope(input.scopeId, 512)
|
|
733
|
+
|| !safeScope(input.incarnationId, 256) || !validRevision(input.expectedRevision)) {
|
|
734
|
+
throw new Error('Invalid runtime binding release.');
|
|
735
|
+
}
|
|
736
|
+
const bindingId = idHash('authswitch-binding-v1', input.runtime, input.scopeId);
|
|
737
|
+
const binding = await this.database.readBinding(bindingId);
|
|
738
|
+
if (!binding || binding.runtime !== input.runtime || binding.scopeId !== input.scopeId
|
|
739
|
+
|| binding.incarnationId !== input.incarnationId || binding.revision !== input.expectedRevision) return null;
|
|
740
|
+
return { bindingId, capabilityHash: binding.capabilityHash };
|
|
741
|
+
}
|
|
742
|
+
|
|
658
743
|
public async resolveAccess(bindingId: string, capability: string, minValidityMs: number,
|
|
659
744
|
rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
660
745
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)
|
|
@@ -710,13 +795,12 @@ export class AuthSwitchAuthorityBroker {
|
|
|
710
795
|
}
|
|
711
796
|
|
|
712
797
|
/**
|
|
713
|
-
* The shared managed-access loop
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
* before this point, in the view `resolveAccess` supplies.
|
|
798
|
+
* The shared managed-access loop. What it decides about the login itself is a marked refusal both callers
|
|
799
|
+
* read the same way: `login_needs_reauth` when only a new sign-in brings the login back, `access_not_fresh`
|
|
800
|
+
* when the login is intact and the provider could not renew it yet. The usage reader still folds either
|
|
801
|
+
* into its own problem; a bound runtime shows the instruction. What only concerns a binding is decided
|
|
802
|
+
* before this point, in the view `resolveAccess` supplies, and a changed identity or a view that keeps
|
|
803
|
+
* moving stays an unmarked fault, because nobody decided it.
|
|
720
804
|
*/
|
|
721
805
|
private async resolveManagedAccess(readView: () => Promise<{
|
|
722
806
|
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
@@ -725,20 +809,20 @@ export class AuthSwitchAuthorityBroker {
|
|
|
725
809
|
const { account, grant } = await readView();
|
|
726
810
|
if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
|
|
727
811
|
|| account.providerId !== 'openai' || grant.providerId !== 'openai') {
|
|
728
|
-
throw new
|
|
812
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
729
813
|
}
|
|
730
814
|
if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
|
|
731
815
|
throw new Error('Provider rejected a future account grant generation.');
|
|
732
816
|
}
|
|
733
817
|
if (grant.state === 'exchange_may_have_been_sent') {
|
|
734
818
|
const inFlight = this.refreshes.get(account.id);
|
|
735
|
-
if (!inFlight) throw new
|
|
819
|
+
if (!inFlight) throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
736
820
|
await inFlight;
|
|
737
821
|
continue;
|
|
738
822
|
}
|
|
739
823
|
if (grant.owner !== 'daemon' || grant.purpose !== 'openai_managed'
|
|
740
824
|
|| !['ready', 'retry_wait'].includes(grant.state)) {
|
|
741
|
-
throw new
|
|
825
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
742
826
|
}
|
|
743
827
|
const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
|
|
744
828
|
if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
|
|
@@ -747,9 +831,9 @@ export class AuthSwitchAuthorityBroker {
|
|
|
747
831
|
rejectedCurrent ? rejectedGrantGeneration : undefined);
|
|
748
832
|
continue;
|
|
749
833
|
}
|
|
750
|
-
throw new
|
|
834
|
+
throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
751
835
|
}
|
|
752
|
-
if (!grant.accessExpiresAt) throw new
|
|
836
|
+
if (!grant.accessExpiresAt) throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
753
837
|
const credential = await this.unsealCredential(account, grant);
|
|
754
838
|
const info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
|
|
755
839
|
if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
|
|
@@ -767,7 +851,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
767
851
|
|| latest.grant.state === 'exchange_may_have_been_sent') continue;
|
|
768
852
|
if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
|
|
769
853
|
|| (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
|
|
770
|
-
throw new
|
|
854
|
+
throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
771
855
|
}
|
|
772
856
|
return { accessToken: credential.accessToken, accountId: account.workspaceId,
|
|
773
857
|
isFedrampAccount: info.chatgptAccountIsFedramp, expiresAt: grant.accessExpiresAt,
|
|
@@ -852,7 +936,8 @@ export class AuthSwitchAuthorityBroker {
|
|
|
852
936
|
});
|
|
853
937
|
this.publish();
|
|
854
938
|
} catch { /* A persisted attempt marker forces needs_reauth on daemon restart. */ }
|
|
855
|
-
|
|
939
|
+
// The grant is now `needs_reauth`: an uncertain rotation is never replayed, so only a sign-in repairs it.
|
|
940
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
856
941
|
}
|
|
857
942
|
}
|
|
858
943
|
|
|
@@ -861,6 +946,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
861
946
|
this.closed = true;
|
|
862
947
|
if (this.timer) clearTimeout(this.timer);
|
|
863
948
|
for (const wake of this.listeners) wake();
|
|
949
|
+
for (const wake of this.operationWaiters) wake();
|
|
864
950
|
this.closing = (async () => {
|
|
865
951
|
if (this.maintenance) await this.maintenance;
|
|
866
952
|
await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
|
|
@@ -5,35 +5,43 @@ import type {
|
|
|
5
5
|
IReq_AuthSwitchBeginReauth, IReq_AuthSwitchCancelOperation,
|
|
6
6
|
IReq_AuthSwitchClaudeNativeHandoff, IReq_AuthSwitchClaudeNativeHandoffs,
|
|
7
7
|
IReq_AuthSwitchSwitchClaudeNative, IReq_AuthSwitchEvents,
|
|
8
|
-
IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
|
|
8
|
+
IReq_AuthSwitchGetBinding, IReq_AuthSwitchGetOperation, IReq_AuthSwitchListOperations,
|
|
9
|
+
IReq_AuthSwitchRemoveAccount, IReq_AuthSwitchRenameAccount,
|
|
9
10
|
IReq_AuthSwitchCancelPreuse, IReq_AuthSwitchGetPreuse, IReq_AuthSwitchSnapshot,
|
|
10
11
|
IReq_AuthSwitchStartPreuse, IReq_AuthSwitchUsage,
|
|
11
12
|
} from './authority-contract.js';
|
|
12
13
|
import type { IReq_AuthSwitchBindAccount, IReq_AuthSwitchReleaseBinding,
|
|
13
|
-
IReq_AuthSwitchResolveAccess } from './authority-runtime-contract.js';
|
|
14
|
+
IReq_AuthSwitchResolveAccess, IReq_AuthSwitchUnbind } from './authority-runtime-contract.js';
|
|
14
15
|
import type { IReq_AuthSwitchImportInventory, IReq_AuthSwitchImportStatus,
|
|
15
16
|
IReq_AuthSwitchImportSubmit } from './authority-import-contract.js';
|
|
16
17
|
import { AuthSwitchAuthorityFrameReader, authSwitchAuthorityFrameBytes,
|
|
17
18
|
maxAuthSwitchAuthorityFrameBytes } from './classes.authorityframing.js';
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* One request on one connection, bounded by its own deadline alone.
|
|
22
|
+
*
|
|
23
|
+
* `TypedRequest.fire` turns each request's `timeoutMs` into the abort of `signal`, so the transport arms no
|
|
24
|
+
* timer of its own: an idle limit here would end a request that was allowed longer -- an import submit is
|
|
25
|
+
* allowed 120 s -- before its deadline. Every authority request carries one; a request without it is refused.
|
|
26
|
+
*/
|
|
19
27
|
const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promise<ITypedRequest> => {
|
|
20
28
|
return new Promise((resolve, reject) => {
|
|
21
|
-
if (signal
|
|
29
|
+
if (!signal) { reject(new Error('Authswitch daemon requests need a deadline.')); return; }
|
|
30
|
+
if (signal.aborted) { reject(signal.reason); return; }
|
|
22
31
|
const socket = plugins.net.createConnection(path);
|
|
23
32
|
let settled = false;
|
|
24
33
|
const frame = new AuthSwitchAuthorityFrameReader();
|
|
25
34
|
const finish = (error?: unknown, response?: ITypedRequest): void => {
|
|
26
35
|
if (settled) return;
|
|
27
36
|
settled = true;
|
|
28
|
-
signal
|
|
37
|
+
signal.removeEventListener('abort', abort);
|
|
29
38
|
socket.destroy();
|
|
30
39
|
if (error) reject(error);
|
|
31
40
|
else if (response) resolve(response);
|
|
32
41
|
else reject(new Error('Authswitch daemon returned no response.'));
|
|
33
42
|
};
|
|
34
|
-
const abort = () => finish(signal
|
|
35
|
-
signal
|
|
36
|
-
socket.setTimeout(35_000, () => finish(new Error('Authswitch daemon request timed out.')));
|
|
43
|
+
const abort = () => finish(signal.reason ?? new Error('Account request was cancelled.'));
|
|
44
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
37
45
|
socket.on('connect', () => {
|
|
38
46
|
const raw = JSON.stringify(payload);
|
|
39
47
|
if (authSwitchAuthorityFrameBytes(raw) > maxAuthSwitchAuthorityFrameBytes) { finish(new Error('Account request is too large.')); return; }
|
|
@@ -53,10 +61,36 @@ const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promi
|
|
|
53
61
|
});
|
|
54
62
|
};
|
|
55
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The daemon answered with an older contract than this client reads: it runs an authswitch release from before
|
|
66
|
+
* this one and was not restarted after the upgrade. Restarting it onto the installed release is the repair.
|
|
67
|
+
*/
|
|
68
|
+
export class AuthSwitchDaemonOutdatedError extends Error {
|
|
69
|
+
constructor() {
|
|
70
|
+
super('The authswitch authority daemon runs an older release than this client. Restart it onto the '
|
|
71
|
+
+ 'installed authswitch: authswitch authority service stop, then authswitch authority service start.');
|
|
72
|
+
this.name = 'AuthSwitchDaemonOutdatedError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A snapshot page carries every member of this release's contract. `schemaVersion` is not bumped for an
|
|
78
|
+
* additive member, so an older daemon is told apart by the members its answer lacks.
|
|
79
|
+
*/
|
|
80
|
+
const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapshot => {
|
|
81
|
+
const page: Partial<IAuthSwitchSnapshot> = snapshot;
|
|
82
|
+
if (!Array.isArray(page.nativeAssignments) || page.nextNativeAssignmentCursor === undefined) {
|
|
83
|
+
throw new AuthSwitchDaemonOutdatedError();
|
|
84
|
+
}
|
|
85
|
+
return snapshot;
|
|
86
|
+
};
|
|
87
|
+
|
|
56
88
|
/** A snapshot is usable only while state is current; lastVerifiedAt is the last successful daemon proof. */
|
|
57
89
|
export interface IAuthSwitchSubscriptionStatus {
|
|
58
90
|
state: 'current' | 'unavailable' | 'closed';
|
|
59
|
-
|
|
91
|
+
/** `daemon_outdated`: the daemon runs an older release; see `AuthSwitchDaemonOutdatedError`. */
|
|
92
|
+
reason: 'initial' | 'snapshot' | 'event' | 'heartbeat' | 'disconnect' | 'resync' | 'abort' | 'consumer_error'
|
|
93
|
+
| 'daemon_outdated';
|
|
60
94
|
epoch: string | null;
|
|
61
95
|
revision: number | null;
|
|
62
96
|
lastVerifiedAt: string | null;
|
|
@@ -66,6 +100,8 @@ export interface IAuthSwitchSubscriptionOptions {
|
|
|
66
100
|
onStatus?: (status: IAuthSwitchSubscriptionStatus) => void | Promise<void>;
|
|
67
101
|
/** Bounded long-poll interval; defaults to 30 seconds. */
|
|
68
102
|
heartbeatMs?: number;
|
|
103
|
+
/** Deliver snapshots that also carry removed accounts and logins; see `IReq_AuthSwitchSnapshot`. */
|
|
104
|
+
includeRemoved?: boolean;
|
|
69
105
|
}
|
|
70
106
|
|
|
71
107
|
/**
|
|
@@ -96,8 +132,10 @@ export class AuthSwitchClient {
|
|
|
96
132
|
.fire(request, { timeoutMs, maxRetries: 0, abortSignal: signal });
|
|
97
133
|
}
|
|
98
134
|
|
|
135
|
+
/** One snapshot page; an older daemon's answer fails with `AuthSwitchDaemonOutdatedError`. */
|
|
99
136
|
public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}, signal?: AbortSignal): Promise<IAuthSwitchSnapshot> {
|
|
100
|
-
return (await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot',
|
|
137
|
+
return assertCurrentSnapshot((await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot',
|
|
138
|
+
options, 35_000, false, signal)).snapshot);
|
|
101
139
|
}
|
|
102
140
|
|
|
103
141
|
/** One independently stamped diagnostic page; callers may continue with its independent cursors. */
|
|
@@ -132,30 +170,39 @@ export class AuthSwitchClient {
|
|
|
132
170
|
}
|
|
133
171
|
|
|
134
172
|
/** Assemble a consistent view from bounded database pages; restart if a writer changes the revision. */
|
|
135
|
-
public async snapshotAll(options: { limit?: number; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
|
|
173
|
+
public async snapshotAll(options: { limit?: number; includeRemoved?: boolean; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
|
|
136
174
|
while (true) {
|
|
137
175
|
if (options.signal?.aborted) throw options.signal.reason ?? new Error('Account snapshot cancelled.');
|
|
138
176
|
let accountAfter: string | undefined;
|
|
139
177
|
let loginAfter: string | undefined;
|
|
140
178
|
let bindingAfter: string | undefined;
|
|
179
|
+
let nativeAssignmentAfter: string | undefined;
|
|
141
180
|
let aggregate: IAuthSwitchSnapshot | undefined;
|
|
142
181
|
let changed = false;
|
|
143
182
|
while (true) {
|
|
144
|
-
const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter,
|
|
145
|
-
|
|
146
|
-
|
|
183
|
+
const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter, nativeAssignmentAfter,
|
|
184
|
+
limit: options.limit, ...(options.includeRemoved ? { includeRemoved: true } : {}) }, options.signal);
|
|
185
|
+
if (!aggregate) {
|
|
186
|
+
aggregate = { ...page, accounts: [...page.accounts], logins: [...page.logins], bindings: [...page.bindings],
|
|
187
|
+
nativeAssignments: [...page.nativeAssignments] };
|
|
188
|
+
} else if (aggregate.epoch !== page.epoch || aggregate.revision !== page.revision) {
|
|
147
189
|
changed = true;
|
|
148
190
|
break;
|
|
149
191
|
} else {
|
|
150
192
|
aggregate.accounts.push(...page.accounts);
|
|
151
193
|
aggregate.logins.push(...page.logins);
|
|
152
194
|
aggregate.bindings.push(...page.bindings);
|
|
195
|
+
aggregate.nativeAssignments.push(...page.nativeAssignments);
|
|
153
196
|
}
|
|
154
197
|
accountAfter = page.nextAccountCursor ?? accountAfter ?? page.accounts.at(-1)?.id;
|
|
155
198
|
loginAfter = page.nextLoginCursor ?? loginAfter ?? page.logins.at(-1)?.id;
|
|
156
199
|
bindingAfter = page.nextBindingCursor ?? bindingAfter ?? page.bindings.at(-1)?.id;
|
|
157
|
-
|
|
158
|
-
|
|
200
|
+
nativeAssignmentAfter = page.nextNativeAssignmentCursor ?? nativeAssignmentAfter
|
|
201
|
+
?? page.nativeAssignments.at(-1)?.id;
|
|
202
|
+
if (!page.nextAccountCursor && !page.nextLoginCursor && !page.nextBindingCursor
|
|
203
|
+
&& !page.nextNativeAssignmentCursor) {
|
|
204
|
+
return { ...aggregate, nextAccountCursor: null, nextLoginCursor: null, nextBindingCursor: null,
|
|
205
|
+
nextNativeAssignmentCursor: null };
|
|
159
206
|
}
|
|
160
207
|
}
|
|
161
208
|
if (!changed) throw new Error('Authority snapshot could not complete.');
|
|
@@ -188,9 +235,9 @@ export class AuthSwitchClient {
|
|
|
188
235
|
* full, and since it holds nothing, a process whose only remaining handle it is can exit inside it --
|
|
189
236
|
* dropping the closing status this loop owes its consumer.
|
|
190
237
|
*/
|
|
191
|
-
const retry = () => new Promise<void>(resolve => {
|
|
238
|
+
const retry = (delayMs = 250) => new Promise<void>(resolve => {
|
|
192
239
|
if (signal.aborted) { resolve(); return; }
|
|
193
|
-
const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); },
|
|
240
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, delayMs);
|
|
194
241
|
timer.unref();
|
|
195
242
|
const aborted = () => { clearTimeout(timer); resolve(); };
|
|
196
243
|
signal.addEventListener('abort', aborted, { once: true });
|
|
@@ -208,19 +255,34 @@ export class AuthSwitchClient {
|
|
|
208
255
|
snapshot = undefined;
|
|
209
256
|
if (available) { available = false; await status('unavailable', reason); }
|
|
210
257
|
};
|
|
258
|
+
/**
|
|
259
|
+
* An older daemon answers every attempt the same way until it is restarted, so the consumer is told why once
|
|
260
|
+
* per outage, and the loop keeps trying at a slower pace: the restart that repairs it needs nothing else.
|
|
261
|
+
*/
|
|
262
|
+
let outdatedReported = false;
|
|
263
|
+
const unreadable = async (error: unknown): Promise<boolean> => {
|
|
264
|
+
if (!(error instanceof AuthSwitchDaemonOutdatedError)) return false;
|
|
265
|
+
snapshot = undefined;
|
|
266
|
+
available = false;
|
|
267
|
+
if (!outdatedReported) { outdatedReported = true; await status('unavailable', 'daemon_outdated'); }
|
|
268
|
+
await retry(5_000);
|
|
269
|
+
return true;
|
|
270
|
+
};
|
|
211
271
|
try {
|
|
212
272
|
if (!signal.aborted) await status('unavailable', 'initial');
|
|
213
273
|
while (!signal.aborted) {
|
|
214
274
|
if (!snapshot) {
|
|
215
275
|
let fresh: IAuthSwitchSnapshot;
|
|
216
|
-
try { fresh = await this.snapshotAll({ signal }); }
|
|
217
|
-
catch {
|
|
276
|
+
try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
|
|
277
|
+
catch (error) {
|
|
218
278
|
if (signal.aborted) break;
|
|
279
|
+
if (await unreadable(error)) continue;
|
|
219
280
|
await invalidate('disconnect');
|
|
220
281
|
await retry();
|
|
221
282
|
continue;
|
|
222
283
|
}
|
|
223
284
|
snapshot = fresh;
|
|
285
|
+
outdatedReported = false;
|
|
224
286
|
await onSnapshot(fresh);
|
|
225
287
|
available = true;
|
|
226
288
|
lastEpoch = fresh.epoch;
|
|
@@ -243,9 +305,10 @@ export class AuthSwitchClient {
|
|
|
243
305
|
}
|
|
244
306
|
if (result.events.length) {
|
|
245
307
|
let fresh: IAuthSwitchSnapshot;
|
|
246
|
-
try { fresh = await this.snapshotAll({ signal }); }
|
|
247
|
-
catch {
|
|
308
|
+
try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
|
|
309
|
+
catch (error) {
|
|
248
310
|
if (signal.aborted) break;
|
|
311
|
+
if (await unreadable(error)) continue;
|
|
249
312
|
await invalidate('disconnect');
|
|
250
313
|
await retry();
|
|
251
314
|
continue;
|
|
@@ -290,6 +353,16 @@ export class AuthSwitchClient {
|
|
|
290
353
|
return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
|
|
291
354
|
{ operationId }, 35_000, false, signal)).operation;
|
|
292
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* Waits up to `waitMs` for the sign-in to change past `afterRevision` and answers with it; a finished
|
|
358
|
+
* sign-in answers at once, and a timed-out wait with the unchanged operation. Follow a sign-in by passing
|
|
359
|
+
* each answer's `revision` back until its state is final.
|
|
360
|
+
*/
|
|
361
|
+
public async watchOperation(operationId: string, afterRevision: number, waitMs = 30_000,
|
|
362
|
+
signal?: AbortSignal): Promise<IReq_AuthSwitchGetOperation['response']['operation']> {
|
|
363
|
+
return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
|
|
364
|
+
{ operationId, afterRevision, waitMs }, waitMs + 5_000, false, signal)).operation;
|
|
365
|
+
}
|
|
293
366
|
public async cancelOperation(operationId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchCancelOperation['response']['operation']> {
|
|
294
367
|
return (await this.request<IReq_AuthSwitchCancelOperation>('authswitch.authority.cancel',
|
|
295
368
|
{ operationId }, 35_000, false, signal)).operation;
|
|
@@ -334,10 +407,23 @@ export class AuthSwitchClient {
|
|
|
334
407
|
return this.request<IReq_AuthSwitchClaudeNativeHandoffs>('authswitch.authority.claude.handoffs',
|
|
335
408
|
{ after, limit }, 35_000, false, signal);
|
|
336
409
|
}
|
|
410
|
+
/** The binding `bind` returned, credential-free, or null once this authority holds none by that id. */
|
|
411
|
+
public async getBinding(bindingId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchGetBinding['response']['binding']> {
|
|
412
|
+
return (await this.request<IReq_AuthSwitchGetBinding>('authswitch.authority.binding',
|
|
413
|
+
{ bindingId }, 35_000, false, signal)).binding;
|
|
414
|
+
}
|
|
337
415
|
public bindAccount(input: IReq_AuthSwitchBindAccount['request'],
|
|
338
416
|
signal?: AbortSignal): Promise<IReq_AuthSwitchBindAccount['response']> {
|
|
339
417
|
return this.request<IReq_AuthSwitchBindAccount>('authswitch.authority.bind', input, 35_000, false, signal);
|
|
340
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Releases the holder's own binding by the runtime, scope, incarnation and revision it was bound under,
|
|
421
|
+
* without its capability and without a ready login; `inactive` when no binding matches all four.
|
|
422
|
+
*/
|
|
423
|
+
public unbind(input: IReq_AuthSwitchUnbind['request'],
|
|
424
|
+
signal?: AbortSignal): Promise<IReq_AuthSwitchUnbind['response']> {
|
|
425
|
+
return this.request<IReq_AuthSwitchUnbind>('authswitch.authority.unbind', input, 35_000, false, signal);
|
|
426
|
+
}
|
|
341
427
|
public resolveAccess(input: IReq_AuthSwitchResolveAccess['request'],
|
|
342
428
|
signal?: AbortSignal): Promise<IReq_AuthSwitchResolveAccess['response']> {
|
|
343
429
|
return this.request<IReq_AuthSwitchResolveAccess>('authswitch.authority.resolveAccess',
|