Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cd51283
feat(be): data-only OpenID providers + on-demand SSO discovery/JWKS c…
sea-snake Jun 15, 2026
c153b6c
refactor(be): single read/prefetch split, drop dead OIDC registry, fi…
sea-snake Jun 15, 2026
8654f55
feat(fe): canister-side SSO discovery + delegation poll for on-demand…
sea-snake Jun 15, 2026
add4399
test(fe): update SSO e2e fixture comment for canister-side discovery
sea-snake Jun 15, 2026
cd95496
fix(be): verify OpenID credential before the storage borrow in regist…
sea-snake Jun 15, 2026
0a45074
test(be): discover_sso allowlist integration tests + api helpers
sea-snake Jun 15, 2026
b5e4443
fix(fe): thread SSO discovery domain through the 1-click authorize flow
sea-snake Jun 15, 2026
b4ed2e4
refactor(be,fe): match the DoH update/query poll shape for SSO discovery
sea-snake Jun 16, 2026
c170805
refactor(be,fe): SSO discovery query returns a state variant, not opt…
sea-snake Jun 16, 2026
e3bb5a5
chore(fe): revert build-regenerated locale .po files
sea-snake Jun 16, 2026
4d617aa
refactor(be,fe): make OpenID Pending a result arm, not an error variant
sea-snake Jun 16, 2026
b136671
fix(be,fe): retry OpenID registration when the JWKS cache is cold
sea-snake Jun 16, 2026
18ba5e4
Merge branch 'main' into feat/openid-sso-cache-verify
sea-snake Jun 19, 2026
aff1cb0
fix(be): canonicalize SSO discovery_domain at the canister boundary
sea-snake Jun 19, 2026
8887bc9
test(be): cover the SSO discovery flow through the canister endpoints
sea-snake Jun 19, 2026
5308254
feat(be): bound SSO discovery/JWKS caches against unbounded keys
sea-snake Jun 19, 2026
6701a3a
refactor(be): unify the SSO caches under a single max_entries budget
sea-snake Jun 19, 2026
a51711e
Merge remote-tracking branch 'upstream/main' into feat/openid-sso-cac…
sea-snake Jun 19, 2026
321704a
fix(be): canonicalize discovery_domain on the SSO discovery endpoints
sea-snake Jun 19, 2026
9ae5d49
fix(fe): respect abort signal during SSO discovery poll delay
sea-snake Jun 19, 2026
677d6e3
fix(fe): resolve TS2367 in SSO discovery abort check
sea-snake Jun 19, 2026
e440e0c
Add aud checks to the SSO flow
aterga Jun 19, 2026
e295a0e
refactor(be,fe): address review on OpenID verify/SSO discovery
aterga Jun 19, 2026
ae6c8b3
refactor(be,fe): address review on OpenID verify/SSO discovery (cont.)
aterga Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 91 additions & 18 deletions src/canister_tests/src/api/internet_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,32 +365,40 @@ pub fn config(
call_candid(env, canister_id, RawEffectivePrincipal::None, "config", ()).map(|(x,)| x)
}

