Skip to content

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

Merged
aterga merged 24 commits into
mainfrom
feat/openid-sso-cache-verify
Jun 19, 2026
Merged

refactor(be,fe): data-only OpenID providers with on-demand SSO discovery/JWKS caches#4022
aterga merged 24 commits into
mainfrom
feat/openid-sso-cache-verify

Conversation

@sea-snake

@sea-snake sea-snake commented Jun 16, 2026

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

Next: #4045

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 ae6c8b3.

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.

Comment thread src/internet_identity/src/openid/verify.rs
Comment thread src/internet_identity/src/openid/sso.rs
Comment thread src/canister_tests/src/api/internet_identity.rs
Comment thread src/canister_tests/src/api/internet_identity.rs
Comment thread src/canister_tests/src/api/internet_identity.rs
Comment thread src/internet_identity/src/openid/verify.rs
@sea-snake sea-snake requested review from MRmarioruci and aterga June 19, 2026 09:01
Resolve conflicts in the generated II bindings (internet_identity_idl.js,
internet_identity_types.d.ts) by regenerating from the merged candid with the
pinned didc; both sides only added independent types/methods (SSO discovery
here, session delegation on main).
The untrusted discovery_domain argument reached the sso_domain stamp verbatim
while the allowlist gate matches case-insensitively, so a mixed-case domain
(e.g. Example.ORG) passed the gate but was stored under a different
sso:<domain> scope than the canonical allowlisted value, breaking credential
lookup. Trim + lowercase it at each endpoint boundary via a shared
canonical_discovery_domain helper, matching the sso_discoverable_domains config
setter and the get_sso_discovery reply which already canonicalize.
@sea-snake

Copy link
Copy Markdown
Contributor Author

Merged main (only the two generated candid bindings conflicted — regenerated from the merged .did with the pinned didc; npm run generate yields no further diff).

Went through the 6 Copilot comments; only one was a real issue:

  • sso.rs — discovery_domain stamped verbatim ✅ Fixed. Canonicalized (trim + lowercase) at the canister boundary in all four endpoints that accept discovery_domain (openid_credential_add, openid_prepare_delegation, openid_get_delegation, openid_identity_registration_finish) via a shared openid::canonical_discovery_domain, rather than inside resolve() — the arg is untrusted and the allowlist gate is case-insensitive, so it must be canonicalized where it enters, matching the existing sso_discoverable_domains setter and get_sso_discovery. Added a unit test.
  • verify.rs:20 Storable "unused" ❌ It is used — caller().to_bytes() needs the trait in scope; removing it fails the build (E0599). No change.
  • verify.rs:318 email_verified "won't compile" ❌ Current code matches with ref s, which binds by reference and doesn't move out of &Claims. Compiles. No change.
  • internet_identity.rs 422/441/460 — helpers "fail candid" without the trailing arg ❌ Candid lets a caller omit trailing optional arguments (decoded as null); these are configured-provider helpers that intentionally pass no domain. Confirmed by the integration suite (19/19 OpenID tests green). No change.

Validation: clippy -D warnings clean, cargo fmt, frontend check/lint/format clean, OpenID integration 19/19, SSO discovery 2/2, openid unit 15/15.

Comment thread src/canister_tests/src/api/internet_identity/api_v2.rs
The SSO sign-in/delegation path had no canister-endpoint integration
coverage — only sso.rs/verify.rs unit tests and Playwright e2e. Existing
OpenID integration tests exercise configured providers with no discovery
domain; config::sso_discovery only covers the allowlist gate.

