Skip to content

Commit 41c03a6

Browse files
atergaclaude
andcommitted
feat(fe): SSO sign-in via two-hop OIDC discovery
II admins can register an organization by `discovery_domain` via `add_discoverable_oidc_config` (backend, already shipped in dfinity#3778). This PR adds the matching frontend: a user clicks "Sign in with SSO", types their organization domain, and the frontend performs a two-hop discovery chain to resolve the provider's OAuth endpoint before redirecting them to sign in. Discovery is lazy and user-initiated — the picker doesn't render one button per organization, just a single "Sign in with SSO" entry that leads to the domain input screen. # Changes **Type alignment with backend.** The frontend `DiscoverableOidcConfig` now matches main's Candid type exactly: `{ discovery_domain: string }`. Everything else (`client_id`, `logo`, `name`, etc.) is resolved on demand during discovery — the backend only stores the domain. **Two-hop discovery (`ssoDiscovery.ts`, new).** 1. `GET https://{domain}/.well-known/ii-openid-configuration` returns `{ client_id, openid_configuration }`. The domain owner is responsible for publishing this at their DNS-backed origin. 2. `GET {openid_configuration}` is the provider's standard OIDC discovery, yielding `authorization_endpoint` and `scopes_supported`. Both hops run entirely from the browser. (The backend does its own two-hop discovery via HTTPS outcalls in `src/internet_identity/src/ openid/generic.rs`; keeping the two implementations separate for now simplifies BE↔FE synchronization.) **SSO flow UI.** - `SignInWithSso.svelte` (new): domain input screen. On submit it validates DNS format, checks the domain is in the backend's `oidc_configs`, runs `discoverSsoConfig`, then calls `continueWithSso` to redirect. If the domain isn't registered, shows "This domain is not registered as an OIDC provider." inline. - `SsoIcon.svelte` (new): key icon for the SSO button. - `PickAuthenticationMethod.svelte`: renders the SSO button whenever `oidc_configs` is non-empty. Does not render per-provider buttons — users don't know which IdP their org uses, they just type their domain. - `authFlow.svelte.ts`: new `signInWithSso` view + `continueWithSso()` method that synthesizes an `OpenIdConfig` from discovery results and hands off to the existing `continueWithOpenId` flow. **Security.** - Domain input is DNS-format validated (length, label length, no special characters). - `oidc_configs` from the backend is the sole allowlist of which organizations can initiate SSO. No hardcoded domain allowlist in frontend code. - All three URLs (the .well-known, the discovery, the auth endpoint) must be HTTPS. - The `openid_configuration` URL from hop 1 must be on a trusted OIDC provider domain (Google, Apple, Microsoft, Okta, login.dfinity.org). - Issuer hostname in the provider discovery must match the `openid_configuration` hostname *exactly* or as a true subdomain — using `endsWith` alone would accept look-alikes like `evildfinity.okta.com`. - Authorization endpoint hostname is constrained to the same, not just HTTPS-validated, so a tampered discovery response can't redirect the auth step off-host. - Per-domain rate limit (1 attempt per 10 min), max 2 concurrent discoveries, 4-hour cache per hop, exponential backoff, timeouts (5s for hop 1, 10s for hop 2) with `clearTimeout` in `finally` so a failed fetch can't leak an armed abort timer. **Cleanup of stale assumptions.** - Removed the earlier draft's eager per-provider button rendering in `PickAuthenticationMethod` — those were based on a richer `DiscoverableOidcConfig` shape that didn't survive into main. - Removed `authFlow.continueWithOidc` and `openID.findConfig`'s `oidc_configs` extension for the same reason; they assumed the config contained a pre-supplied `client_id`, which it no longer does. # Tests - 23 tests in `ssoDiscovery.test.ts`: domain validation, allowlist-at-caller discipline, two-hop happy path, cache, retry, trusted-provider check, HTTPS enforcement, issuer/auth-endpoint hostname exact-match (including an `evildfinity.okta.com` regression case), off-host auth-endpoint rejection, non-object responses. - 22 tests in `openID.test.ts` preserved, including 4 for `selectAuthScopes` (the shared defaults-fallback helper used by `continueWithSso`). - `npm run lint` and `svelte-check` on touched files: clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e68167f commit 41c03a6

13 files changed

Lines changed: 1068 additions & 25 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<script lang="ts">
2+
import type { SVGAttributes } from "svelte/elements";
3+
4+
const { class: className, ...props }: SVGAttributes<SVGSVGElement> = $props();
5+
</script>
6+
7+
<svg viewBox="0 0 20 20" {...props} class={["size-5", className]}>
8+
<path
9+
d="M10 1a4.5 4.5 0 0 0-4.5 4.5c0 1.56.8 2.93 2 3.74V18a1 1 0 0 0 1.7.7L11 16.92l1.3 1.3a1 1 0 0 0 1.42 0l1.3-1.3a1 1 0 0 0 0-1.42l-1.3-1.3 1-1a1 1 0 0 0 0-1.42L13.5 10.6V9.24a4.5 4.5 0 0 0 1-7.74A4.5 4.5 0 0 0 10 1Zm0 2.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"
10+
class="fill-current"
11+
/>
12+
</svg>

src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
import { isOpenIdCancelError } from "$lib/utils/openID";
1414
import type { OpenIdConfig } from "$lib/generated/internet_identity_types";
1515
import CreateIdentity from "$lib/components/wizards/auth/views/CreateIdentity.svelte";
16+
import SignInWithSso from "$lib/components/wizards/auth/views/SignInWithSso.svelte";
17+
import type { SsoDiscoveryResult } from "$lib/utils/ssoDiscovery";
1618
1719
interface Props {
1820
onSignIn: (identityNumber: bigint) => Promise<void>;
@@ -93,6 +95,29 @@
9395
}
9496
};
9597
98+
const handleContinueWithSso = async (
99+
result: SsoDiscoveryResult,
100+
): Promise<void | "cancelled"> => {
101+
try {
102+
isAuthenticating = true;
103+
const authResult = await authFlow.continueWithSso(result);
104+
if (authResult.type === "signIn") {
105+
await onSignIn(authResult.identityNumber);
106+
} else if (authResult.name !== undefined) {
107+
await onSignUp(
108+
await authFlow.completeOpenIdRegistration(authResult.name),
109+
);
110+
}
111+
} catch (error) {
112+
if (isOpenIdCancelError(error)) {
113+
return "cancelled";
114+
}
115+
onError(error);
116+
} finally {
117+
isAuthenticating = false;
118+
}
119+
};
120+
96121
const handleCompleteOpenIdRegistration = async (
97122
name: string,
98123
): Promise<void> => {
@@ -122,6 +147,11 @@
122147
<CreatePasskey create={handleCreatePasskey} />
123148
{:else if authFlow.view === "setupNewIdentity"}
124149
<CreateIdentity create={handleCompleteOpenIdRegistration} />
150+
{:else if authFlow.view === "signInWithSso"}
151+
<SignInWithSso
152+
continueWithSso={handleContinueWithSso}
153+
goBack={authFlow.chooseMethod}
154+
/>
125155
{/if}
126156
{/snippet}
127157

@@ -149,6 +179,7 @@
149179
<PickAuthenticationMethod
150180
setupOrUseExistingPasskey={authFlow.setupOrUseExistingPasskey}
151181
continueWithOpenId={handleContinueWithOpenId}
182+
signInWithSso={authFlow.signInWithSso}
152183
/>
153184
{/if}
154185
{#if authFlow.view !== "chooseMethod"}

src/frontend/src/lib/components/wizards/auth/views/PickAuthenticationMethod.svelte

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import Button from "$lib/components/ui/Button.svelte";
33
import PasskeyIcon from "$lib/components/icons/PasskeyIcon.svelte";
4+
import SsoIcon from "$lib/components/icons/SsoIcon.svelte";
45
import Alert from "$lib/components/ui/Alert.svelte";
56
import ProgressRing from "$lib/components/ui/ProgressRing.svelte";
67
import { backendCanisterConfig } from "$lib/globals";
@@ -12,9 +13,14 @@
1213
interface Props {
1314
setupOrUseExistingPasskey: () => void;
1415
continueWithOpenId: (config: OpenIdConfig) => Promise<void | "cancelled">;
16+
signInWithSso: () => void;
1517
}
1618
17-
const { setupOrUseExistingPasskey, continueWithOpenId }: Props = $props();
19+
const {
20+
setupOrUseExistingPasskey,
21+
continueWithOpenId,
22+
signInWithSso,
23+
}: Props = $props();
1824
1925
let authenticatingProviderId = $state<string>();
2026
let cancelledProviderId = $state<string>();
@@ -33,6 +39,11 @@
3339
3440
const supportsPasskeys = window.PublicKeyCredential !== undefined;
3541
const openIdProviders = backendCanisterConfig.openid_configs?.[0] ?? [];
42+
// SSO entry is shown whenever any organization domain is registered in
43+
// `oidc_configs`. We do not render one button per organization — the user
44+
// discovers their provider by entering their domain on the SSO screen.
45+
const hasSsoProviders =
46+
(backendCanisterConfig.oidc_configs?.[0] ?? []).length > 0;
3647
</script>
3748

3849
<div class="flex flex-col items-stretch gap-5">
@@ -43,6 +54,15 @@
4354
/>
4455
{/if}
4556
<div class="flex flex-col items-stretch gap-3">
57+
<Button
58+
onclick={setupOrUseExistingPasskey}
59+
disabled={!supportsPasskeys || authenticatingProviderId !== undefined}
60+
size="xl"
61+
variant={"secondary"}
62+
>
63+
<PasskeyIcon />
64+
{$t`Continue with passkey`}
65+
</Button>
4666
<div class="flex flex-row flex-nowrap justify-stretch gap-3">
4767
{#each openIdProviders as provider}
4868
{@const name = provider.name}
@@ -69,16 +89,19 @@
6989
</Button>
7090
</Tooltip>
7191
{/each}
92+
{#if hasSsoProviders}
93+
<Button
94+
onclick={signInWithSso}
95+
variant="secondary"
96+
disabled={authenticatingProviderId !== undefined}
97+
size="xl"
98+
class="flex-1"
99+
aria-label={$t`Sign in with SSO`}
100+
>
101+
<SsoIcon class="size-6" />
102+
</Button>
103+
{/if}
72104
</div>
73-
<Button
74-
onclick={setupOrUseExistingPasskey}
75-
disabled={!supportsPasskeys || authenticatingProviderId !== undefined}
76-
size="xl"
77-
variant={"secondary"}
78-
>
79-
<PasskeyIcon />
80-
{$t`Continue with passkey`}
81-
</Button>
82105
</div>
83106
<div class="border-border-tertiary border-t"></div>
84107
<div class="flex flex-row items-center justify-between gap-4">
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<script lang="ts">
2+
import { onMount } from "svelte";
3+
import Button from "$lib/components/ui/Button.svelte";
4+
import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte";
5+
import Input from "$lib/components/ui/Input.svelte";
6+
import ProgressRing from "$lib/components/ui/ProgressRing.svelte";
7+
import SsoIcon from "$lib/components/icons/SsoIcon.svelte";
8+
import { backendCanisterConfig } from "$lib/globals";
9+
import { validateDomain, discoverSsoConfig } from "$lib/utils/ssoDiscovery";
10+
import type { SsoDiscoveryResult } from "$lib/utils/ssoDiscovery";
11+
import { t } from "$lib/stores/locale.store";
12+
13+
interface Props {
14+
continueWithSso: (
15+
result: SsoDiscoveryResult,
16+
) => Promise<void | "cancelled">;
17+
goBack: () => void;
18+
}
19+
20+
const { continueWithSso, goBack }: Props = $props();
21+
22+
// The set of domains registered with II as SSO providers. A domain the user
23+
// types must be in this set for us to attempt discovery — the backend
24+
// config is the source of truth for "which organizations are allowed".
25+
const registeredDomains = new Set(
26+
(backendCanisterConfig.oidc_configs?.[0] ?? []).map(
27+
(c) => c.discovery_domain,
28+
),
29+
);
30+
31+
let inputRef = $state<HTMLInputElement>();
32+
let domain = $state("");
33+
let error = $state<string>();
34+
let isSubmitting = $state(false);
35+
36+
const handleSubmit = async () => {
37+
error = undefined;
38+
const trimmed = domain.trim().toLowerCase();
39+
if (trimmed.length === 0) {
40+
return;
41+
}
42+
43+
try {
44+
validateDomain(trimmed);
45+
} catch (e) {
46+
error = e instanceof Error ? e.message : $t`Invalid domain`;
47+
return;
48+
}
49+
50+
if (!registeredDomains.has(trimmed)) {
51+
error = $t`This domain is not registered as an OIDC provider.`;
52+
return;
53+
}
54+
55+
isSubmitting = true;
56+
try {
57+
const result = await discoverSsoConfig(trimmed);
58+
await continueWithSso(result);
59+
} catch (e) {
60+
error =
61+
e instanceof Error
62+
? e.message
63+
: $t`SSO sign-in failed. Please try again.`;
64+
} finally {
65+
isSubmitting = false;
66+
}
67+
};
68+
69+
onMount(() => {
70+
inputRef?.focus();
71+
});
72+
</script>
73+
74+
<div class="flex flex-1 flex-col">
75+
<div class="text-text-primary mb-8 flex w-full flex-col gap-5">
76+
<FeaturedIcon size="lg" variant="info">
77+
<SsoIcon class="size-5" />
78+
</FeaturedIcon>
79+
<div class="flex flex-col gap-3">
80+
<h1 class="text-2xl font-medium">{$t`Sign In With SSO`}</h1>
81+
<p class="text-text-tertiary text-base font-medium">
82+
{$t`Enter your company domain`}
83+
</p>
84+
</div>
85+
</div>
86+
<form
87+
class="flex flex-col items-stretch gap-6"
88+
onsubmit={(e) => {
89+
e.preventDefault();
90+
handleSubmit();
91+
}}
92+
>
93+
<Input
94+
bind:element={inputRef}
95+
bind:value={domain}
96+
oninput={() => {
97+
error = undefined;
98+
}}
99+
inputmode="url"
100+
placeholder={$t`company.domain.com`}
101+
type="text"
102+
size="md"
103+
autocomplete="off"
104+
autocorrect="off"
105+
autocapitalize="off"
106+
spellcheck="false"
107+
disabled={isSubmitting}
108+
{error}
109+
aria-label={$t`Company domain`}
110+
/>
111+
<Button
112+
onclick={handleSubmit}
113+
variant="primary"
114+
size="lg"
115+
type="submit"
116+
disabled={domain.trim().length === 0 || isSubmitting}
117+
>
118+
{#if isSubmitting}
119+
<ProgressRing />
120+
<span>{$t`Signing in...`}</span>
121+
{:else}
122+
<span>{$t`Continue`}</span>
123+
{/if}
124+
</Button>
125+
<button
126+
type="button"
127+
onclick={goBack}
128+
class="text-text-secondary self-center text-sm font-semibold outline-0 hover:underline focus-visible:underline"
129+
>
130+
{$t`Back to sign-in options`}
131+
</button>
132+
</form>
133+
</div>

src/frontend/src/lib/flows/authFlow.svelte.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ import {
3333
RequestConfig,
3434
decodeJWT,
3535
extractIssuerTemplateClaims,
36+
selectAuthScopes,
3637
} from "$lib/utils/openID";
38+
import type { SsoDiscoveryResult } from "$lib/utils/ssoDiscovery";
3739
import { nanosToMillis } from "$lib/utils/time";
3840

3941
interface AuthFlowOptions {
@@ -47,6 +49,7 @@ export class AuthFlow {
4749
| "setupOrUseExistingPasskey"
4850
| "setupNewPasskey"
4951
| "setupNewIdentity"
52+
| "signInWithSso"
5053
>("chooseMethod");
5154
#captcha = $state<{
5255
image: string;
@@ -90,6 +93,40 @@ export class AuthFlow {
9093
this.#view = "setupOrUseExistingPasskey";
9194
};
9295

96+
signInWithSso = (): void => {
97+
this.#view = "signInWithSso";
98+
};
99+
100+
continueWithSso = async (
101+
ssoResult: SsoDiscoveryResult,
102+
): Promise<
103+
| {
104+
identityNumber: bigint;
105+
type: "signIn";
106+
}
107+
| {
108+
name?: string;
109+
type: "signUp";
110+
}
111+
> => {
112+
const { clientId, discovery } = ssoResult;
113+
114+
// Build a synthetic OpenIdConfig from SSO discovery result
115+
const syntheticConfig: OpenIdConfig = {
116+
auth_uri: discovery.authorization_endpoint,
117+
jwks_uri: "",
118+
logo: "",
119+
name: "SSO",
120+
fedcm_uri: [],
121+
email_verification: [],
122+
issuer: discovery.issuer,
123+
auth_scope: selectAuthScopes(discovery.scopes_supported),
124+
client_id: clientId,
125+
};
126+
127+
return await this.continueWithOpenId(syntheticConfig);
128+
};
129+
93130
continueWithExistingPasskey = async (): Promise<bigint> => {
94131
authenticationV2Funnel.trigger(AuthenticationV2Events.UseExistingPasskey);
95132
const { identity, identityNumber, credentialId } =

0 commit comments

Comments
 (0)