@icp-sdk/auth 10.0.2 → 11.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.
@@ -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
  }