feat: one-click SSO sign-in for organization domains - #141
Conversation
Adds the SSO counterpart to `openIdProvider`:
- `ssoDomain` create option, setting the `sso` search param on the
identity provider URL. It also passes `derivationOrigin` as a search
param, which the SSO flow needs before a delegation is requested.
The two one-click entry points are mutually exclusive, in the type and
in the constructor.
- `scopedKeys({ ssoDomain })`, emitting `sso:<domain>:<key>` and
defaulting to `name` and `email`.
- `isValidSsoDomain(domain, signal?)`, checking the domain format and
that it publishes `/.well-known/ii-openid-configuration`. Intended for
a domain input, so it takes an abort signal and never resolves in under
750ms.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`URL` already does what the hand-rolled check did, and does it the way the identity provider does: it lowercases, splits off a port, and rejects a value carrying a scheme, userinfo, path, query, or fragment. It also fixes internationalized domains, which the regex rejected outright: `中国.cn` now normalizes to the punycode authority the identity provider resolves, `xn--fiqs8s.cn`. The per-label and 253-character limits are gone with it. They came from a DNS name's presentation limits, which are not what the identity provider enforces — it caps the domain at 255 bytes and requires a bare authority, so that is what this mirrors now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The openid and sso entry points had no prose documentation, leaving them visible only in the generated API reference. Adds a guide covering both, attribute scoping, and validating a user-typed domain. Also fixes the quick start requestAttributes example, which omitted the required nonce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds organization-domain one-click SSO support alongside the existing openIdProvider flow, including a new domain validation helper and documentation to make both one-click sign-in entry points discoverable and usable.
Changes:
- Introduces
ssoDomainsupport inAuthClient(URL param handling, mutual exclusion withopenIdProvider) and adds SSO-awarescopedKeys. - Adds
isValidSsoDomain()plus domain normalization/probing logic and a comprehensive test suite. - Updates docs (new One-Click Sign-In guide, Quick Start fix, sidebar navigation).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/client/sso.test.ts | Adds tests for isValidSsoDomain normalization, probing behavior, timing floor, and abort behavior. |
| tests/client/auth-client.test.ts | Adds tests for ssoDomain URL params, mutual exclusion, derivationOrigin behavior, and scopedKeys for both flows. |
| src/client/sso.ts | Implements SSO domain normalization and isValidSsoDomain probing/timing/abort behavior. |
| src/client/index.ts | Exports isValidSsoDomain from the client entrypoint. |
| src/client/auth-client.ts | Adds ssoDomain to create options, normalizes/forwards SSO params, and extends scopedKeys for SSO. |
| docs/src/content/docs/quick-start.md | Fixes requestAttributes example to include required nonce; updates “next” label. |
| docs/src/content/docs/one-click-sign-in.md | New guide covering both one-click entry points, domain validation, and scoped attributes. |
| docs/src/content/docs/_sidebar.json | Adds “One-Click Sign-In” to the docs navigation. |
Suppressed comments (2)
src/client/sso.ts:102
- For an already-aborted
AbortSignal,isValidSsoDomain()still startsprobeSsoDomain()(which may normalize and attemptfetch) even though the result will be discarded. Short-circuiting early avoids unnecessary work and guarantees the rejection uses the signal's abort reason.
export async function isValidSsoDomain(domain: string, signal?: AbortSignal): Promise<boolean> {
const [valid] = await Promise.all([
probeSsoDomain(domain, signal),
sleep(MIN_DURATION_MS, signal),
]);
src/client/sso.ts:129
probeSsoDomain()rethrows the caught fetch error when aborted, which can causeisValidSsoDomain()to reject with anAbortErrorinstead of the signal's abort reason (depending on which branch rejects first). If the API intends to reject with the abort reason, throwsignal.reasonwhensignal.abortedis true.
} catch (error) {
if (signal?.aborted === true) {
throw error;
}
// A DNS, TLS, CORS, or parse failure all leave the domain unusable.
return false;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/client/auth-client.ts:996
- The
scopedKeysSSO overload return type is incorrect: it claims the domain is justLowercase<D>, butnormalizeSsoDomaincan produce a different normalized form (e.g. punycode for internationalized domains). This makes the type lie to consumers (and will not match the runtime value). Also, the implementation silently falls back to the OpenID branch when neitherssoDomainnoropenIdProvideris provided (or if both are provided), producingopenid:undefined:...at runtime.
export function scopedKeys<
D extends string,
K extends string = (typeof DEFAULT_SSO_SCOPE_KEYS)[number],
>(params: { ssoDomain: D; keys?: readonly K[] }): `sso:${Lowercase<D>}:${K}`[];
export function scopedKeys(params: {
openIdProvider?: OpenIdProvider;
ssoDomain?: string;
keys?: readonly string[];
}): string[] {
if (params.ssoDomain !== undefined) {
const domain = normalizeSsoDomain(params.ssoDomain);
const keys = params.keys ?? DEFAULT_SSO_SCOPE_KEYS;
return keys.map((key) => `sso:${domain}:${key}`);
}
const provider = OPENID_PROVIDER_URLS[params.openIdProvider as OpenIdProvider];
const keys = params.keys ?? DEFAULT_OPENID_SCOPE_KEYS;
return keys.map((key) => `openid:${provider}:${key}`);
src/client/sso.ts:25
sleep()has a race wheresignalcan abort after the initialsignal.abortedcheck but before the abort listener is attached; in that case the promise will still resolve after the timeout instead of rejecting with the abort reason. Re-checksignal.abortedafter registering the listener (and remove the listener in the abort path) to ensure abort is always observed.
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted === true) {
return Promise.reject(signal.reason);
}
return new Promise((resolve, reject) => {
const onAbort = (): void => {
clearTimeout(timer);
reject(signal?.reason);
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
src/client/sso.ts:31
isLoopbackAuthorityusesauthority.split(':')[0], which breaks for bracketed IPv6 authorities (e.g.[::1]:11107). That causesnormalizeSsoDomain/isValidSsoDomainto reject IPv6 loopback inputs and to incorrectly pickhttpsinstead ofhttpfor loopback. Parsing the bracket form (and including::1) avoids this.
function isLoopbackAuthority(authority: string): boolean {
const host = authority.split(':')[0];
return host === 'localhost' || host === '127.0.0.1';
}
src/client/sso.ts:130
- When the request is aborted,
probeSsoDomainrethrows the fetch error. That can reject with anAbortError/DOMException instead of theAbortSignal.reason, contradicting the documented behavior (“rejects with the signal’s abort reason”). Consider throwingsignal.reason(falling back to the original error if no reason was provided) whensignal.abortedis true.
} catch (error) {
if (signal?.aborted === true) {
throw error;
}
The implementation signature accepted both `openIdProvider` and `ssoDomain` as optional, so a caller from JavaScript could pass neither and get `openid:undefined:<key>` back, or pass both and silently get one of them. Both now throw, matching the client constructor, and the overloads reject the combination at compile time. Also rejects an abort that lands while the discovery response is being parsed, rather than resolving a verdict for a check the caller dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| return false; | ||
| } | ||
|
|
||
| const scheme = isLoopbackAuthority(normalized) ? 'http' : 'https'; |
There was a problem hiding this comment.
This looks familiar, didn't we have this function implemented before?
NCR if not.
There was a problem hiding this comment.
Good catch — loopback validation was indeed something we already implemented in Internet Identity, twice: the canister's is_loopback_host and the frontend's isLoopbackHost in ssoDiscovery.ts. Neither is importable from here, so this is a third copy rather than a reuse miss, but it was needlessly a different third copy.
Now aligned with the II frontend implementation: same name, and it parses the authority as a URL instead of splitting on a colon by hand.
| @@ -0,0 +1,111 @@ | |||
| --- | |||
| title: One-Click Sign-In | |||
| description: Send users straight to a specific OpenID provider or to their organization's SSO, instead of the identity provider's own chooser. | |||
There was a problem hiding this comment.
| description: Send users straight to a specific OpenID provider or to their organization's SSO, instead of the identity provider's own chooser. | |
| description: Send users straight to a specific OpenID provider or to their organization's SSO, instead of the user-chosen provider. |
There was a problem hiding this comment.
Yes it does abstract over it (default to II) since a long time ago. But we're already moving towards the library becoming II specific, which is fine given that the underlying library (@icp-sdk/signer) already offers the abstract building blocks and there are no more identity providers other than II that rely on the AuthClient lib.
There was a problem hiding this comment.
Applied. The REST API can't commit a suggestion, so it went in as a regular commit rather than through the suggestion UI — the wording is yours verbatim.
|
|
||
| let controller: AbortController | undefined; | ||
|
|
||
| input.addEventListener('input', () => { |
There was a problem hiding this comment.
This code snippet is not self-contained and hard to understand for an outsider. Please fix that
There was a problem hiding this comment.
I agree, it should have an example html snippet so it's a self contained minimal working sso input example.
|
|
||
| Three things to know about it: | ||
|
|
||
| - The organization must serve `/.well-known/ii-openid-configuration` with `Access-Control-Allow-Origin: *`. It is a public, unauthenticated document, and a browser that cannot read it cannot check the domain. |
There was a problem hiding this comment.
Where are the contents of that file documented?
There was a problem hiding this comment.
They'll be documented here, this link is currently dead but we can already link to it in this PR since it should be avaiable soon.
| // ['sso:dfinity.org:email'] | ||
| ``` | ||
|
|
||
| `verified_email` is available under `openid:` but not under `sso:`. "Verified" means Internet Identity established that the user actually has access to the address — either by verifying it itself, or through a claim scheme it has hardcoded for a specific provider, such as Google, Apple, or Microsoft. It has no basis to make that claim about an address asserted by an arbitrary organization's SSO server, so the attribute is not offered under `sso:` at all. |
There was a problem hiding this comment.
Avoid "hardcoded" in the phrasing here.
Just say that II established this either itself or indirectly from a public IdP (as opposed to an admin-controlled IdP)
Same check exists there and in the canister. This now matches the frontend form, which parses the authority as a URL rather than splitting on a colon by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Applies a review suggestion: the page skips the user-chosen provider, which reads better than the identity provider own chooser now that the library is becoming II specific. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…floor The check fetches a document from an organization's own server and had no deadline of its own: a server that accepts the connection and never answers left the promise pending forever. The signal is now required, so the caller states the bound, and `AbortSignal.timeout(...)` reads as that intent. It also makes the contract visible. The function rejects when the signal aborts, which the signature previously gave no hint of. The 750ms floor is gone with it. It existed to stop a verdict flashing while someone was still typing, which only helps a caller checking on key entry, and cost every other caller latency for nothing. Live-typing callers debounce instead, which the docs now show. Dropping it also collapses the two functions into one. The docs lead with a self-contained submit-time example instead, since the previous snippet referenced elements it never defined and spent most of its lines on a debounce.
Adds the SSO counterpart to the existing
openIdProviderone-click sign-in, and documents both flows.ssoDomaincreate optionMirrors
openIdProvider, with two differences:URL, so it is lowercased and IDNA-encoded, and a value carrying a scheme, userinfo, path, query, or fragment is rejected by rebuilding the URL from the host and port alone and comparing. This is the same rule the canister applies inis_bare_authority, except that going throughURLalso accepts an internationalized domain:zürich.examplenormalizes toxn--zrich-kva.example, which is the form the canister resolves.derivationOriginis also passed as a search param. The SSO ceremony starts before a delegation is requested, so the identity provider can't learn the derivation origin from the channel in time to resolve the client for the app.The two entry points select different providers for the same sign-in, so setting both is rejected — in the type (
AuthClientCreateOptionsis now a union over the two, usingneverso the both-set case fails to typecheck) and in the constructor.scopedKeys({ ssoDomain })Defaults to
nameandemail.verified_emailis absent — the identity provider can't certify it for an organization SSO and rejects a request for it, unlike theopenid:scope where it is supported.isValidSsoDomain(domain, signal?)For an app that renders a domain input: checks the format, then that the domain publishes
/.well-known/ii-openid-configurationwith aclient_idand anopenid_configurationURL.Access-Control-Allow-Origin: *. It is a public, unauthenticated configuration file, and a domain a browser can't read it from isn't usable for SSO.AbortSignaland follows the platform convention: it rejects with the signal's abort reason rather than resolvingfalse, fails fast on an already-aborted signal, and drops its listener on completion. Resolving would be a lying boolean, sincefalsealready means "invalid domain".localhost:11107) usehttpand skip the domain-name requirement, matching the identity provider's own scheme selection, so local development against a mock provider works.Docs
The
openidflow had no prose documentation at all — it existed only in the generated API reference, which made it effectively undiscoverable. Adds a One-Click Sign-In guide covering both entry points, attribute scoping, and validating a user-typed domain, and links it from the sidebar after Quick Start.Also fixes the Quick Start
requestAttributesexample, which omitted thenonceargument that became required in v8.Tests
19 tests for
isValidSsoDomainand 12 for the client changes; full suite green, types clean,publint --strictpasses. The mutual-exclusion test relies on@ts-expect-errorwith typecheck enabled, so the compile-time half is covered too.🤖 Generated with Claude Code