Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 26 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 @@ -412,7 +420,7 @@ pub fn openid_prepare_delegation(
"openid_prepare_delegation",
(jwt, salt, session_key),
)
.map(|(x,)| x)
.map(|(x,)| settled(x))
}

pub fn openid_get_delegation(
Expand All @@ -431,7 +439,7 @@ pub fn openid_get_delegation(
"openid_get_delegation",
(jwt, salt, session_key, expiration),
)
.map(|(x,)| x)
.map(|(x,)| settled(x))
}

pub fn openid_credential_add(
Expand All @@ -450,7 +458,7 @@ pub fn openid_credential_add(
"openid_credential_add",
(identity_number, jwt, salt),
)
.map(|(x,)| x)
.map(|(x,)| settled(x))
}

pub fn openid_credential_remove(
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))
}

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