@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.
@@ -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
- * A value that is not a domain with an optional port throws. Use
239
- * {@link isValidSsoDomain} to check a domain the user typed.
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
- identityProviderUrl.searchParams.set('sso', normalizeSsoDomain(options.ssoDomain));
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 returns a function that stops watching.
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 it are snapshots, so an application
643
- * rendering on them needs to be told when to read again. The record changes
644
- * for reasons that are nothing to do with this client — another tab signing
645
- * out, a sibling subdomain publishing a sign-in, a peer client on this page
646
- * re-issuing silently — and this is how those arrive.
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 record is readable, so a listener asking who is signed in
649
- * sees what it was told about. It says that something changed and not what: a
650
- * listener reads the answer it wants, which for most is `getStatus()`.
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, and the refresh the identity has scheduled. Call it when
671
- * discarding a client, so nothing it registered outlives 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.
@@ -21,7 +21,7 @@ export {
21
21
  DEFAULT_SHARED_MEMORY_NAME,
22
22
  SharedMemoryCredentialStorage,
23
23
  } from './shared-memory-credential-storage.js';
24
- export { isValidSsoDomain } from './sso.js';
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
- const WELL_KNOWN_PATH = '/.well-known/ii-openid-configuration';
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` / `127.0.0.1`, optionally followed by `:<port>`. IPv6 loopback
8
- * (`[::1]`, etc.) is intentionally not handled — the identity provider doesn't
9
- * recognise it either, and its e2e setup uses the hostname form.
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(host: string): boolean {
12
- let url: URL;
13
- try {
14
- // Parse as a URL authority so the optional `:<port>` is split off for us
15
- // rather than by hand. Invalid input throws and is treated as non-loopback.
16
- url = new URL(`http://${host}`);
17
- } catch {
18
- return false;
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
- // A bare `host[:port]` has no path/query/fragment; reject e.g. `localhost/x`.
21
- if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
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
- return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
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, IDNA-encoded, host and optional port.
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 with an optional port.
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://${trimmed}`);
73
+ url = new URL(`https://${rest}`);
43
74
  } catch {
44
- throw new Error(`ssoDomain is not a domain: ${trimmed}`);
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
- return authority;
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
- function publishesSsoConfiguration(value: unknown): boolean {
63
- if (typeof value !== 'object' || value === null) {
64
- return false;
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
- const { client_id, openid_configuration } = value as Record<string, unknown>;
67
- return typeof client_id === 'string' && typeof openid_configuration === 'string';
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
- const scheme = isLoopbackHost(normalized) ? 'http' : 'https';
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
  }