Skip to content

feat: one-click SSO sign-in for organization domains - #141

Open
sea-snake wants to merge 13 commits into
mainfrom
feat/sso-domain
Open

feat: one-click SSO sign-in for organization domains#141
sea-snake wants to merge 13 commits into
mainfrom
feat/sso-domain

Conversation

@sea-snake

@sea-snake sea-snake commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Adds the SSO counterpart to the existing openIdProvider one-click sign-in, and documents both flows.

ssoDomain create option

const authClient = new AuthClient({ ssoDomain: 'dfinity.org' });
// → https://id.ai/authorize?sso=dfinity.org

Mirrors openIdProvider, with two differences:

  • The value is the organization domain itself. It is normalized to the authority the identity provider fetches the discovery document from — parsed with 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 in is_bare_authority, except that going through URL also accepts an internationalized domain: zürich.example normalizes to xn--zrich-kva.example, which is the form the canister resolves.
  • derivationOrigin is 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 (AuthClientCreateOptions is now a union over the two, using never so the both-set case fails to typecheck) and in the constructor.

scopedKeys({ ssoDomain })

scopedKeys({ ssoDomain: 'dfinity.org' });
// ['sso:dfinity.org:name', 'sso:dfinity.org:email']

Defaults to name and email. verified_email is absent — the identity provider can't certify it for an organization SSO and rejects a request for it, unlike the openid: 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-configuration with a client_id and an openid_configuration URL.

if (await isValidSsoDomain(input.value)) {  }
  • The document must be served with 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.
  • Takes an AbortSignal and follows the platform convention: it rejects with the signal's abort reason rather than resolving false, fails fast on an already-aborted signal, and drops its listener on completion. Resolving would be a lying boolean, since false already means "invalid domain".
  • Never resolves in under 750 ms. An input checked on key entry asks about a partially typed domain for as long as the user is typing, and each of those is invalid; at format-check speed that flashes an error on every keystroke.
  • Loopback hosts (localhost:11107) use http and skip the domain-name requirement, matching the identity provider's own scheme selection, so local development against a mock provider works.

Docs

The openid flow 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 requestAttributes example, which omitted the nonce argument that became required in v8.

Tests

19 tests for isValidSsoDomain and 12 for the client changes; full suite green, types clean, publint --strict passes. The mutual-exclusion test relies on @ts-expect-error with typecheck enabled, so the compile-time half is covered too.

🤖 Generated with Claude Code

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>
@sea-snake
sea-snake requested a review from a team as a code owner August 3, 2026 21:16
sea-snake and others added 8 commits August 3, 2026 23:34
`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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ssoDomain support in AuthClient (URL param handling, mutual exclusion with openIdProvider) and adds SSO-aware scopedKeys.
  • 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 starts probeSsoDomain() (which may normalize and attempt fetch) 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 cause isValidSsoDomain() to reject with an AbortError instead of the signal's abort reason (depending on which branch rejects first). If the API intends to reject with the abort reason, throw signal.reason when signal.aborted is 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.

Comment thread src/client/sso.ts Outdated
Comment thread src/client/sso.ts Outdated
Comment thread src/client/auth-client.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 scopedKeys SSO overload return type is incorrect: it claims the domain is just Lowercase<D>, but normalizeSsoDomain can 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 neither ssoDomain nor openIdProvider is provided (or if both are provided), producing openid: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 where signal can abort after the initial signal.aborted check 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-check signal.aborted after 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

  • isLoopbackAuthority uses authority.split(':')[0], which breaks for bracketed IPv6 authorities (e.g. [::1]:11107). That causes normalizeSsoDomain / isValidSsoDomain to reject IPv6 loopback inputs and to incorrectly pick https instead of http for 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, probeSsoDomain rethrows the fetch error. That can reject with an AbortError/DOMException instead of the AbortSignal.reason, contradicting the documented behavior (“rejects with the signal’s abort reason”). Consider throwing signal.reason (falling back to the original error if no reason was provided) when signal.aborted is 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>
Comment thread src/client/sso.ts Outdated
return false;
}

const scheme = isLoopbackAuthority(normalized) ? 'http' : 'https';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks familiar, didn't we have this function implemented before?

NCR if not.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

r does this repo abstract over II?

@sea-snake sea-snake Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code snippet is not self-contained and hard to understand for an outsider. Please fix that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where are the contents of that file documented?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

sea-snake and others added 2 commits August 10, 2026 14:15
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants