@modelprofile.com/authswitch 8.2.0 → 9.1.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 +84 -1
- package/dist_ts/authority-contract.js +14 -2
- package/dist_ts/authority-import-contract.d.ts +6 -0
- package/dist_ts/authority-paths.d.ts +37 -0
- package/dist_ts/authority-paths.js +46 -0
- package/dist_ts/authority-runtime-contract.d.ts +11 -1
- package/dist_ts/classes.authoritybroker.d.ts +23 -8
- package/dist_ts/classes.authoritybroker.js +155 -32
- package/dist_ts/classes.authorityclient.d.ts +22 -2
- package/dist_ts/classes.authorityclient.js +98 -13
- package/dist_ts/classes.authoritydaemon.d.ts +21 -3
- package/dist_ts/classes.authoritydaemon.js +77 -32
- package/dist_ts/classes.authoritydatabase.d.ts +21 -3
- package/dist_ts/classes.authoritydatabase.js +98 -12
- package/dist_ts/classes.authorityimport.d.ts +16 -4
- package/dist_ts/classes.authorityimport.js +75 -24
- package/dist_ts/classes.authoritymodels.js +5 -3
- package/dist_ts/classes.authoritypreuse.js +8 -3
- package/dist_ts/classes.authorityservice.d.ts +10 -11
- package/dist_ts/classes.authorityservice.js +14 -23
- package/dist_ts/classes.cli.d.ts +10 -2
- package/dist_ts/classes.cli.js +12 -4
- package/dist_ts/classes.codexmanaged.d.ts +0 -1
- package/dist_ts/classes.codexmanaged.js +13 -26
- package/dist_ts/classes.legacyfence.d.ts +53 -0
- package/dist_ts/classes.legacyfence.js +189 -0
- package/dist_ts/classes.operations.d.ts +15 -3
- package/dist_ts/classes.operations.js +22 -4
- package/dist_ts/classes.service.d.ts +21 -2
- package/dist_ts/classes.service.js +35 -8
- package/dist_ts/classes.tui.d.ts +2 -1
- package/dist_ts/classes.tui.js +3 -2
- 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/dist_ts/ts_migration/legacysources/authswitchstores.js +5 -2
- package/package.json +11 -11
- package/readme.md +194 -24
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/authority-contract.ts +90 -4
- package/ts/authority-import-contract.ts +6 -0
- package/ts/authority-paths.ts +69 -0
- package/ts/authority-runtime-contract.ts +11 -1
- package/ts/classes.authoritybroker.ts +153 -33
- package/ts/classes.authorityclient.ts +102 -15
- package/ts/classes.authoritydaemon.ts +89 -27
- package/ts/classes.authoritydatabase.ts +98 -12
- package/ts/classes.authorityimport.ts +102 -25
- package/ts/classes.authoritymodels.ts +4 -1
- package/ts/classes.authoritypreuse.ts +7 -1
- package/ts/classes.authorityservice.ts +15 -30
- package/ts/classes.cli.ts +14 -3
- package/ts/classes.codexmanaged.ts +10 -19
- package/ts/classes.legacyfence.ts +219 -0
- package/ts/classes.operations.ts +22 -3
- package/ts/classes.service.ts +45 -8
- package/ts/classes.tui.ts +3 -1
- 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
- package/ts/ts_migration/legacysources/authswitchstores.ts +4 -1
|
@@ -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;
|
|
@@ -51,14 +50,61 @@ const tokenExpiry = (credential: TOpenAiCredential): string | null => {
|
|
|
51
50
|
catch { return null; }
|
|
52
51
|
};
|
|
53
52
|
const openAiGrantId = (accountId: string): string => idHash('authswitch-grant-v1', accountId, 'openai_managed', 'chatgpt');
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What a caller is told when the answer is decided. Each states the repair and names no id, path or host.
|
|
56
|
+
*
|
|
57
|
+
* They are written once because several sites answer the same question: an operation id this authority does
|
|
58
|
+
* not hold is the same answer whether a read, a cancel or a start met it, and a client that branches on the
|
|
59
|
+
* code must not have to tell those apart.
|
|
60
|
+
*/
|
|
61
|
+
const unknownOperation = 'This authority knows no device sign-in by that operation ID. '
|
|
62
|
+
+ 'Start a new sign-in and follow that one.';
|
|
63
|
+
const loginNotBindable = 'This login cannot back a runtime binding. The authority must hold the login '
|
|
64
|
+
+ 'itself, it must be the account\'s selected OpenAI login, and it must be ready. Reauthenticate the '
|
|
65
|
+
+ 'account or choose another one.';
|
|
66
|
+
const bindingUnauthorized = 'This runtime binding is not authorized. Bind the account again to obtain a '
|
|
67
|
+
+ 'capability for it.';
|
|
68
|
+
const bindingMoved = 'This runtime binding changed while its credential was being resolved. Bind the '
|
|
69
|
+
+ 'account again.';
|
|
70
|
+
const bindingNeedsReauth = 'The account no longer holds the login this binding was authorized for. '
|
|
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.';
|
|
54
76
|
const publicAccount = (account: IStoredAuthorityAccount): IAuthSwitchAccount => ({
|
|
55
77
|
id: account.id, providerId: account.providerId, label: account.label, email: account.email,
|
|
56
78
|
plan: account.plan, removed: account.removed, revision: account.revision,
|
|
57
79
|
statusObservedAt: account.statusObservedAt,
|
|
58
80
|
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Which native tool refreshes a grant, decided here because this is the one place that publishes a login.
|
|
84
|
+
*
|
|
85
|
+
* `owner` and `purpose` are what the authority stores; the tool is their meaning, and naming it here keeps
|
|
86
|
+
* every consumer from re-deriving it -- the derivation that is wrong as soon as two purposes share a tool,
|
|
87
|
+
* or one tool gains a second purpose. `daemon` and `none` have no native refresher at all, which is a value
|
|
88
|
+
* of its own rather than a missing one.
|
|
89
|
+
*/
|
|
90
|
+
const loginOwnerTool = (grant: IStoredAuthorityGrant): TAuthSwitchLoginOwnerTool | null => {
|
|
91
|
+
if (grant.owner === 'claude_native') return 'claude_code';
|
|
92
|
+
if (grant.owner !== 'legacy_native') return null;
|
|
93
|
+
switch (grant.purpose) {
|
|
94
|
+
case 'opencode_native': return 'opencode';
|
|
95
|
+
case 'openai_managed': return 'codex';
|
|
96
|
+
case 'claude_host_native': return 'claude_code';
|
|
97
|
+
// A container setup token is held for a container, not refreshed by a tool on this host.
|
|
98
|
+
case 'claude_container_setup': return null;
|
|
99
|
+
// A purpose added without deciding its tool fails the type check here rather than publishing `null`,
|
|
100
|
+
// which would claim the authority refreshes a login some tool actually holds.
|
|
101
|
+
default: { const unhandledPurpose: never = grant.purpose; return unhandledPurpose; }
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
59
105
|
const publicLogin = (grant: IStoredAuthorityGrant): IAuthSwitchLogin => ({
|
|
60
106
|
id: grant.id, accountId: grant.accountId, providerId: grant.providerId, purpose: grant.purpose,
|
|
61
|
-
owner: grant.owner,
|
|
107
|
+
owner: grant.owner, ownerTool: loginOwnerTool(grant),
|
|
62
108
|
health: grant.state === 'exchange_may_have_been_sent' ? 'refreshing'
|
|
63
109
|
: grant.state === 'native' ? 'unverified'
|
|
64
110
|
: grant.state === 'legacy_native_pending' || grant.state === 'handoff_pending' ? 'pending_handoff' : grant.state,
|
|
@@ -90,6 +136,12 @@ const publicBinding = (binding: IStoredAuthorityBinding): IAuthSwitchBinding =>
|
|
|
90
136
|
scopeId: binding.scopeId, incarnationId: binding.incarnationId, revision: binding.revision,
|
|
91
137
|
});
|
|
92
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
|
+
|
|
93
145
|
interface IManagedOperation {
|
|
94
146
|
id: string;
|
|
95
147
|
handle: plugins.flexAccounts.ISmartAiProviderLoginHandle;
|
|
@@ -111,6 +163,8 @@ export class AuthSwitchAuthorityBroker {
|
|
|
111
163
|
private readonly operations = new Map<string, IManagedOperation>();
|
|
112
164
|
private readonly refreshes = new Map<string, Promise<void>>();
|
|
113
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>();
|
|
114
168
|
private claudeRefresh?: (grantId: string) => Promise<void>;
|
|
115
169
|
private maintenance?: Promise<void>;
|
|
116
170
|
private timer?: NodeJS.Timeout;
|
|
@@ -224,19 +278,23 @@ export class AuthSwitchAuthorityBroker {
|
|
|
224
278
|
for (const listener of this.listeners) listener();
|
|
225
279
|
}
|
|
226
280
|
|
|
227
|
-
public async snapshot(options:
|
|
281
|
+
public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}): Promise<IAuthSwitchSnapshot> {
|
|
228
282
|
if ((options.accountAfter !== undefined && !isId(options.accountAfter))
|
|
229
283
|
|| (options.loginAfter !== undefined && !isId(options.loginAfter))
|
|
230
|
-
|| (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
|
+
}
|
|
231
288
|
const page = await this.database.page(options.accountAfter ?? null, options.loginAfter ?? null,
|
|
232
|
-
options.bindingAfter ?? null, options.limit ?? 128);
|
|
289
|
+
options.bindingAfter ?? null, options.nativeAssignmentAfter ?? null, options.limit ?? 128);
|
|
233
290
|
return { schemaVersion: 2, epoch: page.meta.epoch, revision: page.meta.revision,
|
|
234
291
|
generatedAt: new Date(this.now()).toISOString(),
|
|
235
|
-
accounts: page.accounts.filter(account => !account.removed).map(publicAccount),
|
|
236
|
-
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),
|
|
237
294
|
bindings: page.bindings.map(publicBinding),
|
|
295
|
+
nativeAssignments: page.claudeHomes.map(publicClaudeAssignment),
|
|
238
296
|
nextAccountCursor: page.nextAccountCursor, nextLoginCursor: page.nextGrantCursor,
|
|
239
|
-
nextBindingCursor: page.nextBindingCursor };
|
|
297
|
+
nextBindingCursor: page.nextBindingCursor, nextNativeAssignmentCursor: page.nextClaudeHomeCursor };
|
|
240
298
|
}
|
|
241
299
|
|
|
242
300
|
public async events(epoch: string, afterRevision: number, waitMs: number, signal?: AbortSignal): Promise<{
|
|
@@ -341,7 +399,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
341
399
|
private async finishStartingOperation(operationId: string, state: 'failed' | 'interrupted',
|
|
342
400
|
error: string): Promise<IStoredAuthorityDeviceOperation> {
|
|
343
401
|
const current = await this.database.readOperation(operationId);
|
|
344
|
-
if (!current || current.kind === 'preuse_openai') throw new
|
|
402
|
+
if (!current || current.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
345
403
|
if (deviceOperationTerminal(current)) return current;
|
|
346
404
|
if (current.state !== 'starting') throw new Error('Account operation already started its provider login.');
|
|
347
405
|
const updateId = plugins.crypto.randomUUID();
|
|
@@ -407,7 +465,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
407
465
|
let current: IStoredAuthorityDeviceOperation;
|
|
408
466
|
try {
|
|
409
467
|
const stored = await this.database.readOperation(id);
|
|
410
|
-
if (!stored || stored.kind === 'preuse_openai') throw new
|
|
468
|
+
if (!stored || stored.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
411
469
|
current = stored;
|
|
412
470
|
} catch (error) {
|
|
413
471
|
await Promise.allSettled([handle.cancel(), handle.close()]);
|
|
@@ -511,11 +569,46 @@ export class AuthSwitchAuthorityBroker {
|
|
|
511
569
|
return publicOperation(pending);
|
|
512
570
|
}
|
|
513
571
|
|
|
514
|
-
|
|
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> {
|
|
515
580
|
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
+
});
|
|
519
612
|
}
|
|
520
613
|
|
|
521
614
|
public async listOperations(after: string | null, limit = 128): Promise<{ operations: IAuthSwitchOperation[]; nextCursor: string | null }> {
|
|
@@ -538,7 +631,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
538
631
|
public async cancelOperation(operationId: string): Promise<IAuthSwitchOperation> {
|
|
539
632
|
if (!isUuid(operationId)) throw new Error('Invalid operation ID.');
|
|
540
633
|
const current = await this.database.readOperation(operationId);
|
|
541
|
-
if (!current || current.kind === 'preuse_openai') throw new
|
|
634
|
+
if (!current || current.kind === 'preuse_openai') throw new AuthSwitchRefusal('not_found', unknownOperation);
|
|
542
635
|
if (current.state === 'committing') throw new Error('Login is completing; check its operation status.');
|
|
543
636
|
if (current.state !== 'starting' && current.state !== 'pending') return publicOperation(current);
|
|
544
637
|
const updateId = plugins.crypto.randomUUID();
|
|
@@ -552,7 +645,11 @@ export class AuthSwitchAuthorityBroker {
|
|
|
552
645
|
}
|
|
553
646
|
|
|
554
647
|
public async renameAccount(accountId: string, expectedRevision: number, label: string): Promise<IAuthSwitchAccount> {
|
|
555
|
-
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
|
+
}
|
|
556
653
|
const updateId = plugins.crypto.randomUUID();
|
|
557
654
|
const updated = await this.database.changeAccount(updateId, accountId, account => {
|
|
558
655
|
if (!account || account.removed || account.revision !== expectedRevision) throw new AuthSwitchRefusal('account_changed', 'Account changed; refresh before editing.');
|
|
@@ -582,11 +679,12 @@ export class AuthSwitchAuthorityBroker {
|
|
|
582
679
|
const capabilityHash = idHash(capability);
|
|
583
680
|
const updateId = plugins.crypto.randomUUID();
|
|
584
681
|
const updated = await this.database.changeBinding(updateId, id, input.accountId, input.loginId, (existing, account, grant) => {
|
|
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).
|
|
585
684
|
if (account.removed || grant?.state !== 'ready' || grant.id !== account.primaryGrantId
|
|
586
685
|
|| grant.owner !== 'daemon'
|
|
587
|
-
|| grant.purpose !== input.purpose || account.providerId !== 'openai'
|
|
588
|
-
|
|
589
|
-
throw new Error('Account is not available for this runtime.');
|
|
686
|
+
|| grant.purpose !== input.purpose || account.providerId !== 'openai') {
|
|
687
|
+
throw new AuthSwitchRefusal('login_unavailable', loginNotBindable);
|
|
590
688
|
}
|
|
591
689
|
return { id, accountId: input.accountId, grantId: grant.id, runtime: input.runtime, scopeId: input.scopeId,
|
|
592
690
|
incarnationId: input.incarnationId, revision: (existing?.revision ?? 0) + 1,
|
|
@@ -596,6 +694,12 @@ export class AuthSwitchAuthorityBroker {
|
|
|
596
694
|
return { binding: publicBinding(updated.binding), capability };
|
|
597
695
|
}
|
|
598
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
|
+
|
|
599
703
|
public async revokeBinding(bindingId: string, capability: string): Promise<boolean> {
|
|
600
704
|
if (!isId(bindingId) || !/^[A-Za-z0-9_-]{43}$/.test(capability)) throw new Error('Invalid runtime binding revocation.');
|
|
601
705
|
const revoked = await this.database.revokeBinding(plugins.crypto.randomUUID(), bindingId,
|
|
@@ -624,21 +728,27 @@ export class AuthSwitchAuthorityBroker {
|
|
|
624
728
|
const current = await this.database.readBindingContext(bindingId);
|
|
625
729
|
const binding = current.binding;
|
|
626
730
|
const expected = Buffer.from(binding?.capabilityHash ?? '0'.repeat(64), 'hex');
|
|
731
|
+
// A missing binding and a wrong capability are compared against the same zero hash on purpose, so
|
|
732
|
+
// nothing here tells a caller which it was. One code preserves exactly that, and it is truthful for
|
|
733
|
+
// both: this capability does not authorize anything, and binding again is the only repair.
|
|
627
734
|
if (!binding || !plugins.crypto.timingSafeEqual(supplied, expected)) {
|
|
628
|
-
throw new
|
|
735
|
+
throw new AuthSwitchRefusal('binding_unauthorized', bindingUnauthorized);
|
|
629
736
|
}
|
|
630
737
|
if (originalBinding && (binding.id !== originalBinding.id
|
|
631
738
|
|| binding.capabilityHash !== originalBinding.capabilityHash
|
|
632
739
|
|| binding.accountId !== originalBinding.accountId || binding.grantId !== originalBinding.grantId
|
|
633
740
|
|| binding.incarnationId !== originalBinding.incarnationId
|
|
634
741
|
|| binding.grantAuthorizationGeneration !== originalBinding.grantAuthorizationGeneration)) {
|
|
635
|
-
|
|
742
|
+
// The capability still matched, so what moved is the binding itself: another incarnation took the
|
|
743
|
+
// scope, or the account was reauthorized under it. The caller's repair is the one above -- bind
|
|
744
|
+
// again -- so it is the same code; a second one would name a difference no runtime can act on.
|
|
745
|
+
throw new AuthSwitchRefusal('binding_unauthorized', bindingMoved);
|
|
636
746
|
}
|
|
637
747
|
originalBinding ??= binding;
|
|
638
748
|
if (!current.account || current.account.removed || !current.grant
|
|
639
749
|
|| current.grant.id !== binding.grantId
|
|
640
750
|
|| current.grant.authorizationGeneration !== binding.grantAuthorizationGeneration) {
|
|
641
|
-
throw new
|
|
751
|
+
throw new AuthSwitchRefusal('login_unavailable', bindingNeedsReauth);
|
|
642
752
|
}
|
|
643
753
|
return { account: current.account, grant: current.grant };
|
|
644
754
|
}, minValidityMs, rejectedGrantGeneration);
|
|
@@ -660,6 +770,14 @@ export class AuthSwitchAuthorityBroker {
|
|
|
660
770
|
}, minValidityMs, rejectedGrantGeneration);
|
|
661
771
|
}
|
|
662
772
|
|
|
773
|
+
/**
|
|
774
|
+
* The shared managed-access loop. What it decides about the login itself is a marked refusal both callers
|
|
775
|
+
* read the same way: `login_needs_reauth` when only a new sign-in brings the login back, `access_not_fresh`
|
|
776
|
+
* when the login is intact and the provider could not renew it yet. The usage reader still folds either
|
|
777
|
+
* into its own problem; a bound runtime shows the instruction. What only concerns a binding is decided
|
|
778
|
+
* before this point, in the view `resolveAccess` supplies, and a changed identity or a view that keeps
|
|
779
|
+
* moving stays an unmarked fault, because nobody decided it.
|
|
780
|
+
*/
|
|
663
781
|
private async resolveManagedAccess(readView: () => Promise<{
|
|
664
782
|
account: IStoredAuthorityAccount; grant: IStoredAuthorityGrant;
|
|
665
783
|
}>, minValidityMs: number, rejectedGrantGeneration?: number): Promise<IAuthSwitchResolvedAccess> {
|
|
@@ -667,20 +785,20 @@ export class AuthSwitchAuthorityBroker {
|
|
|
667
785
|
const { account, grant } = await readView();
|
|
668
786
|
if (account.removed || grant.accountId !== account.id || grant.id !== account.primaryGrantId
|
|
669
787
|
|| account.providerId !== 'openai' || grant.providerId !== 'openai') {
|
|
670
|
-
throw new
|
|
788
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
671
789
|
}
|
|
672
790
|
if (rejectedGrantGeneration !== undefined && rejectedGrantGeneration > grant.grantGeneration) {
|
|
673
791
|
throw new Error('Provider rejected a future account grant generation.');
|
|
674
792
|
}
|
|
675
793
|
if (grant.state === 'exchange_may_have_been_sent') {
|
|
676
794
|
const inFlight = this.refreshes.get(account.id);
|
|
677
|
-
if (!inFlight) throw new
|
|
795
|
+
if (!inFlight) throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
678
796
|
await inFlight;
|
|
679
797
|
continue;
|
|
680
798
|
}
|
|
681
799
|
if (grant.owner !== 'daemon' || grant.purpose !== 'openai_managed'
|
|
682
800
|
|| !['ready', 'retry_wait'].includes(grant.state)) {
|
|
683
|
-
throw new
|
|
801
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
684
802
|
}
|
|
685
803
|
const rejectedCurrent = rejectedGrantGeneration === grant.grantGeneration;
|
|
686
804
|
if (this.isDue(grant, minValidityMs) || rejectedCurrent) {
|
|
@@ -689,9 +807,9 @@ export class AuthSwitchAuthorityBroker {
|
|
|
689
807
|
rejectedCurrent ? rejectedGrantGeneration : undefined);
|
|
690
808
|
continue;
|
|
691
809
|
}
|
|
692
|
-
throw new
|
|
810
|
+
throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
693
811
|
}
|
|
694
|
-
if (!grant.accessExpiresAt) throw new
|
|
812
|
+
if (!grant.accessExpiresAt) throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
695
813
|
const credential = await this.unsealCredential(account, grant);
|
|
696
814
|
const info = plugins.flexAuth.parseOpenAiChatGptTokenInfo(credential.accessToken);
|
|
697
815
|
if (info.chatgptAccountId !== account.workspaceId || info.chatgptUserId !== account.subject) {
|
|
@@ -709,7 +827,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
709
827
|
|| latest.grant.state === 'exchange_may_have_been_sent') continue;
|
|
710
828
|
if (!['ready', 'retry_wait'].includes(latest.grant.state) || this.isDue(latest.grant, minValidityMs)
|
|
711
829
|
|| (rejectedGrantGeneration !== undefined && latest.grant.grantGeneration === rejectedGrantGeneration)) {
|
|
712
|
-
throw new
|
|
830
|
+
throw new AuthSwitchRefusal('access_not_fresh', accessNotFresh);
|
|
713
831
|
}
|
|
714
832
|
return { accessToken: credential.accessToken, accountId: account.workspaceId,
|
|
715
833
|
isFedrampAccount: info.chatgptAccountIsFedramp, expiresAt: grant.accessExpiresAt,
|
|
@@ -794,7 +912,8 @@ export class AuthSwitchAuthorityBroker {
|
|
|
794
912
|
});
|
|
795
913
|
this.publish();
|
|
796
914
|
} catch { /* A persisted attempt marker forces needs_reauth on daemon restart. */ }
|
|
797
|
-
|
|
915
|
+
// The grant is now `needs_reauth`: an uncertain rotation is never replayed, so only a sign-in repairs it.
|
|
916
|
+
throw new AuthSwitchRefusal('login_needs_reauth', loginNeedsReauth);
|
|
798
917
|
}
|
|
799
918
|
}
|
|
800
919
|
|
|
@@ -803,6 +922,7 @@ export class AuthSwitchAuthorityBroker {
|
|
|
803
922
|
this.closed = true;
|
|
804
923
|
if (this.timer) clearTimeout(this.timer);
|
|
805
924
|
for (const wake of this.listeners) wake();
|
|
925
|
+
for (const wake of this.operationWaiters) wake();
|
|
806
926
|
this.closing = (async () => {
|
|
807
927
|
if (this.maintenance) await this.maintenance;
|
|
808
928
|
await Promise.allSettled([...this.operations.values()].map(item => item.handle.cancel()));
|
|
@@ -5,7 +5,8 @@ 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';
|
|
@@ -53,10 +54,36 @@ const post = (path: string, payload: ITypedRequest, signal?: AbortSignal): Promi
|
|
|
53
54
|
});
|
|
54
55
|
};
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The daemon answered with an older contract than this client reads: it runs an authswitch release from before
|
|
59
|
+
* this one and was not restarted after the upgrade. Restarting it onto the installed release is the repair.
|
|
60
|
+
*/
|
|
61
|
+
export class AuthSwitchDaemonOutdatedError extends Error {
|
|
62
|
+
constructor() {
|
|
63
|
+
super('The authswitch authority daemon runs an older release than this client. Restart it onto the '
|
|
64
|
+
+ 'installed authswitch: authswitch authority service stop, then authswitch authority service start.');
|
|
65
|
+
this.name = 'AuthSwitchDaemonOutdatedError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A snapshot page carries every member of this release's contract. `schemaVersion` is not bumped for an
|
|
71
|
+
* additive member, so an older daemon is told apart by the members its answer lacks.
|
|
72
|
+
*/
|
|
73
|
+
const assertCurrentSnapshot = (snapshot: IAuthSwitchSnapshot): IAuthSwitchSnapshot => {
|
|
74
|
+
const page: Partial<IAuthSwitchSnapshot> = snapshot;
|
|
75
|
+
if (!Array.isArray(page.nativeAssignments) || page.nextNativeAssignmentCursor === undefined) {
|
|
76
|
+
throw new AuthSwitchDaemonOutdatedError();
|
|
77
|
+
}
|
|
78
|
+
return snapshot;
|
|
79
|
+
};
|
|
80
|
+
|
|
56
81
|
/** A snapshot is usable only while state is current; lastVerifiedAt is the last successful daemon proof. */
|
|
57
82
|
export interface IAuthSwitchSubscriptionStatus {
|
|
58
83
|
state: 'current' | 'unavailable' | 'closed';
|
|
59
|
-
|
|
84
|
+
/** `daemon_outdated`: the daemon runs an older release; see `AuthSwitchDaemonOutdatedError`. */
|
|
85
|
+
reason: 'initial' | 'snapshot' | 'event' | 'heartbeat' | 'disconnect' | 'resync' | 'abort' | 'consumer_error'
|
|
86
|
+
| 'daemon_outdated';
|
|
60
87
|
epoch: string | null;
|
|
61
88
|
revision: number | null;
|
|
62
89
|
lastVerifiedAt: string | null;
|
|
@@ -66,6 +93,8 @@ export interface IAuthSwitchSubscriptionOptions {
|
|
|
66
93
|
onStatus?: (status: IAuthSwitchSubscriptionStatus) => void | Promise<void>;
|
|
67
94
|
/** Bounded long-poll interval; defaults to 30 seconds. */
|
|
68
95
|
heartbeatMs?: number;
|
|
96
|
+
/** Deliver snapshots that also carry removed accounts and logins; see `IReq_AuthSwitchSnapshot`. */
|
|
97
|
+
includeRemoved?: boolean;
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
/**
|
|
@@ -96,8 +125,10 @@ export class AuthSwitchClient {
|
|
|
96
125
|
.fire(request, { timeoutMs, maxRetries: 0, abortSignal: signal });
|
|
97
126
|
}
|
|
98
127
|
|
|
128
|
+
/** One snapshot page; an older daemon's answer fails with `AuthSwitchDaemonOutdatedError`. */
|
|
99
129
|
public async snapshot(options: IReq_AuthSwitchSnapshot['request'] = {}, signal?: AbortSignal): Promise<IAuthSwitchSnapshot> {
|
|
100
|
-
return (await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot',
|
|
130
|
+
return assertCurrentSnapshot((await this.request<IReq_AuthSwitchSnapshot>('authswitch.authority.snapshot',
|
|
131
|
+
options, 35_000, false, signal)).snapshot);
|
|
101
132
|
}
|
|
102
133
|
|
|
103
134
|
/** One independently stamped diagnostic page; callers may continue with its independent cursors. */
|
|
@@ -132,30 +163,39 @@ export class AuthSwitchClient {
|
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
/** 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> {
|
|
166
|
+
public async snapshotAll(options: { limit?: number; includeRemoved?: boolean; signal?: AbortSignal } = {}): Promise<IAuthSwitchSnapshot> {
|
|
136
167
|
while (true) {
|
|
137
168
|
if (options.signal?.aborted) throw options.signal.reason ?? new Error('Account snapshot cancelled.');
|
|
138
169
|
let accountAfter: string | undefined;
|
|
139
170
|
let loginAfter: string | undefined;
|
|
140
171
|
let bindingAfter: string | undefined;
|
|
172
|
+
let nativeAssignmentAfter: string | undefined;
|
|
141
173
|
let aggregate: IAuthSwitchSnapshot | undefined;
|
|
142
174
|
let changed = false;
|
|
143
175
|
while (true) {
|
|
144
|
-
const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter,
|
|
145
|
-
|
|
146
|
-
|
|
176
|
+
const page = await this.snapshot({ accountAfter, loginAfter, bindingAfter, nativeAssignmentAfter,
|
|
177
|
+
limit: options.limit, ...(options.includeRemoved ? { includeRemoved: true } : {}) }, options.signal);
|
|
178
|
+
if (!aggregate) {
|
|
179
|
+
aggregate = { ...page, accounts: [...page.accounts], logins: [...page.logins], bindings: [...page.bindings],
|
|
180
|
+
nativeAssignments: [...page.nativeAssignments] };
|
|
181
|
+
} else if (aggregate.epoch !== page.epoch || aggregate.revision !== page.revision) {
|
|
147
182
|
changed = true;
|
|
148
183
|
break;
|
|
149
184
|
} else {
|
|
150
185
|
aggregate.accounts.push(...page.accounts);
|
|
151
186
|
aggregate.logins.push(...page.logins);
|
|
152
187
|
aggregate.bindings.push(...page.bindings);
|
|
188
|
+
aggregate.nativeAssignments.push(...page.nativeAssignments);
|
|
153
189
|
}
|
|
154
190
|
accountAfter = page.nextAccountCursor ?? accountAfter ?? page.accounts.at(-1)?.id;
|
|
155
191
|
loginAfter = page.nextLoginCursor ?? loginAfter ?? page.logins.at(-1)?.id;
|
|
156
192
|
bindingAfter = page.nextBindingCursor ?? bindingAfter ?? page.bindings.at(-1)?.id;
|
|
157
|
-
|
|
158
|
-
|
|
193
|
+
nativeAssignmentAfter = page.nextNativeAssignmentCursor ?? nativeAssignmentAfter
|
|
194
|
+
?? page.nativeAssignments.at(-1)?.id;
|
|
195
|
+
if (!page.nextAccountCursor && !page.nextLoginCursor && !page.nextBindingCursor
|
|
196
|
+
&& !page.nextNativeAssignmentCursor) {
|
|
197
|
+
return { ...aggregate, nextAccountCursor: null, nextLoginCursor: null, nextBindingCursor: null,
|
|
198
|
+
nextNativeAssignmentCursor: null };
|
|
159
199
|
}
|
|
160
200
|
}
|
|
161
201
|
if (!changed) throw new Error('Authority snapshot could not complete.');
|
|
@@ -174,8 +214,24 @@ export class AuthSwitchClient {
|
|
|
174
214
|
if (!Number.isSafeInteger(heartbeatMs) || heartbeatMs < 50 || heartbeatMs > 30_000) {
|
|
175
215
|
throw new Error('Authswitch subscription heartbeat must be 50–30000 ms.');
|
|
176
216
|
}
|
|
177
|
-
|
|
178
|
-
|
|
217
|
+
/**
|
|
218
|
+
* The pause between reconnection attempts, which never keeps its consumer's process alive.
|
|
219
|
+
*
|
|
220
|
+
* A subscription that cannot reach the daemon re-arms this timer for as long as that lasts, and during
|
|
221
|
+
* each pause it is the only handle this client holds -- the failed request already destroyed its socket.
|
|
222
|
+
* Referenced, that chain of 250 ms waits would hold an otherwise finished process open indefinitely,
|
|
223
|
+
* and it has nothing to finish: the wait itself does no work, and an abort clears it.
|
|
224
|
+
*
|
|
225
|
+
* An abort that already happened is checked before the timer is armed, because a signal fires once: a
|
|
226
|
+
* consumer that stops from inside its own disconnect callback aborts between this loop's last check and
|
|
227
|
+
* this call, and a listener added afterwards would never run. The pause would then be waited out in
|
|
228
|
+
* full, and since it holds nothing, a process whose only remaining handle it is can exit inside it --
|
|
229
|
+
* dropping the closing status this loop owes its consumer.
|
|
230
|
+
*/
|
|
231
|
+
const retry = (delayMs = 250) => new Promise<void>(resolve => {
|
|
232
|
+
if (signal.aborted) { resolve(); return; }
|
|
233
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', aborted); resolve(); }, delayMs);
|
|
234
|
+
timer.unref();
|
|
179
235
|
const aborted = () => { clearTimeout(timer); resolve(); };
|
|
180
236
|
signal.addEventListener('abort', aborted, { once: true });
|
|
181
237
|
});
|
|
@@ -192,19 +248,34 @@ export class AuthSwitchClient {
|
|
|
192
248
|
snapshot = undefined;
|
|
193
249
|
if (available) { available = false; await status('unavailable', reason); }
|
|
194
250
|
};
|
|
251
|
+
/**
|
|
252
|
+
* An older daemon answers every attempt the same way until it is restarted, so the consumer is told why once
|
|
253
|
+
* per outage, and the loop keeps trying at a slower pace: the restart that repairs it needs nothing else.
|
|
254
|
+
*/
|
|
255
|
+
let outdatedReported = false;
|
|
256
|
+
const unreadable = async (error: unknown): Promise<boolean> => {
|
|
257
|
+
if (!(error instanceof AuthSwitchDaemonOutdatedError)) return false;
|
|
258
|
+
snapshot = undefined;
|
|
259
|
+
available = false;
|
|
260
|
+
if (!outdatedReported) { outdatedReported = true; await status('unavailable', 'daemon_outdated'); }
|
|
261
|
+
await retry(5_000);
|
|
262
|
+
return true;
|
|
263
|
+
};
|
|
195
264
|
try {
|
|
196
265
|
if (!signal.aborted) await status('unavailable', 'initial');
|
|
197
266
|
while (!signal.aborted) {
|
|
198
267
|
if (!snapshot) {
|
|
199
268
|
let fresh: IAuthSwitchSnapshot;
|
|
200
|
-
try { fresh = await this.snapshotAll({ signal }); }
|
|
201
|
-
catch {
|
|
269
|
+
try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
|
|
270
|
+
catch (error) {
|
|
202
271
|
if (signal.aborted) break;
|
|
272
|
+
if (await unreadable(error)) continue;
|
|
203
273
|
await invalidate('disconnect');
|
|
204
274
|
await retry();
|
|
205
275
|
continue;
|
|
206
276
|
}
|
|
207
277
|
snapshot = fresh;
|
|
278
|
+
outdatedReported = false;
|
|
208
279
|
await onSnapshot(fresh);
|
|
209
280
|
available = true;
|
|
210
281
|
lastEpoch = fresh.epoch;
|
|
@@ -227,9 +298,10 @@ export class AuthSwitchClient {
|
|
|
227
298
|
}
|
|
228
299
|
if (result.events.length) {
|
|
229
300
|
let fresh: IAuthSwitchSnapshot;
|
|
230
|
-
try { fresh = await this.snapshotAll({ signal }); }
|
|
231
|
-
catch {
|
|
301
|
+
try { fresh = await this.snapshotAll({ signal, includeRemoved: options.includeRemoved }); }
|
|
302
|
+
catch (error) {
|
|
232
303
|
if (signal.aborted) break;
|
|
304
|
+
if (await unreadable(error)) continue;
|
|
233
305
|
await invalidate('disconnect');
|
|
234
306
|
await retry();
|
|
235
307
|
continue;
|
|
@@ -274,6 +346,16 @@ export class AuthSwitchClient {
|
|
|
274
346
|
return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
|
|
275
347
|
{ operationId }, 35_000, false, signal)).operation;
|
|
276
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Waits up to `waitMs` for the sign-in to change past `afterRevision` and answers with it; a finished
|
|
351
|
+
* sign-in answers at once, and a timed-out wait with the unchanged operation. Follow a sign-in by passing
|
|
352
|
+
* each answer's `revision` back until its state is final.
|
|
353
|
+
*/
|
|
354
|
+
public async watchOperation(operationId: string, afterRevision: number, waitMs = 30_000,
|
|
355
|
+
signal?: AbortSignal): Promise<IReq_AuthSwitchGetOperation['response']['operation']> {
|
|
356
|
+
return (await this.request<IReq_AuthSwitchGetOperation>('authswitch.authority.operation',
|
|
357
|
+
{ operationId, afterRevision, waitMs }, waitMs + 5_000, false, signal)).operation;
|
|
358
|
+
}
|
|
277
359
|
public async cancelOperation(operationId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchCancelOperation['response']['operation']> {
|
|
278
360
|
return (await this.request<IReq_AuthSwitchCancelOperation>('authswitch.authority.cancel',
|
|
279
361
|
{ operationId }, 35_000, false, signal)).operation;
|
|
@@ -318,6 +400,11 @@ export class AuthSwitchClient {
|
|
|
318
400
|
return this.request<IReq_AuthSwitchClaudeNativeHandoffs>('authswitch.authority.claude.handoffs',
|
|
319
401
|
{ after, limit }, 35_000, false, signal);
|
|
320
402
|
}
|
|
403
|
+
/** The binding `bind` returned, credential-free, or null once this authority holds none by that id. */
|
|
404
|
+
public async getBinding(bindingId: string, signal?: AbortSignal): Promise<IReq_AuthSwitchGetBinding['response']['binding']> {
|
|
405
|
+
return (await this.request<IReq_AuthSwitchGetBinding>('authswitch.authority.binding',
|
|
406
|
+
{ bindingId }, 35_000, false, signal)).binding;
|
|
407
|
+
}
|
|
321
408
|
public bindAccount(input: IReq_AuthSwitchBindAccount['request'],
|
|
322
409
|
signal?: AbortSignal): Promise<IReq_AuthSwitchBindAccount['response']> {
|
|
323
410
|
return this.request<IReq_AuthSwitchBindAccount>('authswitch.authority.bind', input, 35_000, false, signal);
|