pub fn discovered_oidc_configs(
pub fn discover_sso(
env: &PocketIc,
canister_id: CanisterId,
) -> Result<Vec<types::OidcConfig>, RejectResponse> {
domain: &str,
) -> Result<(), RejectResponse> {
call_candid(
env,
canister_id,
RawEffectivePrincipal::None,
"discovered_oidc_configs",
(),
"discover_sso",
(domain,),
)
.map(|(x,)| x)
}

pub fn add_discoverable_oidc_config(
pub fn get_sso_discovery(
env: &PocketIc,
canister_id: CanisterId,
config: types::DiscoverableOidcConfig,
) -> Result<(), RejectResponse> {
call_candid(
env,
canister_id,
RawEffectivePrincipal::None,
"add_discoverable_oidc_config",
(config,),
)
domain: &str,
) -> Result<types::SsoDiscoveryState, RejectResponse> {
query_candid(env, canister_id, "get_sso_discovery", (domain,)).map(|(x,)| x)
}

/// Collapse an [`OpenIdResult`](types::OpenIdResult) to a plain `Result` for
/// the OpenID JWT test helpers. They only ever drive configured providers
/// (Google / Microsoft / Apple), whose JWKs are always warm, so `Pending` —
/// the SSO-discovery retry signal — cannot occur; treat it as a test failure.
pub(crate) fn settled<T, E>(result: types::OpenIdResult<T, E>) -> Result<T, E> {
match result {
types::OpenIdResult::Ok(value) => Ok(value),
types::OpenIdResult::Err(error) => Err(error),
types::OpenIdResult::Pending => {
panic!("OpenID JWT call returned Pending for a configured provider")
}
}
}

pub fn openid_prepare_delegation(
Expand All @@ -403,14 +411,33 @@ pub fn openid_prepare_delegation(
) -> Result<
Result<types::OpenIdPrepareDelegationResponse, types::OpenIdDelegationError>,
RejectResponse,
> {
openid_prepare_delegation_with_discovery(env, canister_id, sender, jwt, salt, session_key, None)
.map(settled)
}

/// Like [`openid_prepare_delegation`] but takes the SSO discovery domain and
/// returns the raw [`OpenIdResult`](types::OpenIdResult) so callers exercising
/// the SSO path can observe (and poll on) the `Pending` cache-warming arm.
pub fn openid_prepare_delegation_with_discovery(
env: &PocketIc,
canister_id: CanisterId,
sender: Principal,
jwt: &str,
salt: &[u8; 32],
session_key: &types::SessionKey,
discovery_domain: Option<&str>,
) -> Result<
types::OpenIdResult<types::OpenIdPrepareDelegationResponse, types::OpenIdDelegationError>,
RejectResponse,
> {
call_candid_as(
env,
canister_id,
RawEffectivePrincipal::None,
sender,
"openid_prepare_delegation",
(jwt, salt, session_key),
(jwt, salt, session_key, discovery_domain),
)
.map(|(x,)| x)
}
Expand All @@ -424,12 +451,42 @@ pub fn openid_get_delegation(
session_key: &types::SessionKey,
expiration: &types::Timestamp,
) -> Result<Result<types::SignedDelegation, types::OpenIdDelegationError>, RejectResponse> {
openid_get_delegation_with_discovery(
env,
canister_id,
sender,
jwt,
salt,
session_key,
expiration,
None,
)
.map(settled)
}

/// Like [`openid_get_delegation`] but takes the SSO discovery domain and returns
/// the raw [`OpenIdResult`](types::OpenIdResult) so SSO-path callers can observe
/// the `Pending` arm.
#[allow(clippy::too_many_arguments)]
pub fn openid_get_delegation_with_discovery(
env: &PocketIc,
canister_id: CanisterId,
sender: Principal,
jwt: &str,
salt: &[u8; 32],
session_key: &types::SessionKey,
expiration: &types::Timestamp,
discovery_domain: Option<&str>,
) -> Result<
types::OpenIdResult<types::SignedDelegation, types::OpenIdDelegationError>,
RejectResponse,
> {
query_candid_as(
env,
canister_id,
sender,
"openid_get_delegation",
(jwt, salt, session_key, expiration),
(jwt, salt, session_key, expiration, discovery_domain),
)
.map(|(x,)| x)
}
Expand All @@ -442,13 +499,29 @@ pub fn openid_credential_add(
jwt: &str,
salt: &[u8; 32],
) -> Result<Result<(), types::OpenIdCredentialAddError>, RejectResponse> {
openid_credential_add_with_discovery(env, canister_id, sender, identity_number, jwt, salt, None)
.map(settled)
}

/// Like [`openid_credential_add`] but takes the SSO discovery domain and returns
/// the raw [`OpenIdResult`](types::OpenIdResult) so SSO-path callers can observe
/// the `Pending` arm.
pub fn openid_credential_add_with_discovery(
env: &PocketIc,
canister_id: CanisterId,
sender: Principal,
identity_number: IdentityNumber,
jwt: &str,
salt: &[u8; 32],
discovery_domain: Option<&str>,
) -> Result<types::OpenIdResult<(), types::OpenIdCredentialAddError>, RejectResponse> {
call_candid_as(
env,
canister_id,
RawEffectivePrincipal::None,
sender,
"openid_credential_add",
(identity_number, jwt, salt),
(identity_number, jwt, salt, discovery_domain),
)
.map(|(x,)| x)
}
Expand Down
2 changes: 1 addition & 1 deletion src/canister_tests/src/api/internet_identity/api_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ pub fn openid_identity_registration_finish(
"openid_identity_registration_finish",
(openid_arg.clone(),),
)
.map(|(x,)| x)
.map(|(x,)| super::settled(x))
Comment thread
aterga marked this conversation as resolved.
}

pub fn identity_authn_info(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@
{/each}
<!--
SSO entry is always rendered alongside the named providers. The SSO
screen calls `add_discoverable_oidc_config` on submit; domains not on
the backend canary allowlist are rejected there.
screen resolves the domain via the canister; domains not on the backend
canary allowlist are rejected there.
-->
<button
class="btn btn-secondary h-16 w-full flex-col gap-1.5 text-xs whitespace-normal"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,13 @@
</Tooltip>
{/each}
<!--
SSO entry is always rendered. Registration is enforced on the
backend by the `sso_discoverable_domains` allowlist (init arg,
falling back to the `is_production`-keyed defaults in
`allowed_discovery_domains`) checked inside
`add_discoverable_oidc_config`. Unregistered domains surface as
an error inside the SignInWithSso screen rather than being gated
here — we keep this option visible so users know the mechanism
exists.
SSO entry is always rendered. The allowed domains are enforced on the
backend by the `sso_discoverable_domains` allowlist (init arg, falling
back to the `is_production`-keyed defaults in
`allowed_discovery_domains`) checked inside `discover_sso`. Disallowed
domains surface as an error inside the SignInWithSso screen rather than
being gated here — we keep this option visible so users know the
mechanism exists.
-->
<button
class="btn btn-secondary h-16 w-full flex-col gap-1.5 text-xs whitespace-normal"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import Input from "$lib/components/ui/Input.svelte";
import ProgressRing from "$lib/components/ui/ProgressRing.svelte";
import SsoIcon from "$lib/components/icons/SsoIcon.svelte";
import { anonymousActor } from "$lib/globals";
import {
validateDomain,
discoverSsoConfig,
Expand Down Expand Up @@ -96,16 +95,10 @@
domainInput: string,
): string | undefined => {
if (e instanceof DomainNotConfiguredError) {
if (e.reason === "http-error" && e.httpStatus !== undefined) {
return $t`${domainInput} didn't publish /.well-known/ii-openid-configuration (HTTP ${String(e.httpStatus)}).`;
}
if (e.reason === "timeout") {
return $t`${domainInput} took too long to respond. Try again in a moment.`;
}
if (e.reason === "network") {
return $t`Couldn't load SSO settings from ${domainInput}. Ask your SSO admin to check that /.well-known/ii-openid-configuration is reachable.`;
}
return $t`${domainInput}'s /.well-known/ii-openid-configuration is malformed.`;
return $t`Couldn't load SSO settings from ${domainInput}. Ask your SSO admin to check that /.well-known/ii-openid-configuration is reachable.`;
}
if (e instanceof OAuthProviderError) {
// `unsupported_response_type` = the SSO app is code-only; II needs
Expand Down Expand Up @@ -160,9 +153,9 @@
};

/**
* Debounced lookup: runs the backend registration + two-hop discovery
* shortly after the user stops typing, and stashes the result so the
* Continue button can hand off to `continueWithSso` synchronously.
* Debounced lookup: resolves the SSO config via the canister shortly after
* the user stops typing, and stashes the result so the Continue button can
* hand off to `continueWithSso` synchronously.
*
* Drives the button's enabled state: the button lights up only once
* `preparedResult` is populated for the current `domain`.
Expand Down Expand Up @@ -190,26 +183,22 @@
debounceTimer = setTimeout(async () => {
debounceTimer = undefined;
isLookingUp = true;
// The input may change again while these awaits are in flight; we
// The input may change again while this await is in flight; we
// only apply / error-out when our `trimmed` is still the current
// domain, so a stale response can't clobber a fresher one. The
// matching `lookupController` is also aborted by `invalidatePrepared`,
// which causes `discoverSsoConfig` to reject with `AbortError` —
// explicitly ignored below since cancellation isn't a user error.
// so a superseded lookup stops polling — ignored below since
// cancellation isn't a user error.
const matchesCurrent = () => trimmed === domain.trim().toLowerCase();
try {
await anonymousActor.add_discoverable_oidc_config({
discovery_domain: trimmed,
});
const result = await discoverSsoConfig(trimmed, controller.signal);
if (matchesCurrent()) {
preparedResult = result;
}
} catch (e) {
// Cancelled by a fresher keystroke — silently drop. Distinguishing
// on `controller.signal.aborted` (not just the error's name) means
// we don't accidentally swallow a hop-2 timeout, which surfaces as
// an `AbortError` too but isn't user-initiated.
// Cancelled by a fresher keystroke — silently drop, keyed on
// `controller.signal.aborted` rather than the error so a genuine
// discovery timeout still surfaces.
if (controller.signal.aborted) return;
if (matchesCurrent()) {
setErrorFrom(e, trimmed);
Expand All @@ -233,10 +222,8 @@
// opened synchronously inside `continueWithSso → requestJWT →
// requestWithPopup → redirectInPopup → window.open`. Any await
// between the click event and `window.open` causes Safari to
// block the popup. All the network work
// (add_discoverable_oidc_config + two-hop discovery) is already
// done — stashed in `preparedResult` by the debounced input
// handler.
// block the popup. The discovery is already resolved — stashed in
// `preparedResult` by the debounced input handler.
await continueWithSso(preparedResult);
} catch (e) {
setErrorFrom(e, domain.trim().toLowerCase());
Expand Down
20 changes: 15 additions & 5 deletions src/frontend/src/lib/flows/addAccessMethodFlow.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { get } from "svelte/store";
import { decodeJWT, requestJWT, selectAuthScopes } from "$lib/utils/openID";
import { authenticatedStore } from "$lib/stores/authentication.store";
import { throwCanisterError } from "$lib/utils/utils";
import { retryWhilePending } from "$lib/utils/openidPoll";
import type {
AuthnMethodData,
OpenIdCredential,
Expand Down Expand Up @@ -31,6 +32,7 @@ export class AddAccessMethodFlow {

linkOpenIdAccount = async (
config: OpenIdConfig,
discoveryDomain?: string,
): Promise<OpenIdCredential> => {
const { actor, identityNumber, salt, nonce } = get(authenticatedStore);

Expand All @@ -56,9 +58,17 @@ export class AddAccessMethodFlow {
// the same `(iss, aud)` on the identity, so the canister's
// `OpenIdCredentialAlreadyRegistered` reply here is only hit when
// the credential is linked to another identity. Treat it as-is.
await actor
.openid_credential_add(identityNumber, jwt, salt)
.then(throwCanisterError);
//
// For an SSO domain the add drives the discovery/JWKS fetch and reports
// `Pending` until the cache warms; retry until ready.
await retryWhilePending(() =>
actor.openid_credential_add(
identityNumber,
jwt,
salt,
discoveryDomain !== undefined ? [discoveryDomain] : [],
),
).then(throwCanisterError);

const metadata: MetadataMapV2 = [];
if (name !== undefined) {
Expand Down Expand Up @@ -138,7 +148,7 @@ export class AddAccessMethodFlow {
* devices — no per-device FE bookkeeping needed.
*/
linkSsoAccount = (result: SsoDiscoveryResult): Promise<OpenIdCredential> => {
const { clientId, discovery } = result;
const { clientId, discovery, domain } = result;
const syntheticConfig: OpenIdConfig = {
auth_uri: discovery.authorization_endpoint,
jwks_uri: "",
Expand All @@ -151,6 +161,6 @@ export class AddAccessMethodFlow {
client_id: clientId,
seed_jwks: [],
};
return this.linkOpenIdAccount(syntheticConfig);
return this.linkOpenIdAccount(syntheticConfig, domain);
};
}
Loading
Loading