Skip to content

feat(be,fe): SSO sign-in via two-hop OIDC discovery#3785

Open
timothyaterton wants to merge 1 commit intodfinity:mainfrom
timothyaterton:frontend-oidc-discovery
Open

feat(be,fe): SSO sign-in via two-hop OIDC discovery#3785
timothyaterton wants to merge 1 commit intodfinity:mainfrom
timothyaterton:frontend-oidc-discovery

Conversation

@timothyaterton
Copy link
Copy Markdown
Contributor

@timothyaterton timothyaterton commented Apr 16, 2026

II admins register SSO provider domains via add_discoverable_oidc_config (an update call, not init args). A user then clicks "Sign in with SSO", types their organization domain, and the frontend runs 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 does not render one button per organization, just a single "Sign in with SSO" entry that leads to the domain input screen. If the user types a domain that isn't registered, they get "This domain is not registered as an OIDC provider." inline.

Replaces #3786, which is closed.

Changes

Backend

Fix: add_discoverable_oidc_config now refreshes /.config.did.bin

add_oidc_config persisted to state but did not re-certify /.config.did.bin. The certified asset only got rebuilt in initialize(), so runtime SSO registrations never reached the frontend — the SSO feature was unreachable until the next canister upgrade. Added refresh_config_asset() which re-runs init_assets(&config()) + update_root_hash() after every state write.

Removed: oidc_configs from init args

InternetIdentityInit previously carried oidc_configs, but applying it had never been wired through apply_install_arg — it was silently dropped on install/upgrade. Since SSO registration goes through the add_discoverable_oidc_config update call anyway, the init-args slot was a footgun: callers could pass it and get no effect. Removed:

  • The oidc_configs field on InternetIdentityInit (Rust + Candid).
  • The From<&InternetIdentityInit> for InternetIdentitySynchronizedConfig impl (no longer coherent).
  • The oidc_configs line in config()'s return.

InternetIdentitySynchronizedConfig (served as /.config.did.bin) still has oidc_configs — that's how the frontend sees registered domains. get_static_assets builds the synchronized config by pulling openid_configs from the passed-in init config and oidc_configs from persistent state.

setup_oidc at post-upgrade reads from persistent state directly, so the in-memory OIDC_CONFIGS thread-local is correctly repopulated across upgrades without needing the init-arg round-trip.

Frontend

Type alignment with backend. DiscoverableOidcConfig is { discovery_domain: string } — matches the Candid shape exactly.

Two-hop discovery (ssoDiscovery.ts, new).

  1. GET https://{domain}/.well-known/ii-openid-configuration returns { client_id, openid_configuration }. The domain owner publishes 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 from the browser. (The backend has its own copy in openid/generic.rs; keeping the implementations separate for now minimizes BE↔FE synchronization.)

SSO flow UI.

  • SignInWithSso.svelte (new): domain input screen. Framed icon chip (FeaturedIcon), title "Sign In With SSO", subtitle "Enter your company domain", placeholder "company.domain.com". On submit: DNS-validate the input → membership check against oidc_configs from the synchronized config → discoverSsoConfigcontinueWithSso → redirect. Unregistered domains render "This domain is not registered as an OIDC provider." inline.
  • SsoIcon.svelte (new): key icon.
  • PickAuthenticationMethod.svelte: "Continue with passkey" is the top button; OIDC provider icons + SSO key icon render in the row below at equal width. The SSO icon is always rendered — even in deployments with no registered domains, so users know the mechanism exists.
  • authFlow.svelte.ts: new signInWithSso view state and continueWithSso() method that synthesizes an OpenIdConfig from discovery results and hands off to the existing continueWithOpenId flow.
  • Subtitle text across all three sign-in entry points updated from "Choose method to continue""Choose an authentication method to continue" to match the design.

Security.

  • Domain input is DNS-format validated (length, label length, forbidden characters).
  • oidc_configs from the synchronized config is the sole allowlist of which organizations can initiate SSO — no hardcoded domain list in frontend code.
  • All three URLs (/.well-known/ii-openid-configuration, provider discovery, authorization endpoint) must be HTTPS.
  • The openid_configuration URL from hop 1 must be on a trusted-provider allowlist (Google, Apple, Microsoft, Okta, login.dfinity.org).
  • Issuer and authorization_endpoint hostnames must match the openid_configuration hostname exactly or as a true subdomain — endsWith alone would accept look-alikes like evildfinity.okta.com.
  • Per-domain rate limit (1 attempt per 10 min), max 2 concurrent discoveries, 4-hour cache per hop, exponential backoff, timeouts (5s hop 1, 10s hop 2) with clearTimeout in finally.