Add integration tests that drive the on-demand SSO path with a discovery
domain, mocking the two discovery hops + the JWKS fetch so the single-flight
caches warm from Pending to Ready (the frontend's retry-while-Pending loop):
- can_link_sso_account_via_discovery: credential add stamps the discovered
  sso_domain / sso_name.
- can_get_sso_delegation_via_discovery: prepare (update, warms caches) + get
  (query, reads warm caches) issue a delegation.
- sso_discovery_domain_is_canonicalized: a mixed-case/padded domain is stored
  as canonical lowercase — regression test for the boundary canonicalization.

Adds *_with_discovery helper variants returning the raw OpenIdResult so the
Pending arm is observable; the existing helpers delegate with None.
Comment thread src/internet_identity/src/openid/jwks.rs Outdated
On a system subnet HTTP outcalls cost no cycles and ingress flooding is a
boundary-node concern, so the SSO caches' only DDoS-relevant job is bounded,
fairly-evicted state — which matters once the discovery allowlist is removed
and the discovery domain / jwks_uri become caller-controlled and unbounded.
Each cache's worst case is max_entries * per-entry cap, so:

- Split the shared cache_config so the two caches size independently:
  discovery max_entries 10_000 (small DiscoveredConfig, ~10-20 MB), JWKS
  max_entries 2_000 (~64 MB at the 32 KiB cap below).
- Cap HTTP outcall response sizes (were unbounded): discovery hops 16 KiB;
  JWKS fetch 32 KiB — broad enough for real key sets with x5c chains
  (Microsoft is ~14.5 KB) so the shared configured-provider refresh is
  unaffected.
- Cap kept JWKS keys at 20 (verification matches by kid; providers publish
  far fewer) and stored discovery scopes at 32 (the only unbounded
  DiscoveredConfig field).

No behavior change for honest providers; bounds the attacker-reachable
worst case to ~tens of MB. Allowlist removal itself is deferred.
The discovery and JWKS caches back one coupled flow (domain -> discovery ->
jwks_uri -> JWKS), and a verification needs both the domain's discovery entry
and its JWKS entry warm, so separate budgets just leave one cache holding
entries the other can't back. Replace the two caps with a single
SSO_CACHE_MAX_ENTRIES (5000), bounded by the larger JWKS entry (32 KiB) ->
~170 MB worst case (~5-6% of the ~3 GB heap), keeping wide eviction headroom.
@sea-snake

Copy link
Copy Markdown
Contributor Author

Bounded the two SSO single-flight caches (DISCOVERY_CACHE, JWKS_CACHE) so canister state stays bounded once discovery domains / jwks_uris become caller-controlled (the allowlist removal is a separate follow-up).

Why this shape. II is on a system subnet — HTTP outcalls cost no cycles — and ingress flooding is handled by the boundary nodes. So these caches don't need outcall-rate-limiting or cycle accounting; their only DDoS-relevant job is bounded, fairly-evicted state.

Single shared budget. Discovery and JWKS are one coupled flow (domain → discovery → jwks_uri → JWKS); a verification needs both the domain's discovery entry and its JWKS entry warm, so they share one SSO_CACHE_MAX_ENTRIES = 5000 rather than separate caps (separate ones would just leave one cache holding entries the other can't back). Bounded by the larger JWKS entry (≤ 32 KiB): 5000 × 32 KiB ≈ 160 MB + ~10 MB discovery ≈ ~170 MB worst case, ~5–6% of the ~3 GB Wasm heap — wide eviction headroom so a junk-domain flood can't evict the providers real users rely on.

