@icp-sdk/auth 10.0.2 → 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 +60 -14
- package/dist/esm/client/auth-client.js +71 -13
- 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/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 +1 -1
- package/src/client/auth-client.ts +89 -15
- package/src/client/index.ts +1 -1
- package/src/client/sso-status.ts +257 -0
- package/src/client/sso.ts +77 -92
|
@@ -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.
|
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,
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { Actor, AnonymousIdentity, HttpAgent, type HttpAgentOptions } from '@icp-sdk/core/agent';
|
|
2
|
+
import type { IDL } from '@icp-sdk/core/candid';
|
|
3
|
+
import type { Principal } from '@icp-sdk/core/principal';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether the organization domain a client was built with can be signed in with.
|
|
7
|
+
*
|
|
8
|
+
* - `checking`: Internet Identity has not answered yet.
|
|
9
|
+
* - `available`: the domain resolves to a usable SSO configuration. `name` is
|
|
10
|
+
* the label the organization publishes, for a "Continue with Acme" button.
|
|
11
|
+
* - `invalid`: the value is not a domain, so no check is made and a retry
|
|
12
|
+
* cannot help. The user has to change what they typed. `reason` names every
|
|
13
|
+
* problem with it, to show as is.
|
|
14
|
+
* - `unavailable`: the domain publishes no usable configuration, or fetching
|
|
15
|
+
* it is failing. While `retryAfter` is in the future Internet Identity will
|
|
16
|
+
* not fetch again, so a retry before then gets the same answer; without it,
|
|
17
|
+
* a retry cannot help. `reason` is Internet Identity's explanation, when it
|
|
18
|
+
* gives one, to show as is.
|
|
19
|
+
*/
|
|
20
|
+
export type SsoStatus =
|
|
21
|
+
| { state: 'checking' }
|
|
22
|
+
| { state: 'available'; name?: string }
|
|
23
|
+
| { state: 'invalid'; reason: string }
|
|
24
|
+
| { state: 'unavailable'; retryAfter?: Date; reason?: string };
|
|
25
|
+
|
|
26
|
+
type CandidSsoDomainStatus =
|
|
27
|
+
| { Available: { name: [] | [string] } }
|
|
28
|
+
| { Pending: null }
|
|
29
|
+
| { Unavailable: { retry_after: [] | [bigint]; reason: [] | [string] } };
|
|
30
|
+
|
|
31
|
+
/** The two Internet Identity methods an SSO domain check calls. */
|
|
32
|
+
export interface SsoDomainService {
|
|
33
|
+
app_sso_domain_check(domain: string): Promise<void>;
|
|
34
|
+
app_sso_domain_status(domain: string): Promise<CandidSsoDomainStatus>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Written out for the same reason as the session methods in `session-minter.ts`:
|
|
39
|
+
* this package carries no Internet Identity declarations.
|
|
40
|
+
*/
|
|
41
|
+
const idlFactory: IDL.InterfaceFactory = ({ IDL }) => {
|
|
42
|
+
const Timestamp = IDL.Nat64;
|
|
43
|
+
const Status = IDL.Variant({
|
|
44
|
+
Available: IDL.Record({ name: IDL.Opt(IDL.Text) }),
|
|
45
|
+
Pending: IDL.Null,
|
|
46
|
+
Unavailable: IDL.Record({ retry_after: IDL.Opt(Timestamp), reason: IDL.Opt(IDL.Text) }),
|
|
47
|
+
});
|
|
48
|
+
return IDL.Service({
|
|
49
|
+
app_sso_domain_check: IDL.Func([IDL.Text], [], []),
|
|
50
|
+
app_sso_domain_status: IDL.Func([IDL.Text], [Status], ['query']),
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Builds the service an SSO domain check calls, as the anonymous caller: the
|
|
56
|
+
* check runs before anyone has signed in.
|
|
57
|
+
*/
|
|
58
|
+
export async function createSsoDomainService(options: {
|
|
59
|
+
canisterId: Principal;
|
|
60
|
+
agentOptions?: Omit<HttpAgentOptions, 'identity'>;
|
|
61
|
+
}): Promise<SsoDomainService> {
|
|
62
|
+
const agent = await HttpAgent.create({
|
|
63
|
+
...options.agentOptions,
|
|
64
|
+
// Last, so no agent option can make the check call as someone.
|
|
65
|
+
identity: new AnonymousIdentity(),
|
|
66
|
+
});
|
|
67
|
+
return Actor.createActor<SsoDomainService>(idlFactory, {
|
|
68
|
+
agent,
|
|
69
|
+
canisterId: options.canisterId,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The first and the longest wait between two status reads. */
|
|
74
|
+
export const SSO_POLL_MIN_MS = 500;
|
|
75
|
+
export const SSO_POLL_MAX_MS = 2_000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* How long a check may read `Pending` before it is started again. Matches the
|
|
79
|
+
* age at which Internet Identity treats a fetch as abandoned.
|
|
80
|
+
*/
|
|
81
|
+
export const SSO_RECHECK_AFTER_MS = 120_000;
|
|
82
|
+
|
|
83
|
+
const CHECKING: SsoStatus = Object.freeze({ state: 'checking' });
|
|
84
|
+
|
|
85
|
+
const NANOS_PER_MILLI = 1_000_000n;
|
|
86
|
+
|
|
87
|
+
function fromCandid(status: CandidSsoDomainStatus): SsoStatus | undefined {
|
|
88
|
+
if ('Available' in status) {
|
|
89
|
+
const name = status.Available.name[0];
|
|
90
|
+
return name === undefined ? { state: 'available' } : { state: 'available', name };
|
|
91
|
+
}
|
|
92
|
+
if ('Unavailable' in status) {
|
|
93
|
+
const retryAfterNs = status.Unavailable.retry_after[0];
|
|
94
|
+
const reason = status.Unavailable.reason[0];
|
|
95
|
+
return {
|
|
96
|
+
state: 'unavailable',
|
|
97
|
+
...(retryAfterNs !== undefined && {
|
|
98
|
+
retryAfter: new Date(Number(retryAfterNs / NANOS_PER_MILLI)),
|
|
99
|
+
}),
|
|
100
|
+
...(reason !== undefined && { reason }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Pending: the fetch is still running, which is what `checking` already says.
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sameStatus(a: SsoStatus, b: SsoStatus): boolean {
|
|
108
|
+
switch (a.state) {
|
|
109
|
+
case 'checking':
|
|
110
|
+
return b.state === 'checking';
|
|
111
|
+
case 'available':
|
|
112
|
+
return b.state === 'available' && a.name === b.name;
|
|
113
|
+
case 'invalid':
|
|
114
|
+
return b.state === 'invalid' && a.reason === b.reason;
|
|
115
|
+
case 'unavailable':
|
|
116
|
+
return (
|
|
117
|
+
b.state === 'unavailable' &&
|
|
118
|
+
a.retryAfter?.getTime() === b.retryAfter?.getTime() &&
|
|
119
|
+
a.reason === b.reason
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Asks Internet Identity whether one SSO domain can be signed in with, and holds
|
|
126
|
+
* the answer.
|
|
127
|
+
*
|
|
128
|
+
* One update starts the fetch, then the status is read with a query until it is
|
|
129
|
+
* final: a query needs no consensus, so polling it is cheap where polling an
|
|
130
|
+
* update is not. A read that fails is retried at the next interval, so a check
|
|
131
|
+
* ends only on an answer from Internet Identity, or on {@link dispose}.
|
|
132
|
+
*/
|
|
133
|
+
export class SsoStatusChecker {
|
|
134
|
+
readonly #domain: string | undefined;
|
|
135
|
+
readonly #createService: () => Promise<SsoDomainService>;
|
|
136
|
+
readonly #onChange: () => void;
|
|
137
|
+
#service: Promise<SsoDomainService> | undefined;
|
|
138
|
+
#status: SsoStatus;
|
|
139
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
140
|
+
// Bumped by every start and by dispose, so a run that was replaced drops its
|
|
141
|
+
// late answers instead of overwriting a newer one.
|
|
142
|
+
#run = 0;
|
|
143
|
+
#disposed = false;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param domain - The normalized domain, or, for a value that is not one, why
|
|
147
|
+
* not: `invalid` from the start and never checked.
|
|
148
|
+
* @param createService - Builds the service on the first call.
|
|
149
|
+
* @param onChange - Called after the status changes.
|
|
150
|
+
*/
|
|
151
|
+
constructor(
|
|
152
|
+
domain: string | { invalid: string },
|
|
153
|
+
createService: () => Promise<SsoDomainService>,
|
|
154
|
+
onChange: () => void,
|
|
155
|
+
) {
|
|
156
|
+
this.#domain = typeof domain === 'string' ? domain : undefined;
|
|
157
|
+
this.#createService = createService;
|
|
158
|
+
this.#onChange = onChange;
|
|
159
|
+
this.#status =
|
|
160
|
+
typeof domain === 'string'
|
|
161
|
+
? CHECKING
|
|
162
|
+
: Object.freeze({ state: 'invalid', reason: domain.invalid });
|
|
163
|
+
if (typeof domain === 'string') this.#start(0);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The current answer. The same object until it changes. */
|
|
167
|
+
get status(): SsoStatus {
|
|
168
|
+
return this.#status;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Checks again. Does nothing for an `invalid` domain, or once disposed. */
|
|
172
|
+
refresh(): void {
|
|
173
|
+
if (this.#domain === undefined || this.#disposed) return;
|
|
174
|
+
this.#set(CHECKING);
|
|
175
|
+
this.#start(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Stops checking. A call already sent is not recalled; its answer is dropped. */
|
|
179
|
+
dispose(): void {
|
|
180
|
+
this.#disposed = true;
|
|
181
|
+
this.#run += 1;
|
|
182
|
+
clearTimeout(this.#timer);
|
|
183
|
+
this.#timer = undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#start(delayMs: number): void {
|
|
187
|
+
this.#run += 1;
|
|
188
|
+
const run = this.#run;
|
|
189
|
+
clearTimeout(this.#timer);
|
|
190
|
+
this.#timer = setTimeout(() => {
|
|
191
|
+
void this.#check(run);
|
|
192
|
+
}, delayMs);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
#isCurrent(run: number): boolean {
|
|
196
|
+
return run === this.#run && !this.#disposed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async #check(run: number): Promise<void> {
|
|
200
|
+
const domain = this.#domain;
|
|
201
|
+
if (domain === undefined || !this.#isCurrent(run)) return;
|
|
202
|
+
const checkedAt = Date.now();
|
|
203
|
+
try {
|
|
204
|
+
const service = await this.#serviceFor();
|
|
205
|
+
if (!this.#isCurrent(run)) return;
|
|
206
|
+
await service.app_sso_domain_check(domain);
|
|
207
|
+
} catch {
|
|
208
|
+
// The reads below still see whatever Internet Identity has, and a check
|
|
209
|
+
// that never started is started again after `SSO_RECHECK_AFTER_MS`.
|
|
210
|
+
}
|
|
211
|
+
await this.#poll(run, domain, checkedAt, SSO_POLL_MIN_MS);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async #poll(run: number, domain: string, checkedAt: number, nextDelayMs: number): Promise<void> {
|
|
215
|
+
if (!this.#isCurrent(run)) return;
|
|
216
|
+
let answer: SsoStatus | undefined;
|
|
217
|
+
try {
|
|
218
|
+
const service = await this.#serviceFor();
|
|
219
|
+
if (!this.#isCurrent(run)) return;
|
|
220
|
+
answer = fromCandid(await service.app_sso_domain_status(domain));
|
|
221
|
+
} catch {
|
|
222
|
+
answer = undefined;
|
|
223
|
+
}
|
|
224
|
+
if (!this.#isCurrent(run)) return;
|
|
225
|
+
if (answer !== undefined) {
|
|
226
|
+
this.#set(answer);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (Date.now() - checkedAt >= SSO_RECHECK_AFTER_MS) {
|
|
230
|
+
this.#timer = setTimeout(() => {
|
|
231
|
+
void this.#check(run);
|
|
232
|
+
}, nextDelayMs);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.#timer = setTimeout(() => {
|
|
236
|
+
void this.#poll(run, domain, checkedAt, Math.min(nextDelayMs * 2, SSO_POLL_MAX_MS));
|
|
237
|
+
}, nextDelayMs);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#serviceFor(): Promise<SsoDomainService> {
|
|
241
|
+
if (this.#service === undefined) {
|
|
242
|
+
const created = this.#createService();
|
|
243
|
+
this.#service = created;
|
|
244
|
+
// A service that could not be built is built again on the next call.
|
|
245
|
+
created.catch(() => {
|
|
246
|
+
if (this.#service === created) this.#service = undefined;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return this.#service;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
#set(next: SsoStatus): void {
|
|
253
|
+
if (sameStatus(this.#status, next)) return;
|
|
254
|
+
this.#status = next;
|
|
255
|
+
this.#onChange();
|
|
256
|
+
}
|
|
257
|
+
}
|
package/src/client/sso.ts
CHANGED
|
@@ -1,122 +1,107 @@
|
|
|
1
1
|
// The identity provider's cap on the domain it will fetch.
|
|
2
2
|
const MAX_AUTHORITY_LENGTH = 255;
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// The identity provider's own limits on a DNS name (idna's strict mode).
|
|
5
|
+
const MAX_DNS_NAME_LENGTH = 253;
|
|
6
|
+
const MAX_DNS_LABEL_LENGTH = 63;
|
|
7
|
+
|
|
8
|
+
const MAX_QUOTED_LENGTH = 100;
|
|
9
|
+
|
|
10
|
+
/** `value` for an error message: cut short, and quoted so control characters show escaped. */
|
|
11
|
+
function quote(value: string): string {
|
|
12
|
+
return JSON.stringify(
|
|
13
|
+
value.length > MAX_QUOTED_LENGTH ? `${value.slice(0, MAX_QUOTED_LENGTH)}…` : value,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
5
16
|
|
|
6
17
|
/**
|
|
7
|
-
* `localhost
|
|
8
|
-
*
|
|
9
|
-
*
|
|
18
|
+
* `localhost`, `127.0.0.1`, or a name under `.localhost` (RFC 6761 reserves the
|
|
19
|
+
* whole domain for loopback), like the identity provider's own check. IPv6
|
|
20
|
+
* loopback is not one, there either.
|
|
10
21
|
*/
|
|
11
|
-
function isLoopbackHost(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
23
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname.endsWith('.localhost');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What keeps `hostname` from being a DNS name, by the identity provider's rules. */
|
|
27
|
+
function dnsNameProblems(hostname: string): string[] {
|
|
28
|
+
const labels = hostname.split('.');
|
|
29
|
+
const problems: string[] = [];
|
|
30
|
+
if (labels.length < 2) problems.push('a single label');
|
|
31
|
+
if (labels.some((label) => label.length === 0)) problems.push('an empty label');
|
|
32
|
+
if (labels.some((label) => label.length > MAX_DNS_LABEL_LENGTH)) {
|
|
33
|
+
problems.push(`a label longer than ${MAX_DNS_LABEL_LENGTH} characters`);
|
|
19
34
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return false;
|
|
35
|
+
if (labels.some((label) => /[^a-z0-9-]/.test(label))) {
|
|
36
|
+
problems.push('a character other than a letter, digit, or hyphen');
|
|
23
37
|
}
|
|
24
|
-
|
|
38
|
+
if (labels.some((label) => label.startsWith('-') || label.endsWith('-'))) {
|
|
39
|
+
problems.push('a label starting or ending with a hyphen');
|
|
40
|
+
}
|
|
41
|
+
// `--` in the third and fourth places is reserved, except for punycode.
|
|
42
|
+
if (labels.some((label) => label.slice(2, 4) === '--' && !label.startsWith('xn--'))) {
|
|
43
|
+
problems.push('a label with "--" in its third and fourth places');
|
|
44
|
+
}
|
|
45
|
+
if (hostname.length > MAX_DNS_NAME_LENGTH) {
|
|
46
|
+
problems.push(`a name longer than ${MAX_DNS_NAME_LENGTH} characters`);
|
|
47
|
+
}
|
|
48
|
+
return problems;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function listed(items: string[]): string {
|
|
52
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
25
53
|
}
|
|
26
54
|
|
|
27
55
|
/**
|
|
28
56
|
* Normalizes an SSO domain to the authority the identity provider fetches the
|
|
29
|
-
* discovery document from: lowercased
|
|
57
|
+
* discovery document from: lowercased and IDNA-encoded. A loopback host may
|
|
58
|
+
* carry a port, for a local mock provider; any other host may not.
|
|
30
59
|
*
|
|
31
60
|
* @param domain - The organization domain.
|
|
32
61
|
* @returns The normalized authority.
|
|
33
|
-
* @throws When `domain` is not a domain
|
|
62
|
+
* @throws When `domain` is not a domain, naming every problem with it.
|
|
34
63
|
*/
|
|
35
64
|
export function normalizeSsoDomain(domain: string): string {
|
|
36
65
|
const trimmed = domain.trim();
|
|
37
66
|
if (trimmed.length === 0) {
|
|
38
67
|
throw new Error('ssoDomain cannot be empty');
|
|
39
68
|
}
|
|
69
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(trimmed)?.[0];
|
|
70
|
+
const rest = scheme === undefined ? trimmed : trimmed.slice(scheme.length);
|
|
40
71
|
let url: URL;
|
|
41
72
|
try {
|
|
42
|
-
url = new URL(`https://${
|
|
73
|
+
url = new URL(`https://${rest}`);
|
|
43
74
|
} catch {
|
|
44
|
-
throw new Error(`ssoDomain is not a domain
|
|
45
|
-
}
|
|
46
|
-
const authority = url.host;
|
|
47
|
-
// Rebuilding from the host and port alone has to reproduce what was parsed,
|
|
48
|
-
// so anything else the domain carried shows up as a difference.
|
|
49
|
-
if (new URL(`https://${authority}`).href !== url.href) {
|
|
50
|
-
throw new Error(`ssoDomain must be a domain and optional port, nothing else: ${trimmed}`);
|
|
51
|
-
}
|
|
52
|
-
if (authority.length > MAX_AUTHORITY_LENGTH) {
|
|
53
|
-
throw new Error(`ssoDomain exceeds ${MAX_AUTHORITY_LENGTH} characters`);
|
|
54
|
-
}
|
|
55
|
-
// A bare hostname is a half-typed domain, not something worth a request.
|
|
56
|
-
if (!authority.includes('.') && !isLoopbackHost(authority)) {
|
|
57
|
-
throw new Error(`ssoDomain is not a domain name: ${trimmed}`);
|
|
75
|
+
throw new Error(`ssoDomain ${quote(trimmed)} is not a domain`);
|
|
58
76
|
}
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
const { username, password, pathname, hostname, port, host, href } = url;
|
|
78
|
+
// `URL` drops an empty or default port such as `:443`, so the typed one is read
|
|
79
|
+
// from the input itself.
|
|
80
|
+
const typedAuthority = rest.split(/[/?#]/)[0] ?? '';
|
|
81
|
+
const typedPort = /:([0-9]*)$/.exec(
|
|
82
|
+
typedAuthority.slice(typedAuthority.lastIndexOf('@') + 1),
|
|
83
|
+
)?.[1];
|
|
84
|
+
const [beforeFragment = ''] = href.split('#');
|
|
61
85
|
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
|
|
86
|
+
const problems: string[] = [];
|
|
87
|
+
if (scheme !== undefined) problems.push('a scheme');
|
|
88
|
+
if (username !== '' || password !== '') problems.push('userinfo');
|
|
89
|
+
if (pathname !== '/') problems.push('a path');
|
|
90
|
+
if (beforeFragment.includes('?')) problems.push('a query');
|
|
91
|
+
if (href.includes('#')) problems.push('a fragment');
|
|
92
|
+
const loopback = isLoopbackHost(hostname);
|
|
93
|
+
if (typedPort !== undefined && !loopback) {
|
|
94
|
+
problems.push('a port, which only loopback hosts may carry');
|
|
95
|
+
} else if (typedPort !== undefined && (typedPort === '' || typedPort !== port)) {
|
|
96
|
+
problems.push('an empty or default port');
|
|
65
97
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Checks whether an organization domain can be used for SSO sign-in: it is a
|
|
72
|
-
* domain name, and it publishes `/.well-known/ii-openid-configuration` with a
|
|
73
|
-
* `client_id` and an `openid_configuration` URL.
|
|
74
|
-
*
|
|
75
|
-
* The document must be served with `Access-Control-Allow-Origin: *`.
|
|
76
|
-
*
|
|
77
|
-
* The check waits on a third-party server and has no deadline of its own, so
|
|
78
|
-
* `signal` sets one. Pass `AbortSignal.timeout(...)` for a one-shot check, or a
|
|
79
|
-
* controller you abort per keystroke when checking as the user types.
|
|
80
|
-
*
|
|
81
|
-
* @param domain - The organization domain.
|
|
82
|
-
* @param signal - Bounds the check.
|
|
83
|
-
* @returns Whether the domain is usable for SSO sign-in.
|
|
84
|
-
* @throws When `signal` aborts — an abandoned check is not a verdict on the
|
|
85
|
-
* domain, so it must not be read as `false`.
|
|
86
|
-
*
|
|
87
|
-
* @example
|
|
88
|
-
* if (await isValidSsoDomain(input.value, AbortSignal.timeout(5_000))) {
|
|
89
|
-
* const authClient = new AuthClient({ ssoDomain: input.value });
|
|
90
|
-
* await authClient.signIn();
|
|
91
|
-
* }
|
|
92
|
-
*/
|
|
93
|
-
export async function isValidSsoDomain(domain: string, signal: AbortSignal): Promise<boolean> {
|
|
94
|
-
signal.throwIfAborted();
|
|
95
|
-
|
|
96
|
-
let normalized: string;
|
|
97
|
-
try {
|
|
98
|
-
normalized = normalizeSsoDomain(domain);
|
|
99
|
-
} catch {
|
|
100
|
-
return false;
|
|
98
|
+
if (trimmed.length > MAX_AUTHORITY_LENGTH) {
|
|
99
|
+
problems.push(`more than ${MAX_AUTHORITY_LENGTH} characters`);
|
|
100
|
+
} else if (!loopback) {
|
|
101
|
+
problems.push(...dnsNameProblems(hostname));
|
|
101
102
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const response = await fetch(`${scheme}://${normalized}${WELL_KNOWN_PATH}`, {
|
|
106
|
-
headers: { Accept: 'application/json' },
|
|
107
|
-
signal,
|
|
108
|
-
});
|
|
109
|
-
if (!response.ok) {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
const valid = publishesSsoConfiguration(await response.json());
|
|
113
|
-
signal.throwIfAborted();
|
|
114
|
-
return valid;
|
|
115
|
-
} catch (error) {
|
|
116
|
-
if (signal.aborted) {
|
|
117
|
-
throw error;
|
|
118
|
-
}
|
|
119
|
-
// A DNS, TLS, CORS, or parse failure all leave the domain unusable.
|
|
120
|
-
return false;
|
|
103
|
+
if (problems.length > 0) {
|
|
104
|
+
throw new Error(`ssoDomain ${quote(trimmed)} has ${listed(problems)}`);
|
|
121
105
|
}
|
|
106
|
+
return host;
|
|
122
107
|
}
|