Tests

  • 1 new integration test in http.rsadd_discoverable_oidc_config_refreshes_config_asset: install empty, call the update, assert /.config.did.bin reflects the new registration immediately (no upgrade required).
  • config/oidc_configs.rs::should_coexist_with_openid_configs updated to query SSO state via discovered_oidc_configs instead of the removed config().oidc_configs.
  • 23 unit tests in ssoDiscovery.test.ts (domain validation, two-hop chain, cache, retry, trusted-provider check, HTTPS enforcement, exact-hostname match for issuer / auth endpoint, off-host rejection).
  • 22 unit tests in openID.test.ts preserved, including 4 for selectAuthScopes (shared defaults-fallback helper).
  • cargo test -p internet_identity --bin internet_identity: 219/219 pass.
  • cargo clippy --all-targets -D warnings: clean.
  • npm run lint + svelte-check on touched files: clean.

< Previous PR

@aterga aterga force-pushed the frontend-oidc-discovery branch from 19f0eac to a9731ba Compare April 20, 2026 20:53
aterga added a commit to timothyaterton/internet-identity that referenced this pull request Apr 20, 2026
…ecurity (dfinity#3784)

## Summary

Add the `aud` (audience / client_id) field to `OpenIdCredentialKey`,
changing it from `(iss, sub)` to `(iss, sub, aud)`. This is a security
prerequisite for SSO: since SSO allows anyone to provide a `client_id`
via their `ii-openid-configuration` endpoint, without `aud` in the key
two different OIDC clients at the same provider with the same user `sub`
would collide, enabling impersonation.

## Changes

- **Type update**: `OpenIdCredentialKey` type alias changed from `(Iss,
Sub)` to `(Iss, Sub, Aud)` in both `internet_identity_interface` and the
`openid` module
- **CBOR encoding**: `StorableOpenIdCredentialKey` rewritten with manual
`Encode`/`Decode` impls — new entries use CBOR map format `{0:iss,
1:sub, 2:aud}`; the decoder also handles legacy CBOR array format `[iss,
sub]` for backward compatibility
- **Migration**: `post_upgrade` drains the credential key index via
`pop_first`, resolves `aud` from each anchor's
`StorableOpenIdCredential` (which already stores `aud` at CBOR index
`#[n(2)]`), and re-inserts with the complete `(iss, sub, aud)` key.
Unresolvable entries are preserved with empty `aud` for retry on next
upgrade.
- **Key construction**: Updated `OpenIdCredential::key()`,
`StorableOpenIdCredential::key()`, `calculate_delegation_seed()`, and
all call sites
- **Candid interface**: Updated `.did` file and generated JS/TS
declarations
- **Frontend**: Updated credential removal call to pass `aud`
- **Tests**: Added unit tests for new CBOR map encoding, legacy array
decoding, and round-trip serialization. Updated existing test assertions
to use 3-tuple keys.

## Delegation seed backward compatibility

The `calculate_delegation_seed` function already receives `client_id`
(which equals `aud`) as a separate parameter. The seed calculation is
unchanged — `aud` from the key tuple is ignored (`_aud`) in the
destructuring, preserving identical `Principal` derivation for existing
credentials.

## Migration safety

- Uses `pop_first()` to drain the BTreeMap, avoiding byte-level encoding
mismatches between legacy array-encoded keys and new map-encoded keys
- Resolves `aud` from the anchor's stored `StorableOpenIdCredential`
which already has `aud` at CBOR index 2
- Falls back to re-inserting with empty `aud` if resolution fails, with
a logged warning — the entry is preserved for retry on next upgrade
- Idempotent: safe to run on every upgrade; entries already in the new
format are preserved unchanged

## Test plan

- [x] All 209 unit tests pass (including Candid interface compatibility)
- [ ] Integration tests (require canister WASM build — pass in CI)
- [ ] Deploy to testnet and verify migration of existing credentials
- [ ] Verify credential lookup works after migration
- [ ] Verify new credential registration includes `aud` in key

---
[< Previous PR](dfinity#3778)
| [Next PR >](dfinity#3785)

---------

Co-authored-by: Claude Agent <noreply@anthropic.com>
Co-authored-by: Arshavir Ter-Gabrielyan <arshavir.ter.gabrielyan@dfinity.org>
@aterga aterga marked this pull request as ready for review April 20, 2026 20:55
@aterga aterga requested a review from a team as a code owner April 20, 2026 20:55
Copilot AI review requested due to automatic review settings April 20, 2026 20:55
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds frontend-side OIDC discovery for a new “discoverable” provider configuration list (oidc_configs), enabling the UI/auth flow to fetch provider discovery documents on demand (instead of relying solely on backend-provided openid_configs).

Changes:

  • Extend backend config decoding/types to include oidc_configs (discoverable providers with discovery_url + optional client_id).
  • Add oidcDiscovery.ts with caching/rate limiting/validation and integrate it into auth + OpenID config lookup.
  • Render additional provider buttons for oidc_configs and add unit tests for discovery + config lookup.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/frontend/src/lib/utils/openID.ts Extend findConfig() to synthesize an OpenIdConfig from cached OIDC discovery + oidc_configs.
src/frontend/src/lib/utils/openID.test.ts Add tests covering findConfig() behavior with oidc_configs and discovery cache mocking.
src/frontend/src/lib/utils/oidcDiscovery.ts New module implementing OIDC discovery fetch, validation, caching, concurrency + rate limiting.
src/frontend/src/lib/utils/oidcDiscovery.test.ts New unit tests for discovery fetch, caching, and validation behavior.
src/frontend/src/lib/globals.ts Add candid IDL + TS types to decode oidc_configs from .config.did.bin.
src/frontend/src/lib/flows/authFlow.svelte.ts Add continueWithOidc() that fetches discovery and reuses the existing OpenID flow.
src/frontend/src/lib/components/wizards/auth/views/PickAuthenticationMethod.svelte Render provider buttons for oidc_configs and wire them to a new handler.
src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte Wire continueWithOidc handler into the auth wizard flow.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
Comment thread src/frontend/src/lib/flows/authFlow.svelte.ts Outdated
Comment thread src/frontend/src/lib/flows/authFlow.svelte.ts Outdated
Comment thread src/frontend/src/lib/utils/openID.ts Outdated
Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
@timothyaterton timothyaterton changed the title feat: frontend OIDC discovery for on-demand provider config feat(fe): OIDC discovery for on-demand provider config Apr 20, 2026
@aterga aterga requested review from Copilot and sea-snake April 20, 2026 21:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
Comment thread src/frontend/src/lib/utils/openID.ts Outdated
Comment thread src/frontend/src/lib/utils/oidcDiscovery.ts Outdated
@aterga aterga force-pushed the frontend-oidc-discovery branch from 0ebd6e2 to 3d587a1 Compare April 20, 2026 22:10
@timothyaterton timothyaterton changed the title feat(fe): OIDC discovery for on-demand provider config feat(fe): SSO sign-in via two-hop OIDC discovery Apr 20, 2026
@aterga
Copy link
Copy Markdown
Collaborator

aterga commented Apr 20, 2026

@copilot-pull-request-reviewer the PR has been restructured significantly in 3d587a1 (see previous comment thread replies). The earlier oidcDiscovery.ts primitive and the eager per-provider OIDC button rendering are removed; the SSO flow is now a single domain-input entry point (SignInWithSso.svelte) with the allowlist sourced from oidc_configs. Please re-review the current state.

@aterga aterga force-pushed the frontend-oidc-discovery branch 2 times, most recently from 41c03a6 to 815d71e Compare April 20, 2026 23:56
@aterga aterga changed the title feat(fe): SSO sign-in via two-hop OIDC discovery feat(be,fe): SSO sign-in via two-hop OIDC discovery Apr 20, 2026
@aterga aterga force-pushed the frontend-oidc-discovery branch from 815d71e to 19d0014 Compare April 21, 2026 00:17
II admins register SSO provider domains via `add_discoverable_oidc_config`
(an update call, not init args). A user then clicks "Sign in with SSO",
types their organization domain, and the frontend runs 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 does not render one
button per organization, just a single "Sign in with SSO" entry that
leads to the domain input screen. If the user types a domain that
isn't registered, they get "This domain is not registered as an OIDC
provider." inline.

Replaces dfinity#3786, which is closed.

# Changes — backend

## Fix: `add_discoverable_oidc_config` now refreshes `/.config.did.bin`

`add_oidc_config` persisted to state but did not re-certify
`/.config.did.bin`. The certified asset only got rebuilt in
`initialize()`, so runtime SSO registrations never reached the
frontend — the SSO icon stayed hidden until the next canister
upgrade. Added `refresh_config_asset()` which re-runs
`init_assets(&config())` + `update_root_hash()` after every state
write.

## Remove: `oidc_configs` from init args

`InternetIdentityInit` previously carried `oidc_configs`, but applying
it had never been wired through `apply_install_arg` — it was silently
dropped on install/upgrade. Since SSO registration goes through the
`add_discoverable_oidc_config` update call anyway, the init-args slot
was a footgun: callers could pass it and get no effect. Removed:

- The `oidc_configs` field on `InternetIdentityInit`.
- The `From<&InternetIdentityInit> for InternetIdentitySynchronizedConfig`
  impl (can no longer copy the field that doesn't exist).
- The `oidc_configs` line in the Candid interface of `InternetIdentityInit`.
- The `oidc_configs` line in `config()`'s return.

`InternetIdentitySynchronizedConfig` (served as `/.config.did.bin`)
still has `oidc_configs` — that's how the frontend sees registered
domains. `get_static_assets` now builds the synchronized config by
pulling `openid_configs` from the passed-in init config and
`oidc_configs` from persistent state.

`setup_oidc` at post-upgrade reads from persistent state directly
rather than going through the removed init-arg field, so the in-memory
`OIDC_CONFIGS` thread-local is correctly repopulated across upgrades.

# Changes — frontend

**Type alignment with backend.** `DiscoverableOidcConfig` is
`{ discovery_domain: string }`.

**Two-hop discovery (`ssoDiscovery.ts`, new).**
1. `GET https://{domain}/.well-known/ii-openid-configuration` returns
   `{ client_id, openid_configuration }`.
2. `GET {openid_configuration}` is the provider's standard OIDC
   discovery, yielding `authorization_endpoint` / `scopes_supported`.

Both hops run from the browser. (The backend has its own copy in
`openid/generic.rs`; keeping the implementations separate for now
minimizes BE↔FE synchronization.)

**SSO flow UI.**
- `SignInWithSso.svelte` (new): domain input. Framed icon chip, title
  "Sign In With SSO", subtitle "Enter your company domain",
  placeholder `company.domain.com`. On submit: DNS-validate →
  membership check against `oidc_configs` from synchronized config →
  `discoverSsoConfig` → `continueWithSso` → redirect.
- `SsoIcon.svelte` (new): key icon.
- `PickAuthenticationMethod.svelte`: "Continue with passkey" at the
  top; OIDC provider icons + SSO key icon render in the row below at
  equal width. SSO icon appears only when `oidc_configs` is non-empty.
- `authFlow.svelte.ts`: new `signInWithSso` view state and
  `continueWithSso()` method.
- Subtitle text across all three sign-in entry points updated from
  "Choose method to continue" → "Choose an authentication method to
  continue".

**Security.**
- Domain input DNS-format validated.
- `oidc_configs` from the synchronized config is the sole allowlist —
  no hardcoded domain list in frontend code.
- All three URLs (`/.well-known/ii-openid-configuration`, provider
  discovery, authorization endpoint) must be HTTPS.
- The `openid_configuration` URL must be on a trusted-provider
  allowlist (Google, Apple, Microsoft, Okta, `login.dfinity.org`).
- Issuer and `authorization_endpoint` hostnames must match the
  `openid_configuration` hostname exactly or as a true subdomain —
  `endsWith` alone would accept look-alikes like `evildfinity.okta.com`.
- Per-domain rate limit (1 / 10 min), max 2 concurrent discoveries,
  4-hour cache per hop, exponential backoff, timeouts with
  `clearTimeout` in `finally`.

# Tests

- 1 new integration test in `http.rs`:
  `add_discoverable_oidc_config_refreshes_config_asset` — install
  empty, call the update, assert `/.config.did.bin` reflects the new
  registration immediately (no upgrade required).
- `config/oidc_configs.rs::should_coexist_with_openid_configs` updated
  to query SSO state via `discovered_oidc_configs` instead of
  `config().oidc_configs`.
- 23 unit tests in `ssoDiscovery.test.ts` (domain validation, two-hop
  chain, cache, retry, trusted-provider check, HTTPS enforcement,
  exact-hostname match for issuer/auth endpoint, off-host rejection).
- 22 unit tests in `openID.test.ts` preserved, including 4 for
  `selectAuthScopes`.
- `cargo test -p internet_identity --bin internet_identity`: 219/219
  pass.
- `cargo clippy --all-targets -D warnings`: clean.
- `npm run lint` + `svelte-check` on touched files: clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aterga aterga force-pushed the frontend-oidc-discovery branch from 19d0014 to 914f7e9 Compare April 21, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants