- {#if isSso}
+ {#if isSso || logo === undefined}
+
{:else}
{@html logo}
diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(access-and-recovery)/access/utils.test.ts b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(access-and-recovery)/access/utils.test.ts
index a4e1f9b612..62204d0441 100644
--- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(access-and-recovery)/access/utils.test.ts
+++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/(access-and-recovery)/access/utils.test.ts
@@ -43,6 +43,7 @@ const makeOpenId = (
last_usage_timestamp: lastUse !== undefined ? [lastUse] : [],
aud: "",
metadata: [],
+ sso_configuration: [],
});
describe("compareAccessMethods", () => {
diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did
index 321f8f5a42..2b6a441183 100644
--- a/src/internet_identity/internet_identity.did
+++ b/src/internet_identity/internet_identity.did
@@ -417,6 +417,26 @@ type OpenIdCredential = record {
aud : Aud;
last_usage_timestamp : opt Timestamp;
metadata : MetadataMapV2;
+ // Two-hop SSO provenance for credentials linked via
+ // `add_discoverable_oidc_config`. Looked up on demand by `(iss, aud)`
+ // against current canister state. `null` for direct-provider
+ // credentials (Google / Apple / Microsoft) and for SSO credentials
+ // whose provider is no longer registered.
+ sso_configuration : opt SsoConfiguration;
+};
+
+// Per-credential SSO provenance. Grouped as a single optional record so
+// `sso_configuration : null` is a single "this credential isn't SSO"
+// check, rather than two independent optional fields.
+type SsoConfiguration = record {
+ // The `discovery_domain` the user entered (always present when the
+ // outer `sso_configuration` is non-null).
+ domain : text;
+ // Human-readable name from the domain's
+ // `/.well-known/ii-openid-configuration`. `null` when the domain
+ // doesn't publish one — callers should fall back to `domain` for
+ // the label.
+ name : opt text;
};
type OpenIdCredentialAddError = variant {
diff --git a/src/internet_identity/src/openid.rs b/src/internet_identity/src/openid.rs
index 598bb1e9f9..9421e69d83 100644
--- a/src/internet_identity/src/openid.rs
+++ b/src/internet_identity/src/openid.rs
@@ -22,6 +22,11 @@ use std::{cell::RefCell, collections::HashMap};
mod generic;
+// Re-export the SSO-configuration lookup so the storage-layer
+// `OpenIdCredential → OpenIdCredentialData` conversion can call it
+// without making the whole `generic` module public.
+pub use generic::sso_configuration_for;
+
pub const OPENID_SESSION_DURATION_NS: u64 = 30 * MINUTE_NS;
pub type OpenIdCredentialKey = (Iss, Sub, Aud);
diff --git a/src/internet_identity/src/openid/generic.rs b/src/internet_identity/src/openid/generic.rs
index e8663355df..912df5cfd0 100644
--- a/src/internet_identity/src/openid/generic.rs
+++ b/src/internet_identity/src/openid/generic.rs
@@ -21,6 +21,7 @@ use identity_jose::jws::{
};
use internet_identity_interface::internet_identity::types::{
DiscoverableOidcConfig, MetadataEntryV2, OpenIdConfig, OpenIdEmailVerificationScheme,
+ SsoConfiguration,
};
use rsa::{Pkcs1v15Sign, RsaPublicKey};
use serde::Serialize;
@@ -259,6 +260,11 @@ pub fn is_allowed_discovery_domain(domain: &str) -> bool {
/// SSO provider that resolves its configuration via two-hop discovery.
/// Used with the `DiscoverableOidcConfig` type.
+///
+/// The `discovery_domain` and human-readable `name` aren't held on the
+/// provider itself — they live on the matching `DiscoveryState` in
+/// `DISCOVERY_TASKS` (written by the periodic discovery timer, read by
+/// `sso_fields_for` when shaping API responses).
pub struct DiscoverableProvider {
discovered_client_id: Rc>>,
discovered_issuer: Rc>>,
@@ -338,7 +344,13 @@ impl OpenIdProvider for DiscoverableProvider {
OpenIDJWTVerificationError::GenericError("Invalid signature".to_string())
})?;
- // Return credential with metadata
+ // Return credential with metadata. SSO-specific labels
+ // (`sso_domain`, `sso_name`) are NOT stored here; they're looked
+ // up on-demand from current `DISCOVERY_TASKS` state when a
+ // credential is returned via the API (see
+ // `openid::generic::sso_fields_for`). Computing them at query
+ // time means the FE always sees the current SSO `name` if the
+ // domain's `/.well-known/ii-openid-configuration` is updated.
let mut metadata: HashMap = HashMap::new();
if let Some(email) = claims.email {
metadata.insert("email".into(), MetadataEntryV2::String(email));
@@ -371,6 +383,12 @@ pub struct DiscoveryState {
pub client_id_ref: Rc>>,
pub openid_configuration_ref: Rc>>,
pub issuer_ref: Rc>>,
+ /// Human-readable SSO name served at
+ /// `{discovery_domain}/.well-known/ii-openid-configuration`.
+ /// Populated by hop-1 once per refresh; `None` if absent in the
+ /// hop-1 body. Read by `sso_fields_for` when shaping credential
+ /// responses.
+ pub name_ref: Rc>>,
/// Only read by the periodic discovery timer (non-test builds).
#[allow(dead_code)]
pub certs_ref: Rc>>,
@@ -394,13 +412,15 @@ impl DiscoverableProvider {
let discovered_issuer: Rc>> = Rc::new(RefCell::new(None));
let discovered_client_id: Rc>> = Rc::new(RefCell::new(None));
let openid_configuration: Rc>> = Rc::new(RefCell::new(None));
+ let discovered_name: Rc>> = Rc::new(RefCell::new(None));
DISCOVERY_TASKS.with_borrow_mut(|tasks| {
tasks.push(DiscoveryState {
- discovery_domain: config.discovery_domain.clone(),
+ discovery_domain: config.discovery_domain,
client_id_ref: Rc::clone(&discovered_client_id),
openid_configuration_ref: Rc::clone(&openid_configuration),
issuer_ref: Rc::clone(&discovered_issuer),
+ name_ref: discovered_name,
certs_ref: Rc::clone(&certs),
last_jwks_uri: Rc::new(RefCell::new(None)),
});
@@ -490,6 +510,11 @@ async fn run_discovery_tasks() {
task.openid_configuration_ref
.replace(Some(ii_config.openid_configuration));
task.issuer_ref.replace(Some(doc.issuer));
+ // Pass `name` through unchanged — `None` means the domain didn't
+ // publish one in `/.well-known/ii-openid-configuration`. The FE
+ // decides how to render: "DFINITY" if `sso_name` is present,
+ // "dfinity.org" if only `sso_domain` is available.
+ task.name_ref.replace(ii_config.name);
// Start or restart cert fetching when jwks_uri changes
let jwks_changed = {
@@ -646,6 +671,12 @@ fn transform_discovery(response: HttpResponse) -> HttpResponse {
struct IIOpenIdConfiguration {
client_id: String,
openid_configuration: String,
+ /// Human-readable name for the SSO (e.g. `"DFINITY"`). Optional; we
+ /// pass it through to the API as-is, letting the FE pick between
+ /// this and the bare domain for rendering. `#[serde(default)]` so
+ /// older deployments that don't publish the field continue to parse.
+ #[serde(default)]
+ name: Option,
}
/// OIDC discovery document — only the fields needed by the backend.
@@ -677,6 +708,43 @@ pub fn discovered_state_for(
})
}
+/// Looks up the SSO provenance for an OpenID credential by its
+/// `(iss, aud)` pair. Computed on-demand from current `DISCOVERY_TASKS`
+/// state — that way `get_anchor_info` always reflects live SSO metadata
+/// (the domain's current `name` field, or absence thereof) instead of
+/// whatever got stamped at verification time.
+///
+/// Returns `None` for:
+/// - Direct-provider credentials (Google / Apple / Microsoft — the
+/// matching provider isn't a `DiscoverableProvider`, so no task
+/// matches on `(iss, aud)`).
+/// - SSO credentials whose domain has since been removed from the
+/// canister's allowlist (no task registered at all).
+/// - SSO credentials whose hop-1 / hop-2 discovery hasn't completed
+/// yet (task exists but `client_id_ref` / `issuer_ref` are `None`).
+///
+/// Returns `Some(SsoConfiguration { domain, name })` otherwise, where
+/// `name` may still be `None` if the domain doesn't publish one.
+/// Callers that want a human-readable label fall back
+/// `name → domain → direct-provider `findConfig` result` on the
+/// frontend — the backend intentionally does not collapse the two so
+/// the FE can tell "no name published" apart from "has a name" for
+/// future divergent rendering.
+pub fn sso_configuration_for(iss: &str, aud: &str) -> Option {
+ DISCOVERY_TASKS.with_borrow(|tasks| {
+ tasks
+ .iter()
+ .find(|t| {
+ t.issuer_ref.borrow().as_deref() == Some(iss)
+ && t.client_id_ref.borrow().as_deref() == Some(aud)
+ })
+ .map(|t| SsoConfiguration {
+ domain: t.discovery_domain.clone(),
+ name: t.name_ref.borrow().clone(),
+ })
+ })
+}
+
fn compute_next_certs_fetch_delay(
result: &Result,
current_delay: Option,
diff --git a/src/internet_identity/src/storage/anchor.rs b/src/internet_identity/src/storage/anchor.rs
index 3e4432b2bf..8c43d35130 100644
--- a/src/internet_identity/src/storage/anchor.rs
+++ b/src/internet_identity/src/storage/anchor.rs
@@ -127,18 +127,31 @@ impl From for DeviceDataWithoutAlias {
impl From for OpenIdCredentialData {
fn from(openid_credential: OpenIdCredential) -> Self {
+ // Populate SSO provenance on-demand from current `DISCOVERY_TASKS`
+ // state so the FE always sees the live value — if a domain
+ // updates the `name` in its `/.well-known/ii-openid-
+ // configuration`, the next `get_anchor_info` reflects it
+ // without any per-credential storage migration.
+ let sso_configuration = crate::openid::sso_configuration_for(
+ &openid_credential.iss,
+ &openid_credential.aud,
+ );
Self {
iss: openid_credential.iss,
sub: openid_credential.sub,
aud: openid_credential.aud,
last_usage_timestamp: openid_credential.last_usage_timestamp,
metadata: openid_credential.metadata,
+ sso_configuration,
}
}
}
impl From for OpenIdCredential {
fn from(openid_credential: OpenIdCredentialData) -> Self {
+ // The inverse conversion drops the derived SSO fields — they're
+ // not stored, they're recomputed each time we go `OpenIdCredential
+ // → OpenIdCredentialData`.
Self {
iss: openid_credential.iss,
sub: openid_credential.sub,
diff --git a/src/internet_identity_interface/src/internet_identity/types/openid.rs b/src/internet_identity_interface/src/internet_identity/types/openid.rs
index 1d5800695b..9e976814a6 100644
--- a/src/internet_identity_interface/src/internet_identity/types/openid.rs
+++ b/src/internet_identity_interface/src/internet_identity/types/openid.rs
@@ -15,6 +15,29 @@ pub struct OpenIdCredentialData {
// authn method stats if this value is already set to any value.
pub last_usage_timestamp: Option,
pub metadata: HashMap,
+ /// Two-hop SSO provenance for credentials linked via
+ /// `add_discoverable_oidc_config`. Looked up on demand by `(iss, aud)`
+ /// from current canister state. `None` for direct-provider credentials
+ /// (Google / Apple / Microsoft) and for SSO credentials whose provider
+ /// is no longer registered on the canister.
+ pub sso_configuration: Option,
+}
+
+/// Per-credential SSO provenance returned alongside an
+/// `OpenIdCredentialData`. Grouped into one optional struct (rather than
+/// two independent optional fields) so "is this credential SSO?" is a
+/// single presence check.
+#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)]
+pub struct SsoConfiguration {
+ /// The `discovery_domain` the user entered (always present for SSO
+ /// credentials).
+ pub domain: String,
+ /// Human-readable name served alongside `client_id` at
+ /// `{domain}/.well-known/ii-openid-configuration`. `None` when the
+ /// domain doesn't publish one — callers that want a label should
+ /// fall back to `domain` on the frontend side. Kept separate from
+ /// `domain` so callers can render the two cases differently.
+ pub name: Option,
}
#[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)]