Skip to content

refactor(be,fe): data-only OpenID providers with on-demand SSO discovery/JWKS caches#4022

Open
sea-snake wants to merge 12 commits into
mainfrom
feat/openid-sso-cache-verify
Open

refactor(be,fe): data-only OpenID providers with on-demand SSO discovery/JWKS caches#4022
sea-snake wants to merge 12 commits into
mainfrom
feat/openid-sso-cache-verify

Conversation

@sea-snake

Copy link
Copy Markdown
Contributor

OpenID sign-in dispatched through trait objects (dyn OpenIdProvider) and resolved SSO organization domains client-side. This reworks it into a single data-driven verification pipeline with two clearly separated JWK sources of truth, moves SSO discovery onto the canister, and makes the "cache still warming" state a first-class retry signal instead of an error. No user-visible behavior changes for the configured Google / Microsoft / Apple providers; SSO (organization) sign-in now resolves and verifies entirely canister-side.

Changes

Backend

  • Replace the dyn OpenIdProvider trait dispatch and the monolithic openid/generic.rs with a data-only provider model: one shared verification pipeline (openid/verify.rs, provider.rs, jwks.rs) that is identical for every provider up to the point it reads the JWK.
  • Two JWK sources of truth behind that shared pipeline: configured providers (openid/configured.rs) keep their timers + stable-storage-persisted keys (some providers can't reach HTTP-outcall consensus); SSO providers (openid/sso.rs) source keys on demand through the single-flight cache.
  • Canister-side SSO discovery: discover_sso : (text) -> () (fire-and-forget drive of the two-hop discovery+JWKS fetch) and get_sso_discovery : (text) -> (SsoDiscoveryState) query where SsoDiscoveryState = variant { Resolved : SsoDiscovery; Pending; NotAllowed }. Gated by the sso_discoverable_domains allowlist. Replaces the client-side discovery module.
  • The four OpenID JWT methods take an optional SSO discovery domain and return Pending as a result arm (not an error) when the SSO discovery/JWKS cache is cold or has been evicted:
    • openid_credential_add, openid_prepare_delegation, openid_get_delegation return OpenIdResult<T, E> = variant { Ok; Pending; Err }.
    • openid_identity_registration_finish returns variant { Ok; Pending; Err }; its verification is hoisted into the endpoint so the JWT is verified exactly once and the pubkey-registration path is untouched.
    • Pending is removed from OpenIdCredentialAddError / OpenIdDelegationError.

Frontend

  • SSO discovery polls get_sso_discovery and drives discover_sso while Pending; ssoDiscovery.ts no longer fetches discovery documents itself.
  • Sign-in, account-link, and signup all retry while the canister reports Pending (shared retryWhilePending / poll loop), so a cold or evicted SSO cache no longer turns into a hard error.

Tests

  • New config/sso_discovery.rs integration tests for the discover_sso / get_sso_discovery allowlist gate.
  • Updated OpenID / attributes / v2_api registration integration suites and api helpers for the new return shapes.
  • SSO Playwright e2e green (12/12) across the discovery, sign-in, link, and signup paths.

🤖 Generated with Claude Code

sea-snake and others added 12 commits June 15, 2026 12:57
…aches

Refactor the OpenID module off trait-dispatched providers (Vec<Box<dyn
OpenIdProvider>>) onto a data-only model with a single shared verification
pipeline. Verification is identical for every provider kind up to the JWK
read; the only divergence is the JWK source:

- Configured providers (Google/Microsoft/Apple): JWKs in stable storage
  (memory id 24), seeded + timer-refreshed (unchanged, PR #3959). Always
  synchronously Ready.
- SSO (discoverable) providers: discovery and JWKS fetched on demand into two
  single-flight caches (domain -> config, jwks_uri -> keys), replacing the
  DISCOVERY_TASKS background timer. May read Pending on a cold cache.

Module reshape (openid/generic.rs removed):
- verify.rs   shared pipeline (decode -> claims -> signature -> build)
- configured.rs  data-only CONFIG_REGISTRY + stable JWKs + refresh timer + seed
- sso.rs      two single-flight caches + on-demand two-hop discovery + allowlist
- jwks.rs     the JwkSource seam + shared JWKS fetch
- provider.rs dispatch: (iss,aud[,discovery_domain]) -> descriptor + JwkSource

The four JWT methods take an optional discovery_domain and surface a Pending
error on a cold SSO cache (configured providers never do). prepare/get
delegation use the poll model: prepare (update) drives the fills, get (query)
peeks via the new single_flight_cache::peek, re-calling prepare on Pending.
Adds discover_sso / discover_sso_query for canister-side sign-in initiation.

Drops the sso_fields_for reverse-scan (migration #4013 complete); credential
SSO fields are read straight off the stamp. Candid + tests updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…x comments

- Replace the update/query mode flag with one peek-based read path
  (verify_jwt, discover_sso, resolve, read_jwks) plus a separate prefetch_sso
  that updates call to drive the cache fills. Queries read; updates prefetch
  then read.
- Remove the now-unused discoverable-OIDC registry and listing:
  discovered_oidc_configs, add_discoverable_oidc_config, OidcConfig, and the
  in-memory domain list. On-demand discover_sso gated by the allowlist covers
  the use case.
- Comment pass: describe the code as it is, no design-doc/PR citations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… caches

The SSO discovery domain now flows through the JWT methods, and the frontend
reads the canister's on-demand discovery instead of fetching it itself.

- authenticateWithJWT takes a discoveryDomain and polls prepare/get delegation
  while the canister reports Pending (cold SSO cache), re-calling prepare to
  drive the fetch. Configured providers resolve on the first attempt.
- ssoDiscovery.ts resolves a domain through discover_sso / discover_sso_query
  (validate, drive, poll) instead of the client-side two-hop fetch; the auth
  and add-access-method flows thread the domain into prepare/credential_add/
  registration_finish.
- Shared openidPoll helpers (pollDelay, retryWhilePending) back the three poll
  sites. discover_sso replaces the add_discoverable_oidc_config call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ration

create_identity verified the JWT inside storage_borrow_mut; now that
verification reads JWKs from stable storage / the SSO caches, that nested
storage_borrow trapped with 'RefCell already mutably borrowed'. Hoist the
verification out of the mutable borrow and pass the result in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 1-click `?sso=<domain>` authorize path redeemed its JWT through
continueWithOpenId without the discovery domain, so the canister verified it
against the (empty) configured registry and failed. Thread discoveryDomain
through continueWithOpenId -> openIdJwtSignIn and through completeOpenIdReg ->
registerWithOpenId -> openIdRegistrationCommit so SSO sign-in and sign-up both
verify against the domain's discovery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
discover_sso/discover_sso_query both returned the value, making the query look
redundant. Match the DoH resolve/status poll instead:

- discover_sso (update) only drives the discovery cache (one cache — JWKS is a
  verify-time concern) and returns Ok/Err, no value.
- get_sso_discovery (query) reads the resolved config.
- The frontend polls the query and, while it reads no value, drives the update
  — same shape as the email-recovery DoH poll loop.

Also replace the free-text Err with a typed SsoDiscoveryError { DomainNotAllowed }
(a failed fetch reads as not-resolved-yet, so that's the only error), and drop
the unused detail field / parameter-property syntax on DomainNotConfiguredError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/Result

get_sso_discovery returned Result<opt SsoDiscovery, SsoDiscoveryError> — two
layers of 'maybe' that don't say what's going on. Replace it with a status
variant, like the email-recovery status query:

  get_sso_discovery : (text) -> (SsoDiscoveryState) query;
  SsoDiscoveryState = variant { Resolved : SsoDiscovery; Pending; NotAllowed };

discover_sso (update) becomes a fire-and-forget drive (-> ()); the allowlist
rejection is reported by the query's NotAllowed state, so SsoDiscoveryError is
gone. The frontend polls the query and drives the update while Pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The frontend build's message extraction rewrote the bot-managed locale files;
restore them to match main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cold SSO discovery/JWKS cache is a retry signal, not a failure, but it
was modelled as a `Pending` member of OpenIdCredentialAddError and
OpenIdDelegationError. That put a transient state on the error channel: the
shared error toaster (error.ts) had to special-case it, and any consumer that
folded the error into a terminal failure would wrongly give up.

Move it onto the result:

  openid_credential_add     -> variant { Ok; Pending; Err : OpenIdCredentialAddError }
  openid_prepare_delegation -> variant { Ok : ...; Pending; Err : OpenIdDelegationError }
  openid_get_delegation     -> variant { Ok : ...; Pending; Err : OpenIdDelegationError }

backed by a small generic OpenIdResult<T, E>. Pending drops out of both error
enums and out of the error.ts catch-all. The frontend poll loops (jwt.ts,
retryWhilePending) match the top-level Pending arm and retry — including the
account-link path, which now keeps polling while the cache warms instead of
erroring out. openid_identity_registration_finish is unchanged: the sign-in
attempt warms the JWKS before registration, so it never surfaces Pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
openid_identity_registration_finish folded a cold SSO discovery/JWKS cache
(verify_jwt -> Cached::Pending) into a terminal IdRegFinishError, on the
assumption that the preceding sign-in had already warmed the keys. But the
JWKS cache is LRU + TTL with backoff, so the entry can be evicted between
sign-in and registration — turning an SSO signup into a hard failure with a
misleading "OIDC discovery in progress" error and no retry.

Surface it as a retry instead, like the other OpenID methods:

  openid_identity_registration_finish -> variant { Ok : IdRegFinishResult; Pending; Err : IdRegFinishError }

Verification is hoisted into the endpoint (verify_openid_for_registration):
on Cached::Pending it returns the Pending arm; on Cached::Ready the verified
credential is handed to the shared registration flow, so it's verified exactly
once and the pubkey path is untouched. The frontend signup commit polls with
retryWhilePending, consistent with the link and sign-in paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 16, 2026 10:59
@sea-snake sea-snake requested a review from a team as a code owner June 16, 2026 10:59
@zeropath-ai

zeropath-ai Bot commented Jun 16, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to b136671.

Security Overview
Detected Code Changes

The diff is too large to display a summary of code changes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Refactors Internet Identity’s OpenID/SSO implementation from provider trait dispatch into a single shared, data-driven JWT verification pipeline. It moves SSO two-hop discovery and JWKS caching fully canister-side, and introduces a first-class Pending retry result for cold/evicted SSO discovery/JWKS caches, with corresponding frontend polling/retry logic.

Changes:

  • Backend: Introduces shared OpenID verification pipeline (openid/verify.rs) with provider resolution (provider.rs) and a JWK source seam (jwks.rs) split between configured providers (stable+timer refreshed) and SSO providers (on-demand single-flight caches).
  • Backend API/DID: Adds discover_sso / get_sso_discovery, and updates OpenID methods to accept an optional SSO discovery domain and return OpenIdResult { Ok | Pending | Err }.
  • Frontend: Removes client-side two-hop discovery fetching; instead polls get_sso_discovery and drives discover_sso, and retries OpenID calls while the canister returns Pending.

Reviewed changes

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

Show a summary per file
File Description
src/internet_identity/tests/integration/v2_api/authn_method_test_helpers.rs Updates OpenID registration helper to include discovery_domain.
src/internet_identity/tests/integration/config/sso_discovery.rs New integration tests for SSO discovery allowlist gating.
src/internet_identity/tests/integration/config/oidc_configs.rs Removes obsolete OIDC config registration/discovery tests.
src/internet_identity/tests/integration/config.rs Switches integration test module from oidc_configs to sso_discovery.
src/internet_identity/src/storage/anchor.rs Drops legacy fallback for unstamped SSO fields; reads stamped values directly.
src/internet_identity/src/single_flight_cache.rs Adds query-safe peek API that never spawns fills/mutates cache.
src/internet_identity/src/openid/verify.rs New shared JWT verification + credential build pipeline.
src/internet_identity/src/openid/sso.rs New canister-side SSO discovery + JWKS single-flight caches and allowlist gate.
src/internet_identity/src/openid/provider.rs Resolves JWTs to provider descriptors + JWK sources (configured vs SSO).
src/internet_identity/src/openid/jwks.rs Introduces JWK source abstraction + shared JWKS fetch/transform.
src/internet_identity/src/openid/configured.rs New configured-provider registry + stable JWKS seed/refresh logic.
src/internet_identity/src/openid.rs Replaces provider trait dispatch with configured/SSO resolution and shared verify pipeline; adds SSO discovery APIs.
src/internet_identity/src/main.rs Updates canister methods for new OpenID result shapes and adds SSO discovery endpoints.
src/internet_identity/src/attributes.rs Updates SSO attribute tests to rely on stamped sso_domain.
src/internet_identity/src/anchor_management/registration/registration_flow_v2.rs Hoists OpenID verification for registration and threads verified credential through finish path; supports Pending.
src/internet_identity/internet_identity.did Updates DID types/methods for OpenIdResult, SSO discovery APIs, and new args.
src/internet_identity_interface/src/internet_identity/types/openid.rs Adds OpenIdResult type used by updated OpenID APIs.
src/internet_identity_interface/src/internet_identity/types/api_v2.rs Extends OpenIDRegFinishArg with discovery_domain.
src/internet_identity_interface/src/internet_identity/types.rs Adds SsoDiscovery / SsoDiscoveryState and updates related docs.
src/frontend/tests/e2e-playwright/fixtures/sso.ts Adjusts e2e fixture comments to reflect canister-side SSO discovery.
src/frontend/src/routes/(new-styling)/authorize/+page.ts Updates allowlist-boundary comments for 1-click SSO entry.
src/frontend/src/routes/(new-styling)/authorize/+page.svelte Removes client call to register discoverable OIDC config; passes SSO domain through OpenID flow.
src/frontend/src/lib/utils/ssoDiscovery.ts Replaces FE two-hop fetch with canister-driven discover_sso/get_sso_discovery polling.
src/frontend/src/lib/utils/ssoDiscovery.test.ts Updates unit tests to mock canister SSO discovery API and polling behavior.
src/frontend/src/lib/utils/openidPoll.ts New shared polling helpers (pollDelay, retryWhilePending).
src/frontend/src/lib/utils/authentication/jwt.ts Retries openid_prepare_delegation / openid_get_delegation while canister returns Pending.
src/frontend/src/lib/stores/last-used-identities.store.ts Updates docs around SSO resolution source (canister discovery).
src/frontend/src/lib/generated/internet_identity_types.d.ts Regenerates bindings for updated DID (new SSO types, new OpenID arg/result shapes).
src/frontend/src/lib/generated/internet_identity_idl.js Regenerates IDL factory for updated DID (new methods and types).
src/frontend/src/lib/flows/authLastUsedFlow.svelte.ts Re-resolves SSO configs via canister when continuing with last-used SSO identity.
src/frontend/src/lib/flows/authFlow.svelte.ts Threads SSO discovery domain through OpenID flows and retries registration while Pending.
src/frontend/src/lib/flows/addAccessMethodFlow.svelte.ts Links OpenID/SSO credentials while retrying openid_credential_add on Pending.
src/frontend/src/lib/components/wizards/auth/views/SignInWithSso.svelte Removes canister-side SSO registration call; relies on discovery polling and updated error mapping.
src/frontend/src/lib/components/wizards/auth/views/PickAuthenticationMethod.svelte Updates comments to reflect new SSO allowlist enforcement via discover_sso.
src/frontend/src/lib/components/wizards/addAccessMethod/views/AddAccessMethod.svelte Updates comments to reflect new canister-side SSO resolution/rejection.
src/canister_tests/src/api/internet_identity/api_v2.rs Updates v2 OpenID registration finish helper to collapse OpenIdResult via settled.
src/canister_tests/src/api/internet_identity.rs Replaces OIDC discovery helpers with SSO discovery helpers and adds settled for OpenID result collapsing.

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

"Name too long".into(),
));
}
if let Some(EmailVerifiedClaim::String(ref s)) = claims.email_verified {
Comment on lines +146 to +148
stamp: Stamp::Sso {
domain: domain.to_string(),
name: cfg.name,
Comment on lines 419 to 422
sender,
"openid_prepare_delegation",
(jwt, salt, session_key),
)
Comment on lines 435 to 441
query_candid_as(
env,
canister_id,
sender,
"openid_get_delegation",
(jwt, salt, session_key, expiration),
)
Comment on lines 453 to 460
call_candid_as(
env,
canister_id,
RawEffectivePrincipal::None,
sender,
"openid_credential_add",
(identity_number, jwt, salt),
)
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
use base64::Engine;
use candid::Deserialize;
use ic_stable_structures::Storable;
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.

2 participants