Skip to content

Commit 3d587a1

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) <[email protected]>
1 parent e68167f commit 3d587a1

10 files changed

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

src/frontend/src/lib/globals.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ import { fromBase64 } from "./utils/utils";
2020
// direct dependencies between frontend and backend, as they may be deployed independently.
2121
//
2222
// Only compatibility between the two is guaranteed, not strict synchronization.
23+
const OpenIdEmailVerificationIDL = IDL.Variant({
24+
Google: IDL.Null,
25+
Unknown: IDL.Null,
26+
Microsoft: IDL.Null,
27+
});
28+
2329
const backendCanisterConfigIDL = IDL.Record({
2430
openid_configs: IDL.Opt(
2531
IDL.Vec(
@@ -29,19 +35,20 @@ const backendCanisterConfigIDL = IDL.Record({
2935
logo: IDL.Text,
3036
name: IDL.Text,
3137
fedcm_uri: IDL.Opt(IDL.Text),
32-
email_verification: IDL.Opt(
33-
IDL.Variant({
34-
Google: IDL.Null,
35-
Unknown: IDL.Null,
36-
Microsoft: IDL.Null,
37-
}),
38-
),
38+
email_verification: IDL.Opt(OpenIdEmailVerificationIDL),
3939
issuer: IDL.Text,
4040
auth_scope: IDL.Vec(IDL.Text),
4141
client_id: IDL.Text,
4242
}),
4343
),
4444
),
45+
oidc_configs: IDL.Opt(
46+
IDL.Vec(
47+
IDL.Record({
48+
discovery_domain: IDL.Text,
49+
}),
50+
),
51+
),
4552
});
4653

4754
// Types for above IDL definition
@@ -60,7 +67,20 @@ export interface OpenIdConfig {
6067
auth_scope: Array<string>;
6168
client_id: string;
6269
}
63-
export type BackendCanisterConfig = { openid_configs: [] | [OpenIdConfig[]] };
70+
/**
71+
* SSO-provider config registered by an II admin. The backend stores only the
72+
* organization domain; the frontend resolves `client_id` / authorization
73+
* endpoint / scopes on demand via the two-hop SSO discovery chain in
74+
* `ssoDiscovery.ts` when the user enters this domain on the SSO screen.
75+
*/
76+
export interface DiscoverableOidcConfig {
77+
discovery_domain: string;
78+
}
79+
80+
export type BackendCanisterConfig = {
81+
openid_configs: [] | [OpenIdConfig[]];
82+
oidc_configs: [] | [DiscoverableOidcConfig[]];
83+
};
6484

6585
export let canisterId: Principal;
6686
export let frontendCanisterConfig: InternetIdentityFrontendInit;

0 commit comments

Comments
 (0)