Per-fill / per-entry caps (fills were previously unbounded, max_response_bytes: None):

  • Response size: discovery hops 16 KiB; JWKS fetch 32 KiB — broad because real key sets embed x5c chains (Microsoft's JWKS is ~14.5 KB with 8 keys) and this fetch is shared with the configured Google/Microsoft refresh, which must not be rejected.
  • Content: ≤ 20 JWKs (verification matches by kid; providers publish far fewer) and ≤ 32 stored discovery scopes (the only unbounded DiscoveredConfig field).

No behavior change for honest providers.

Comment thread src/internet_identity/src/main.rs Outdated
Comment thread src/internet_identity/src/main.rs
Comment thread src/frontend/src/lib/utils/openidPoll.ts Outdated
sea-snake and others added 2 commits June 19, 2026 16:13
discover_sso / get_sso_discovery passed the raw domain straight through;
the lookup path lowercases but never trims, so " dfinity.org" was
rejected where the JWT endpoints (which canonicalize at the boundary)
accept it. Route both through canonical_discovery_domain so every
endpoint gates on the same trimmed+lowercased form. Splits the
canonicalizer into a String->String core and an Option wrapper
(canonical_discovery_domain_opt) for the JWT endpoints.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
discoverSsoConfig only checked signal.aborted at the top of each
iteration, so an abort during the 500ms sleep or the get_sso_discovery
query still fired one more discover_sso update before the loop noticed.
Make pollDelay resolve early on abort and re-check the signal before
driving the update, so a dropped lookup stops without an extra call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The loop-top `signal?.aborted === true` guard narrows `aborted` to
`false | undefined` for the rest of the iteration, and tsc doesn't
re-widen across the intervening `await`s, so the second inline check
before the discover_sso update was flagged as an always-false comparison
(TS2367), breaking `npm run check` and the frontend wasm build. Extract
an `isAborted(signal)` helper so each check returns a fresh boolean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/frontend/src/lib/utils/ssoDiscovery.ts
sea-snake added a commit that referenced this pull request Jun 19, 2026
Bring in the 3 base commits (incl. the TS2367 SSO discovery abort fix) that
landed on #4022 after this branch was cut.
Comment thread src/internet_identity/src/openid/verify.rs
Comment thread src/internet_identity/src/openid/verify.rs Outdated
Comment thread src/internet_identity/src/openid/verify.rs
aterga and others added 3 commits June 19, 2026 19:47
Backend (openid):
- Fix build: import the `std::fmt::Display` trait (not the
  `std::ffi::os_str::Display` struct) and repair a botched `resolve()`
  call in the SSO unit tests left over from adding the `jwt_aud` arg.
- verify_claims: make the JWT time checks panic-free. Compare validity
  in whole seconds and use `checked_add` for `iat + window` so a
  malicious far-future `iat`/`exp` can't overflow `secs_to_nanos`.
- verify_and_build: destructure the verified `Claims`, explicitly
  dropping `aud` (only weakly checked; the canonical `client_id` is
  stored instead) and the consumed `nonce`/`exp`/`iat`.
- Add negative-path unit tests: invalid signature, issuer, audience and
  nonce; expired JWT; JWT past the validity window; JWT issued in the
  future; and a far-future `iat` overflow guard. Reset the test clock and
  caller at the start of every test so thread reuse can't leak state.

Frontend (ssoDiscovery):
- Parse the loopback host via `new URL` instead of hand-splitting on
  `:`, and reject hosts carrying a path/query/fragment. Add tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remaining files for the review fixes:
- openid/verify.rs: panic-free time checks, destructured Claims, and the
  negative-path unit tests.
- openid/sso.rs: repair the botched `resolve()` call in the SSO tests.
- frontend ssoDiscovery.ts/.test.ts: URL-based loopback parsing + tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

aterga commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Addressed the open review comments and got the branch compiling again.

Build fixes — the branch did not compile at e440e0c:

  • openid.rs imported std::ffi::os_str::Display (a struct) instead of the std::fmt::Display trait, so impl Display for AudClaim failed with E0404.
  • The SSO unit test resolve_cross_checks_issuer still called resolve() with the old 2-arg signature, with the new &AudClaim mis-placed as an assert! format argument.

Review comments:

  • verify.rs:143 — destructured the verified Claims, explicitly dropping aud (only weakly checked via AudClaim::matches; the canonical client_id is stored instead) and the consumed nonce/exp/iat.
  • verify.rs:292 — the JWT time checks are now panic-free: validity is compared in whole seconds and iat + window uses checked_add, so a malicious far-future iat/exp can no longer overflow secs_to_nanos.
  • verify.rs:429 — added the negative-path tests (details in the inline reply).
  • ssoDiscovery.ts:59 — the loopback host is now parsed with new URL instead of hand-splitting on : (and a host carrying a path/query/fragment is rejected).

Locally verified: cargo fmt --check, cargo clippy -D warnings, the openid unit tests (23/23), and the ssoDiscovery vitest suite (15/15) all pass. The remaining canister-tests / e2e jobs are running in CI.


Generated by Claude Code

@aterga aterga enabled auto-merge (squash) June 19, 2026 19:39
@aterga aterga merged commit b8e7448 into main Jun 19, 2026
46 checks passed
@aterga aterga deleted the feat/openid-sso-cache-verify branch June 19, 2026 19:40
aterga pushed a commit that referenced this pull request Jun 23, 2026
…wlist (#4045)

Stacks on #4022. When deploying II for testing/staging we want to let
*any* domain use the SSO discovery feature without curating the
`sso_discoverable_domains` allowlist for each one. Today every SSO
domain must be on that allowlist (or the built-in `is_production`
defaults), which is friction for environments where we just want SSO
open.

This adds a backend deploy flag that opens the domain gate to
everything, while leaving the strict-`https` posture untouched.

## Changes

**Backend**
- New init/upgrade arg `sso_allow_any_domain : opt bool` on
`InternetIdentityInit`, persisted in `PersistentState` (and
`StorablePersistentState`) and reported back by the config query.
`None`/`Some(false)` leave the allowlist in force; `Some(true)` opens
the gate.
- `is_allowed_discovery_domain` returns true for any domain when the
flag is set, so `discover_sso` / `get_sso_discovery` and the JWT verify
path accept every domain instead of only allowlisted/default ones.
- The `https`-relaxation gate (`is_allowlisted_host`) is decoupled from
the flag and still consults the explicit `sso_discoverable_domains` list
only, so opening the domain gate never lets an arbitrary host serve
discovery over plain HTTP.

**Frontend**
- Regenerated candid bindings and updated the `InternetIdentityInit`
literals for the new field. No behavior change.

## Tests
- Unit test `openid::sso::allow_any_domain_opens_the_gate`.
- Integration test
`config::sso_discovery::sso_allow_any_domain_opens_the_gate` — passes
with all 3 sso_discovery tests against locally-built wasm + PocketIC
9.0.3.

<div align="left">Previous: #4022</div>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

4 participants