@icp-sdk/auth 10.0.1 → 11.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/README.md +9 -123
- package/dist/esm/client/auth-client.d.ts +64 -17
- package/dist/esm/client/auth-client.js +118 -86
- package/dist/esm/client/auth-client.js.map +1 -1
- package/dist/esm/client/index.d.ts +1 -1
- package/dist/esm/client/index.js +0 -1
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/session-delegation.d.ts +8 -6
- package/dist/esm/client/session-delegation.js +22 -17
- package/dist/esm/client/session-delegation.js.map +1 -1
- package/dist/esm/client/sso-status.d.ts +91 -0
- package/dist/esm/client/sso-status.js +207 -0
- package/dist/esm/client/sso-status.js.map +1 -0
- package/dist/esm/client/sso.d.ts +3 -26
- package/dist/esm/client/sso.js +75 -89
- package/dist/esm/client/sso.js.map +1 -1
- package/package.json +2 -2
- package/src/client/auth-client.ts +146 -93
- package/src/client/index.ts +1 -1
- package/src/client/session-delegation.ts +33 -23
- package/src/client/sso-status.ts +257 -0
- package/src/client/sso.ts +77 -92
package/dist/esm/client/sso.js
CHANGED
|
@@ -1,120 +1,106 @@
|
|
|
1
1
|
// The identity provider's cap on the domain it will fetch.
|
|
2
2
|
const MAX_AUTHORITY_LENGTH = 255;
|
|
3
|
-
|
|
3
|
+
// The identity provider's own limits on a DNS name (idna's strict mode).
|
|
4
|
+
const MAX_DNS_NAME_LENGTH = 253;
|
|
5
|
+
const MAX_DNS_LABEL_LENGTH = 63;
|
|
6
|
+
const MAX_QUOTED_LENGTH = 100;
|
|
7
|
+
/** `value` for an error message: cut short, and quoted so control characters show escaped. */
|
|
8
|
+
function quote(value) {
|
|
9
|
+
return JSON.stringify(value.length > MAX_QUOTED_LENGTH ? `${value.slice(0, MAX_QUOTED_LENGTH)}…` : value);
|
|
10
|
+
}
|
|
4
11
|
/**
|
|
5
|
-
* `localhost
|
|
6
|
-
*
|
|
7
|
-
*
|
|
12
|
+
* `localhost`, `127.0.0.1`, or a name under `.localhost` (RFC 6761 reserves the
|
|
13
|
+
* whole domain for loopback), like the identity provider's own check. IPv6
|
|
14
|
+
* loopback is not one, there either.
|
|
8
15
|
*/
|
|
9
|
-
function isLoopbackHost(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
function isLoopbackHost(hostname) {
|
|
17
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname.endsWith('.localhost');
|
|
18
|
+
}
|
|
19
|
+
/** What keeps `hostname` from being a DNS name, by the identity provider's rules. */
|
|
20
|
+
function dnsNameProblems(hostname) {
|
|
21
|
+
const labels = hostname.split('.');
|
|
22
|
+
const problems = [];
|
|
23
|
+
if (labels.length < 2)
|
|
24
|
+
problems.push('a single label');
|
|
25
|
+
if (labels.some((label) => label.length === 0))
|
|
26
|
+
problems.push('an empty label');
|
|
27
|
+
if (labels.some((label) => label.length > MAX_DNS_LABEL_LENGTH)) {
|
|
28
|
+
problems.push(`a label longer than ${MAX_DNS_LABEL_LENGTH} characters`);
|
|
15
29
|
}
|
|
16
|
-
|
|
17
|
-
|
|
30
|
+
if (labels.some((label) => /[^a-z0-9-]/.test(label))) {
|
|
31
|
+
problems.push('a character other than a letter, digit, or hyphen');
|
|
32
|
+
}
|
|
33
|
+
if (labels.some((label) => label.startsWith('-') || label.endsWith('-'))) {
|
|
34
|
+
problems.push('a label starting or ending with a hyphen');
|
|
18
35
|
}
|
|
19
|
-
//
|
|
20
|
-
if (
|
|
21
|
-
|
|
36
|
+
// `--` in the third and fourth places is reserved, except for punycode.
|
|
37
|
+
if (labels.some((label) => label.slice(2, 4) === '--' && !label.startsWith('xn--'))) {
|
|
38
|
+
problems.push('a label with "--" in its third and fourth places');
|
|
22
39
|
}
|
|
23
|
-
|
|
40
|
+
if (hostname.length > MAX_DNS_NAME_LENGTH) {
|
|
41
|
+
problems.push(`a name longer than ${MAX_DNS_NAME_LENGTH} characters`);
|
|
42
|
+
}
|
|
43
|
+
return problems;
|
|
44
|
+
}
|
|
45
|
+
function listed(items) {
|
|
46
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
24
47
|
}
|
|
25
48
|
/**
|
|
26
49
|
* Normalizes an SSO domain to the authority the identity provider fetches the
|
|
27
|
-
* discovery document from: lowercased
|
|
50
|
+
* discovery document from: lowercased and IDNA-encoded. A loopback host may
|
|
51
|
+
* carry a port, for a local mock provider; any other host may not.
|
|
28
52
|
*
|
|
29
53
|
* @param domain - The organization domain.
|
|
30
54
|
* @returns The normalized authority.
|
|
31
|
-
* @throws When `domain` is not a domain
|
|
55
|
+
* @throws When `domain` is not a domain, naming every problem with it.
|
|
32
56
|
*/
|
|
33
57
|
export function normalizeSsoDomain(domain) {
|
|
34
58
|
const trimmed = domain.trim();
|
|
35
59
|
if (trimmed.length === 0) {
|
|
36
60
|
throw new Error('ssoDomain cannot be empty');
|
|
37
61
|
}
|
|
62
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(trimmed)?.[0];
|
|
63
|
+
const rest = scheme === undefined ? trimmed : trimmed.slice(scheme.length);
|
|
38
64
|
let url;
|
|
39
65
|
try {
|
|
40
|
-
url = new URL(`https://${
|
|
66
|
+
url = new URL(`https://${rest}`);
|
|
41
67
|
}
|
|
42
68
|
catch {
|
|
43
|
-
throw new Error(`ssoDomain is not a domain
|
|
44
|
-
}
|
|
45
|
-
const authority = url.host;
|
|
46
|
-
// Rebuilding from the host and port alone has to reproduce what was parsed,
|
|
47
|
-
// so anything else the domain carried shows up as a difference.
|
|
48
|
-
if (new URL(`https://${authority}`).href !== url.href) {
|
|
49
|
-
throw new Error(`ssoDomain must be a domain and optional port, nothing else: ${trimmed}`);
|
|
69
|
+
throw new Error(`ssoDomain ${quote(trimmed)} is not a domain`);
|
|
50
70
|
}
|
|
51
|
-
|
|
52
|
-
|
|
71
|
+
const { username, password, pathname, hostname, port, host, href } = url;
|
|
72
|
+
// `URL` drops an empty or default port such as `:443`, so the typed one is read
|
|
73
|
+
// from the input itself.
|
|
74
|
+
const typedAuthority = rest.split(/[/?#]/)[0] ?? '';
|
|
75
|
+
const typedPort = /:([0-9]*)$/.exec(typedAuthority.slice(typedAuthority.lastIndexOf('@') + 1))?.[1];
|
|
76
|
+
const [beforeFragment = ''] = href.split('#');
|
|
77
|
+
const problems = [];
|
|
78
|
+
if (scheme !== undefined)
|
|
79
|
+
problems.push('a scheme');
|
|
80
|
+
if (username !== '' || password !== '')
|
|
81
|
+
problems.push('userinfo');
|
|
82
|
+
if (pathname !== '/')
|
|
83
|
+
problems.push('a path');
|
|
84
|
+
if (beforeFragment.includes('?'))
|
|
85
|
+
problems.push('a query');
|
|
86
|
+
if (href.includes('#'))
|
|
87
|
+
problems.push('a fragment');
|
|
88
|
+
const loopback = isLoopbackHost(hostname);
|
|
89
|
+
if (typedPort !== undefined && !loopback) {
|
|
90
|
+
problems.push('a port, which only loopback hosts may carry');
|
|
53
91
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
throw new Error(`ssoDomain is not a domain name: ${trimmed}`);
|
|
92
|
+
else if (typedPort !== undefined && (typedPort === '' || typedPort !== port)) {
|
|
93
|
+
problems.push('an empty or default port');
|
|
57
94
|
}
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
function publishesSsoConfiguration(value) {
|
|
61
|
-
if (typeof value !== 'object' || value === null) {
|
|
62
|
-
return false;
|
|
95
|
+
if (trimmed.length > MAX_AUTHORITY_LENGTH) {
|
|
96
|
+
problems.push(`more than ${MAX_AUTHORITY_LENGTH} characters`);
|
|
63
97
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Checks whether an organization domain can be used for SSO sign-in: it is a
|
|
69
|
-
* domain name, and it publishes `/.well-known/ii-openid-configuration` with a
|
|
70
|
-
* `client_id` and an `openid_configuration` URL.
|
|
71
|
-
*
|
|
72
|
-
* The document must be served with `Access-Control-Allow-Origin: *`.
|
|
73
|
-
*
|
|
74
|
-
* The check waits on a third-party server and has no deadline of its own, so
|
|
75
|
-
* `signal` sets one. Pass `AbortSignal.timeout(...)` for a one-shot check, or a
|
|
76
|
-
* controller you abort per keystroke when checking as the user types.
|
|
77
|
-
*
|
|
78
|
-
* @param domain - The organization domain.
|
|
79
|
-
* @param signal - Bounds the check.
|
|
80
|
-
* @returns Whether the domain is usable for SSO sign-in.
|
|
81
|
-
* @throws When `signal` aborts — an abandoned check is not a verdict on the
|
|
82
|
-
* domain, so it must not be read as `false`.
|
|
83
|
-
*
|
|
84
|
-
* @example
|
|
85
|
-
* if (await isValidSsoDomain(input.value, AbortSignal.timeout(5_000))) {
|
|
86
|
-
* const authClient = new AuthClient({ ssoDomain: input.value });
|
|
87
|
-
* await authClient.signIn();
|
|
88
|
-
* }
|
|
89
|
-
*/
|
|
90
|
-
export async function isValidSsoDomain(domain, signal) {
|
|
91
|
-
signal.throwIfAborted();
|
|
92
|
-
let normalized;
|
|
93
|
-
try {
|
|
94
|
-
normalized = normalizeSsoDomain(domain);
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
const scheme = isLoopbackHost(normalized) ? 'http' : 'https';
|
|
100
|
-
try {
|
|
101
|
-
const response = await fetch(`${scheme}://${normalized}${WELL_KNOWN_PATH}`, {
|
|
102
|
-
headers: { Accept: 'application/json' },
|
|
103
|
-
signal,
|
|
104
|
-
});
|
|
105
|
-
if (!response.ok) {
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
const valid = publishesSsoConfiguration(await response.json());
|
|
109
|
-
signal.throwIfAborted();
|
|
110
|
-
return valid;
|
|
98
|
+
else if (!loopback) {
|
|
99
|
+
problems.push(...dnsNameProblems(hostname));
|
|
111
100
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
// A DNS, TLS, CORS, or parse failure all leave the domain unusable.
|
|
117
|
-
return false;
|
|
101
|
+
if (problems.length > 0) {
|
|
102
|
+
throw new Error(`ssoDomain ${quote(trimmed)} has ${listed(problems)}`);
|
|
118
103
|
}
|
|
104
|
+
return host;
|
|
119
105
|
}
|
|
120
106
|
//# sourceMappingURL=sso.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sso.js","sourceRoot":"","sources":["../../../src/client/sso.ts"],"names":[],"mappings":"AAAA,2DAA2D;AAC3D,MAAM,oBAAoB,GAAG,GAAG,CAAC;AAEjC,MAAM,
|
|
1
|
+
{"version":3,"file":"sso.js","sourceRoot":"","sources":["../../../src/client/sso.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@icp-sdk/auth",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"author": "DFINITY Stiftung <sdk@dfinity.org>",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "Authentication library for Internet Computer web apps",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@icp-sdk/core": "^6"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@icp-sdk/signer": "^6.
|
|
57
|
+
"@icp-sdk/signer": "^6.1.0",
|
|
58
58
|
"idb": "^7.1.1"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
@@ -24,6 +24,7 @@ import { SessionIdentity } from './session-identity.js';
|
|
|
24
24
|
import { SessionMinter } from './session-minter.js';
|
|
25
25
|
import { type Slots, slotsFor } from './slots.js';
|
|
26
26
|
import { normalizeSsoDomain } from './sso.js';
|
|
27
|
+
import { createSsoDomainService, type SsoStatus, SsoStatusChecker } from './sso-status.js';
|
|
27
28
|
import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -235,8 +236,17 @@ export type AuthClientCreateOptions = AuthClientBaseOptions &
|
|
|
235
236
|
* When set, the identity provider URL includes an `sso` search param so
|
|
236
237
|
* the user authenticates via that organization's own provider.
|
|
237
238
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
239
|
+
* The value is trimmed, lowercased and IDNA-encoded, and has to be a
|
|
240
|
+
* domain and nothing else: no scheme, port, path, query, fragment or
|
|
241
|
+
* userinfo. A loopback host (`localhost`, `127.0.0.1`, or a name under
|
|
242
|
+
* `.localhost`) is accepted too, with a port, for a local mock provider.
|
|
243
|
+
*
|
|
244
|
+
* The client checks the domain with Internet Identity as soon as it is
|
|
245
|
+
* built, and {@link AuthClient.getSsoStatus} reports the answer. A value
|
|
246
|
+
* that is not a domain is reported as `invalid`, with the reason, rather
|
|
247
|
+
* than thrown, so a
|
|
248
|
+
* client can be built from whatever the user typed; a sign-in with it
|
|
249
|
+
* opens Internet Identity, which explains the error on its own screen.
|
|
240
250
|
*/
|
|
241
251
|
ssoDomain?: string;
|
|
242
252
|
}
|
|
@@ -384,6 +394,8 @@ export class AuthClient {
|
|
|
384
394
|
// interaction rather than two.
|
|
385
395
|
#interactions = 0;
|
|
386
396
|
#channelLock: HeldLock | undefined;
|
|
397
|
+
// Set only for a client built with `ssoDomain`.
|
|
398
|
+
readonly #sso: SsoStatusChecker | undefined;
|
|
387
399
|
|
|
388
400
|
constructor(options: AuthClientCreateOptions = {}) {
|
|
389
401
|
this.#options = options;
|
|
@@ -467,7 +479,18 @@ export class AuthClient {
|
|
|
467
479
|
identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
|
|
468
480
|
}
|
|
469
481
|
if (options.ssoDomain !== undefined) {
|
|
470
|
-
|
|
482
|
+
let domain: string | { invalid: string };
|
|
483
|
+
try {
|
|
484
|
+
domain = normalizeSsoDomain(options.ssoDomain);
|
|
485
|
+
} catch (error) {
|
|
486
|
+
domain = { invalid: error instanceof Error ? error.message : String(error) };
|
|
487
|
+
}
|
|
488
|
+
// A value that is not a domain goes to Internet Identity as typed, which
|
|
489
|
+
// explains it on its own screen.
|
|
490
|
+
identityProviderUrl.searchParams.set(
|
|
491
|
+
'sso',
|
|
492
|
+
typeof domain === 'string' ? domain : options.ssoDomain.trim(),
|
|
493
|
+
);
|
|
471
494
|
// The SSO ceremony starts before a delegation is requested, so the
|
|
472
495
|
// channel carries the derivation origin too late to resolve the client.
|
|
473
496
|
if (options.derivationOrigin !== undefined) {
|
|
@@ -476,6 +499,14 @@ export class AuthClient {
|
|
|
476
499
|
options.derivationOrigin.toString(),
|
|
477
500
|
);
|
|
478
501
|
}
|
|
502
|
+
const canisterId = this.#canisterId;
|
|
503
|
+
this.#sso = new SsoStatusChecker(
|
|
504
|
+
domain,
|
|
505
|
+
() => createSsoDomainService({ canisterId, agentOptions: options.agentOptions }),
|
|
506
|
+
() => {
|
|
507
|
+
for (const listener of [...this.#listeners]) listener();
|
|
508
|
+
},
|
|
509
|
+
);
|
|
479
510
|
}
|
|
480
511
|
// `prompt` and `hint` are Internet Identity extensions, so they ride on the
|
|
481
512
|
// authorize URL rather than in the ICRC request. Baking them in here, as
|
|
@@ -621,6 +652,45 @@ export class AuthClient {
|
|
|
621
652
|
return this.#status;
|
|
622
653
|
}
|
|
623
654
|
|
|
655
|
+
/**
|
|
656
|
+
* Whether the organization domain this client was built with can be signed
|
|
657
|
+
* in with, or `undefined` for a client built without `ssoDomain`.
|
|
658
|
+
*
|
|
659
|
+
* Synchronous, and the same object until the answer changes, like
|
|
660
|
+
* {@link getStatus}; {@link subscribe} says when to read it again. A client
|
|
661
|
+
* built with `ssoDomain` starts checking as soon as it is built: it asks
|
|
662
|
+
* Internet Identity to resolve the domain, then reads the result
|
|
663
|
+
* until it is final, so `checking` lasts until Internet Identity answers.
|
|
664
|
+
* Building the client also warms Internet Identity's cache for the domain, so
|
|
665
|
+
* a sign-in that follows starts without waiting on the fetch.
|
|
666
|
+
*
|
|
667
|
+
* {@link signIn} proceeds in every state: Internet Identity resolves the
|
|
668
|
+
* domain itself during the sign-in, and explains a failure, a value that is
|
|
669
|
+
* not a domain included, on its own screen.
|
|
670
|
+
*
|
|
671
|
+
* @example
|
|
672
|
+
* const client = new AuthClient({ ssoDomain: input.value });
|
|
673
|
+
* client.subscribe(() => render(client.getSsoStatus()));
|
|
674
|
+
* render(client.getSsoStatus());
|
|
675
|
+
*/
|
|
676
|
+
getSsoStatus(): SsoStatus | undefined {
|
|
677
|
+
return this.#sso?.status;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Checks the organization domain again, for a "Try again" button.
|
|
682
|
+
*
|
|
683
|
+
* The status goes back to `checking` and then to the new answer. Calling it
|
|
684
|
+
* while an `unavailable` answer's `retryAfter` is still in the future is
|
|
685
|
+
* harmless: Internet Identity does not fetch again before then, so the answer
|
|
686
|
+
* comes back the same at once. Nothing re-checks on its own once `retryAfter`
|
|
687
|
+
* passes. Does nothing for an `invalid` domain, which no retry can fix, or for
|
|
688
|
+
* a client built without `ssoDomain`.
|
|
689
|
+
*/
|
|
690
|
+
refreshSsoStatus(): void {
|
|
691
|
+
this.#sso?.refresh();
|
|
692
|
+
}
|
|
693
|
+
|
|
624
694
|
/** Reads the record and builds the answer `getStatus` hands out. */
|
|
625
695
|
#readStatus(): SessionStatus {
|
|
626
696
|
const record = this.#stateStorage.get(this.#slots.state);
|
|
@@ -637,22 +707,24 @@ export class AuthClient {
|
|
|
637
707
|
}
|
|
638
708
|
|
|
639
709
|
/**
|
|
640
|
-
* Watches who is signed in here, and
|
|
710
|
+
* Watches who is signed in here, and the SSO status of a client built with
|
|
711
|
+
* `ssoDomain`, and returns a function that stops watching.
|
|
641
712
|
*
|
|
642
|
-
* `getStatus()` and the predicates beside
|
|
643
|
-
* rendering on them needs to be told when to read
|
|
644
|
-
* for reasons that are nothing to do with this
|
|
645
|
-
* out, a sibling subdomain publishing a sign-in,
|
|
646
|
-
* re-issuing silently
|
|
713
|
+
* `getStatus()`, `getSsoStatus()` and the predicates beside them are
|
|
714
|
+
* snapshots, so an application rendering on them needs to be told when to read
|
|
715
|
+
* again. The record changes for reasons that are nothing to do with this
|
|
716
|
+
* client: another tab signing out, a sibling subdomain publishing a sign-in,
|
|
717
|
+
* a peer client on this page re-issuing silently. The SSO status changes when
|
|
718
|
+
* Internet Identity answers. This is how both arrive.
|
|
647
719
|
*
|
|
648
|
-
* Fired once the
|
|
649
|
-
*
|
|
650
|
-
*
|
|
720
|
+
* Fired once the new answer is readable, so a listener sees what it was told
|
|
721
|
+
* about. It says that something changed and not what: a listener reads the
|
|
722
|
+
* answer it wants, `getStatus()` or `getSsoStatus()`.
|
|
651
723
|
*
|
|
652
724
|
* What it does not cover is the identity being replaced under an application
|
|
653
725
|
* that holds one — an app delegation rotating is deliberately invisible, and
|
|
654
726
|
* nothing about who is signed in has changed when it does.
|
|
655
|
-
* @param listener - Called after the record changes.
|
|
727
|
+
* @param listener - Called after the record or the SSO status changes.
|
|
656
728
|
* @returns A function that unregisters it.
|
|
657
729
|
*/
|
|
658
730
|
subscribe(listener: () => void): () => void {
|
|
@@ -667,10 +739,12 @@ export class AuthClient {
|
|
|
667
739
|
|
|
668
740
|
/**
|
|
669
741
|
* Releases what this client hooked: the browser listeners, the state
|
|
670
|
-
* subscription,
|
|
671
|
-
* discarding a client, so nothing it
|
|
742
|
+
* subscription, the refresh the identity has scheduled, and an SSO domain
|
|
743
|
+
* check still in progress. Call it when discarding a client, so nothing it
|
|
744
|
+
* registered outlives it.
|
|
672
745
|
*/
|
|
673
746
|
dispose(): void {
|
|
747
|
+
this.#sso?.dispose();
|
|
674
748
|
// Recorded, because the constructor starts the restore without awaiting it:
|
|
675
749
|
// a client disposed while one is in flight would otherwise have an identity
|
|
676
750
|
// installed afterwards, scheduling refreshes nobody can stop.
|
|
@@ -741,19 +815,14 @@ export class AuthClient {
|
|
|
741
815
|
}
|
|
742
816
|
|
|
743
817
|
/**
|
|
744
|
-
*
|
|
745
|
-
* interaction on it.
|
|
818
|
+
* Takes the lock that makes this the only interaction on the signer channel.
|
|
746
819
|
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
* is open. Two callers doing that by hand is one place too many for the order
|
|
752
|
-
* to come out backwards.
|
|
820
|
+
* Called right after a request is handed to the signer, which opens the
|
|
821
|
+
* channel within that call: a popup may only be opened from the task the
|
|
822
|
+
* click started, and lock requests in front of it were enough for Chromium to
|
|
823
|
+
* refuse the window, so the lock is asked for once the window exists.
|
|
753
824
|
*/
|
|
754
|
-
|
|
755
|
-
await this.#signer.openChannel();
|
|
756
|
-
|
|
825
|
+
#takeChannelLock(): void {
|
|
757
826
|
if (this.#channelLock === undefined) {
|
|
758
827
|
const lock = stealLock(CHANNEL_LOCK);
|
|
759
828
|
// Whoever took the channel has already renavigated the window this one was
|
|
@@ -832,14 +901,11 @@ export class AuthClient {
|
|
|
832
901
|
const returnTo =
|
|
833
902
|
raw === undefined ? undefined : this.memoize(() => sameOriginTarget(raw)?.href ?? null);
|
|
834
903
|
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
//
|
|
840
|
-
// Under `Promise.all([signIn, requestAttributes])` this is what keeps a
|
|
841
|
-
// faster concurrent request from flushing before this flow's delegation
|
|
842
|
-
// request is buffered.
|
|
904
|
+
// Session-key acquisition starts on the call, and the delegation request
|
|
905
|
+
// that needs it is handed to the signer on the call too, as a promise of the
|
|
906
|
+
// request. The signer places it in call order there, so in the redirect
|
|
907
|
+
// flow it replays into the slot it was sent from whatever the key costs on
|
|
908
|
+
// either load, and a `requestAttributes` issued alongside keeps its own.
|
|
843
909
|
const sessionKeyPromise: Promise<{
|
|
844
910
|
key: SignIdentity | PartialIdentity;
|
|
845
911
|
pending?: boolean;
|
|
@@ -853,9 +919,25 @@ export class AuthClient {
|
|
|
853
919
|
// rejection and propagates it when reached.
|
|
854
920
|
void sessionKeyPromise.catch(() => undefined);
|
|
855
921
|
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
|
|
922
|
+
// Opens the signer window within the call, which the browser allows only in
|
|
923
|
+
// the tick the click started, so nothing before it may ask the browser for
|
|
924
|
+
// anything.
|
|
925
|
+
const sessionChainPromise = requestSessionDelegation(
|
|
926
|
+
this.#signer,
|
|
927
|
+
sessionKeyPromise.then(({ key }) => ({
|
|
928
|
+
sessionPublicKey: key.getPublicKey().toDer(),
|
|
929
|
+
// Both bounds are sent only where the caller asked, so the provider's
|
|
930
|
+
// own defaults apply otherwise rather than numbers this library
|
|
931
|
+
// invented. How long a sign-in lasts is the provider's policy, narrowed
|
|
932
|
+
// by what the user chooses at consent and by an organization's cap.
|
|
933
|
+
maxTimeToLive: options?.maxTimeToLive,
|
|
934
|
+
maxTimeToIdle: options?.maxTimeToIdle,
|
|
935
|
+
derivationOrigin: this.#options.derivationOrigin?.toString(),
|
|
936
|
+
})),
|
|
937
|
+
);
|
|
938
|
+
void sessionChainPromise.catch(() => undefined);
|
|
939
|
+
|
|
940
|
+
this.#takeChannelLock();
|
|
859
941
|
|
|
860
942
|
// The other lock a ceremony needs, taken once the window is open for the
|
|
861
943
|
// same reason. Stolen rather than queued for, so the newer intent wins and
|
|
@@ -893,16 +975,7 @@ export class AuthClient {
|
|
|
893
975
|
throw new Error('A session needs a key that can sign');
|
|
894
976
|
}
|
|
895
977
|
|
|
896
|
-
const sessionChain = await
|
|
897
|
-
sessionPublicKey: key.getPublicKey().toDer(),
|
|
898
|
-
// Both bounds are sent only where the caller asked, so the provider's
|
|
899
|
-
// own defaults apply otherwise rather than numbers this library
|
|
900
|
-
// invented. How long a sign-in lasts is the provider's policy, narrowed
|
|
901
|
-
// by what the user chooses at consent and by an organization's cap.
|
|
902
|
-
maxTimeToLive: options?.maxTimeToLive,
|
|
903
|
-
maxTimeToIdle: options?.maxTimeToIdle,
|
|
904
|
-
derivationOrigin: this.#options.derivationOrigin?.toString(),
|
|
905
|
-
});
|
|
978
|
+
const sessionChain = await sessionChainPromise;
|
|
906
979
|
|
|
907
980
|
// The chain comes from the signer over a transport shared with others, so
|
|
908
981
|
// the key it delegates to is checked here rather than assumed. A chain for
|
|
@@ -1002,21 +1075,10 @@ export class AuthClient {
|
|
|
1002
1075
|
);
|
|
1003
1076
|
}
|
|
1004
1077
|
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
// this hold, a faster concurrent request (e.g. the nonce path of
|
|
1010
|
-
// `requestAttributes`) buffers first and trips that flush before this flow's
|
|
1011
|
-
// delegation request — issued only once the key is ready — is buffered,
|
|
1012
|
-
// splitting what should be one redirect into two.
|
|
1013
|
-
//
|
|
1014
|
-
// The key is captured in a closure, not read back after the producer: on the
|
|
1015
|
-
// FIRST load the producer sets `acquired`, so the delegation request that
|
|
1016
|
-
// follows is issued with no intervening storage read — a read there would
|
|
1017
|
-
// re-open the very flush gap this closes. The producer is skipped on the
|
|
1018
|
-
// replay load (its result is journaled), where the key is instead read from
|
|
1019
|
-
// the pending slot.
|
|
1078
|
+
// Acquired inside a `memoize` producer, so the first load journals which key
|
|
1079
|
+
// this ceremony started with. The key is captured in a closure on the first
|
|
1080
|
+
// load, where the producer runs; on the replay load the producer is skipped
|
|
1081
|
+
// (its result is journaled) and the key is read from the pending slot.
|
|
1020
1082
|
//
|
|
1021
1083
|
// What is journaled is the key's *public* half: not a secret, it survives the
|
|
1022
1084
|
// redirect as text, and on the return load it says whether the pending slot
|
|
@@ -1057,9 +1119,10 @@ export class AuthClient {
|
|
|
1057
1119
|
*
|
|
1058
1120
|
* In 'window' mode the callback lets the identity provider window open while the
|
|
1059
1121
|
* nonce is still resolving, avoiding a perceived delay before the user sees
|
|
1060
|
-
* the prompt;
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1122
|
+
* the prompt; the signer keeps the channel open while the request waits for
|
|
1123
|
+
* its nonce. In both modes the request takes its place with the signer when
|
|
1124
|
+
* this method is called, so it can be issued alongside `signIn` in either
|
|
1125
|
+
* order.
|
|
1063
1126
|
*
|
|
1064
1127
|
* @param params - Request parameters.
|
|
1065
1128
|
* @param params.keys - Attribute keys to request (e.g. `['email', 'name']`).
|
|
@@ -1089,16 +1152,24 @@ export class AuthClient {
|
|
|
1089
1152
|
}): Promise<SignedAttributes> {
|
|
1090
1153
|
// In redirect mode the nonce is journaled (base64, since the journal is
|
|
1091
1154
|
// JSON) so the replay on the return load signs against the same bytes.
|
|
1092
|
-
const
|
|
1093
|
-
?
|
|
1094
|
-
:
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1155
|
+
const nonce: Promise<string> = this.#urlTransport
|
|
1156
|
+
? this.#urlTransport.memoize(async () => toBase64(await params.nonce()))
|
|
1157
|
+
: params.nonce().then(toBase64);
|
|
1158
|
+
|
|
1159
|
+
// Handed to the signer on the call, as a promise of the request: the signer
|
|
1160
|
+
// window opens within the call while the nonce is still on its way, and the
|
|
1161
|
+
// request keeps its place in call order however long the nonce takes on
|
|
1162
|
+
// either load of a redirect flow.
|
|
1163
|
+
const responsePromise = this.#signer.sendRequest(
|
|
1164
|
+
nonce.then((value) => ({
|
|
1165
|
+
jsonrpc: '2.0' as const,
|
|
1166
|
+
id: globalThis.crypto.randomUUID(),
|
|
1167
|
+
method: 'ii-icrc3-attributes',
|
|
1168
|
+
params: { keys: params.keys, nonce: value },
|
|
1169
|
+
})),
|
|
1170
|
+
);
|
|
1171
|
+
this.#takeChannelLock();
|
|
1172
|
+
const response = await responsePromise;
|
|
1102
1173
|
|
|
1103
1174
|
if ('error' in response) {
|
|
1104
1175
|
throw new Error(response.error.message);
|
|
@@ -1235,24 +1306,6 @@ export class AuthClient {
|
|
|
1235
1306
|
}
|
|
1236
1307
|
}
|
|
1237
1308
|
|
|
1238
|
-
// Popup-mode nonce resolution. Start the fetch, then open the transport
|
|
1239
|
-
// channel so the identity provider window is visible while we wait, and
|
|
1240
|
-
// suspend auto-close so it can't fire mid-await (e.g. due to a close
|
|
1241
|
-
// scheduled by a prior request on the same signer). The original auto-close
|
|
1242
|
-
// setting is restored before sendRequest, so the next response resumes
|
|
1243
|
-
// normal close behaviour.
|
|
1244
|
-
async #resolveNonce(nonce: () => Promise<Uint8Array>): Promise<Uint8Array> {
|
|
1245
|
-
const value = nonce();
|
|
1246
|
-
await this.#openSignerChannel();
|
|
1247
|
-
const previousAutoClose = this.#signer.autoCloseTransportChannel;
|
|
1248
|
-
this.#signer.autoCloseTransportChannel = false;
|
|
1249
|
-
try {
|
|
1250
|
-
return await value;
|
|
1251
|
-
} finally {
|
|
1252
|
-
this.#signer.autoCloseTransportChannel = previousAutoClose;
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
1309
|
/**
|
|
1257
1310
|
* Mints ahead of the next request when the page comes back, if one is due.
|
|
1258
1311
|
*
|
package/src/client/index.ts
CHANGED
|
@@ -21,7 +21,7 @@ export {
|
|
|
21
21
|
DEFAULT_SHARED_MEMORY_NAME,
|
|
22
22
|
SharedMemoryCredentialStorage,
|
|
23
23
|
} from './shared-memory-credential-storage.js';
|
|
24
|
-
export {
|
|
24
|
+
export type { SsoStatus } from './sso-status.js';
|
|
25
25
|
export {
|
|
26
26
|
LocalStateStorage,
|
|
27
27
|
MemoryStateStorage,
|