Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions src/frontend/src/lib/generated/internet_identity_idl.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const idlFactory = ({ IDL }) => {
});
const InternetIdentityInit = IDL.Record({
'doh_config' : IDL.Opt(IDL.Opt(DohConfig)),
'sso_allow_any_domain' : IDL.Opt(IDL.Bool),
'sso_credential_migration' : IDL.Opt(IDL.Vec(SsoCredentialMigrationEntry)),
'is_production' : IDL.Opt(IDL.Bool),
'backend_canister_id' : IDL.Opt(IDL.Principal),
Expand Down Expand Up @@ -1311,6 +1312,7 @@ export const init = ({ IDL }) => {
});
const InternetIdentityInit = IDL.Record({
'doh_config' : IDL.Opt(IDL.Opt(DohConfig)),
'sso_allow_any_domain' : IDL.Opt(IDL.Bool),
'sso_credential_migration' : IDL.Opt(IDL.Vec(SsoCredentialMigrationEntry)),
'is_production' : IDL.Opt(IDL.Bool),
'backend_canister_id' : IDL.Opt(IDL.Principal),
Expand Down
8 changes: 8 additions & 0 deletions src/frontend/src/lib/generated/internet_identity_types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,14 @@ export interface InternetIdentityInit {
* `docs/ongoing/email-recovery.md` §7.6. Same set/clear pattern.
*/
'doh_config' : [] | [[] | [DohConfig]],
/**
* Deploy flag opening the SSO discovery domain gate to any domain. When
* `true`, `sso_discoverable_domains` (and its defaults) no longer restrict
* which domains may be discovered as SSO providers. Does not relax the
* strict-`https` requirement: serving discovery over plain `http` still
* requires the host to be on the explicit `sso_discoverable_domains` list.
*/
'sso_allow_any_domain' : [] | [boolean],
/**
* One-shot backfill of the `sso_domain` / `sso_name` fields on stored
* OpenID credentials. When set, a batched timer-driven migration stamps
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/lib/utils/iiConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const DEFAULT_INIT: InternetIdentityInit = {
captcha_config: [],
openid_configs: [],
sso_discoverable_domains: [],
sso_allow_any_domain: [],
sso_credential_migration: [],
register_rate_limit: [],
related_origins: [
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/routes/vc-flow/index/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
related_origins: frontendCanisterConfig.related_origins,
openid_configs: [],
sso_discoverable_domains: [],
sso_allow_any_domain: [],
sso_credential_migration: [],
backend_origin: [frontendCanisterConfig.backend_origin],
captcha_config: [],
Expand Down
6 changes: 6 additions & 0 deletions src/internet_identity/internet_identity.did
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ type InternetIdentityInit = record {
// to `dfinity.org` (production) or `beta.dfinity.org` (everything else),
// keyed off `is_production`.
sso_discoverable_domains : opt vec text;
// Deploy flag opening the SSO discovery domain gate to any domain. When
// `true`, `sso_discoverable_domains` (and its defaults) no longer restrict
// which domains may be discovered as SSO providers. Does not relax the
// strict-`https` requirement: serving discovery over plain `http` still
// requires the host to be on the explicit `sso_discoverable_domains` list.
sso_allow_any_domain : opt bool;
// One-shot backfill of the `sso_domain` / `sso_name` fields on stored
// OpenID credentials. When set, a batched timer-driven migration stamps
// every stored credential whose (iss, aud) matches an entry and whose
Expand Down
6 changes: 6 additions & 0 deletions src/internet_identity/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,7 @@ fn config() -> InternetIdentityInit {
new_flow_origins: persistent_state.new_flow_origins.clone(),
openid_configs: persistent_state.openid_configs.clone(),
sso_discoverable_domains: persistent_state.sso_discoverable_domains.clone(),
sso_allow_any_domain: persistent_state.sso_allow_any_domain,
// One-shot upgrade arg driving the SSO credential backfill; not
// persisted as config, so there is nothing to report back here.
sso_credential_migration: None,
Expand Down Expand Up @@ -828,6 +829,11 @@ fn apply_install_arg(maybe_arg: Option<InternetIdentityInit>) {
persistent_state.sso_discoverable_domains = Some(sso_discoverable_domains);
})
}
if let Some(sso_allow_any_domain) = arg.sso_allow_any_domain {
state::persistent_state_mut(|persistent_state| {
persistent_state.sso_allow_any_domain = Some(sso_allow_any_domain);
})
}
if let Some(entries) = arg.sso_credential_migration {
// One-shot arg, not persisted: the entries only need to live
// until the batch migration kicked off in `initialize()` has
Expand Down
46 changes: 44 additions & 2 deletions src/internet_identity/src/openid/sso.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,20 +224,45 @@ pub fn allowed_discovery_domains() -> Vec<String> {
}
}

pub fn is_allowed_discovery_domain(domain: &str) -> bool {
/// Deploy flag: when set, the SSO discovery domain gate accepts *any* domain
/// (see `InternetIdentityInit::sso_allow_any_domain`). Deliberately does not
/// feed the `https`-relaxation gate ([`is_allowlisted_host`]), which always
/// consults the explicit allowlist so opening the domain gate never lets an
/// arbitrary host serve discovery over plain HTTP.
fn sso_allow_any_domain() -> bool {
#[cfg(not(test))]
{
state::persistent_state(|ps| ps.sso_allow_any_domain).unwrap_or(false)
}
#[cfg(test)]
{
tests::TEST_ALLOW_ANY.with_borrow(|b| *b)
}
}

/// True if `domain` is on the configured/default SSO allowlist
/// (case-insensitive). The explicit list only — independent of the
/// `sso_allow_any_domain` deploy flag.
fn is_explicitly_allowlisted(domain: &str) -> bool {
allowed_discovery_domains()
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(domain))
}

pub fn is_allowed_discovery_domain(domain: &str) -> bool {
sso_allow_any_domain() || is_explicitly_allowlisted(domain)
}
Comment thread
aterga marked this conversation as resolved.
Comment on lines +243 to +254

/// True if `host` (the `host:port` portion of a URL) matches an allowlist
/// entry. Used as the gate for relaxing the `https://` requirement: any domain
/// explicitly blessed by an II admin MAY publish its discovery endpoints over
/// plain HTTP, which is what makes e2e tests against `http://localhost:11107`
/// work without weakening prod's strict-HTTPS posture for unblessed hosts.
/// Consults the explicit allowlist only — the `sso_allow_any_domain` deploy
/// flag opens the domain gate but never relaxes the `https` requirement.
#[cfg(not(test))]
fn is_allowlisted_host(host: &str) -> bool {
is_allowed_discovery_domain(host)
is_explicitly_allowlisted(host)
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -506,13 +531,15 @@ mod tests {

thread_local! {
pub(super) static TEST_ALLOWED: RefCell<Vec<String>> = const { RefCell::new(vec![]) };
pub(super) static TEST_ALLOW_ANY: RefCell<bool> = const { RefCell::new(false) };
pub(super) static TEST_DISCOVERY: RefCell<HashMap<String, DiscoveredConfig>> = RefCell::new(HashMap::new());
pub(super) static TEST_JWKS: RefCell<HashMap<String, Vec<Jwk>>> = RefCell::new(HashMap::new());
}

fn reset() {
set_test_now(1_700_000_000);
TEST_ALLOWED.with_borrow_mut(|d| *d = vec!["example.org".to_string()]);
TEST_ALLOW_ANY.with_borrow_mut(|b| *b = false);
TEST_DISCOVERY.with_borrow_mut(|m| m.clear());
TEST_JWKS.with_borrow_mut(|m| m.clear());
DISCOVERY_CACHE.with(|c| *c.borrow_mut() = new_discovery_cache());
Expand Down Expand Up @@ -548,6 +575,21 @@ mod tests {
.is_err());
}

#[test]
fn allow_any_domain_opens_the_gate() {
reset();
// Off by default: a domain off the explicit allowlist is rejected.
assert!(!is_allowed_discovery_domain("not-allowed.com"));
// Flag on: every domain passes the discovery gate.
TEST_ALLOW_ANY.with_borrow_mut(|b| *b = true);
assert!(is_allowed_discovery_domain("not-allowed.com"));
assert!(is_allowed_discovery_domain("example.org"));
// The explicit allowlist is unchanged — the `https`-relaxation gate
// still consults it, so the flag does not bless arbitrary http hosts.
assert!(is_explicitly_allowlisted("example.org"));
assert!(!is_explicitly_allowlisted("not-allowed.com"));
}

#[test]
fn prefetch_then_peek_resolves() {
reset();
Expand Down
6 changes: 6 additions & 0 deletions src/internet_identity/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ pub struct PersistentState {
// falls back to the built-in `is_production`-keyed defaults; `Some(vec)`
// replaces them entirely.
pub sso_discoverable_domains: Option<Vec<String>>,
// Deploy flag opening the SSO discovery domain gate to any domain. `None`/
// `Some(false)` keep `sso_discoverable_domains` (and its defaults) in force;
// `Some(true)` accepts every domain. Does not relax the strict-`https`
// requirement — see `openid::sso::is_allowed_discovery_domain`.
pub sso_allow_any_domain: Option<bool>,
// SSO provider configs managed via add_discoverable_oidc_config update call.
pub oidc_configs: Option<Vec<DiscoverableOidcConfig>>,
// Configuration for Web Analytics tool
Expand Down Expand Up @@ -168,6 +173,7 @@ impl Default for PersistentState {
new_flow_origins: None,
openid_configs: None,
sso_discoverable_domains: None,
sso_allow_any_domain: None,
oidc_configs: None,
analytics_config: None,
event_stats_24h_start: None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pub struct StorablePersistentState {
openid_configs: Option<Vec<OpenIdConfig>>,
oidc_configs: Option<Vec<DiscoverableOidcConfig>>,
sso_discoverable_domains: Option<Vec<String>>,
sso_allow_any_domain: Option<bool>,
analytics_config: Option<AnalyticsConfig>,
enable_dapps_explorer: Option<bool>,
is_production: Option<bool>,
Expand Down Expand Up @@ -89,6 +90,7 @@ impl From<PersistentState> for StorablePersistentState {
openid_configs: s.openid_configs,
oidc_configs: s.oidc_configs,
sso_discoverable_domains: s.sso_discoverable_domains,
sso_allow_any_domain: s.sso_allow_any_domain,
analytics_config: s.analytics_config,
enable_dapps_explorer: s.enable_dapps_explorer,
is_production: s.is_production,
Expand Down Expand Up @@ -116,6 +118,7 @@ impl From<StorablePersistentState> for PersistentState {
openid_configs: s.openid_configs,
oidc_configs: s.oidc_configs,
sso_discoverable_domains: s.sso_discoverable_domains,
sso_allow_any_domain: s.sso_allow_any_domain,
analytics_config: s.analytics_config,
event_stats_24h_start: s.event_stats_24h_start,
enable_dapps_explorer: s.enable_dapps_explorer,
Expand Down Expand Up @@ -173,6 +176,7 @@ mod tests {
openid_configs: None,
oidc_configs: None,
sso_discoverable_domains: None,
sso_allow_any_domain: None,
analytics_config: None,
enable_dapps_explorer: None,
is_production: None,
Expand Down Expand Up @@ -204,6 +208,7 @@ mod tests {
openid_configs: None,
oidc_configs: None,
sso_discoverable_domains: None,
sso_allow_any_domain: None,
event_stats_24h_start: None,
analytics_config: None,
enable_dapps_explorer: None,
Expand Down
30 changes: 30 additions & 0 deletions src/internet_identity/tests/integration/config/sso_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,33 @@ fn sso_discovery_honours_explicit_allowlist() {
SsoDiscoveryState::NotAllowed
);
}

/// The `sso_allow_any_domain` deploy flag opens the gate to every domain: a
/// domain off the allowlist reads `Pending` instead of `NotAllowed`.
#[test]
fn sso_allow_any_domain_opens_the_gate() {
let env = env();
let arg = InternetIdentityInit {
sso_discoverable_domains: Some(vec!["example.com".to_string()]),
sso_allow_any_domain: Some(true),
..Default::default()
};
let canister_id = install_ii_canister_with_arg_and_cycles(
&env,
II_WASM.clone(),
Some(arg),
10_000_000_000_000,
);

// The explicit entry is still allowed.
assert_eq!(
api::get_sso_discovery(&env, canister_id, "example.com").unwrap(),
SsoDiscoveryState::Pending
);
// A domain off the allowlist is now accepted rather than `NotAllowed`.
assert_eq!(
api::get_sso_discovery(&env, canister_id, "evil.example.com").unwrap(),
SsoDiscoveryState::Pending
);
api::discover_sso(&env, canister_id, "evil.example.com").unwrap();
}
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,14 @@ pub struct InternetIdentityInit {
/// `dfinity.org` (production) or `beta.dfinity.org` (everything else)
/// keyed off `is_production`.
pub sso_discoverable_domains: Option<Vec<String>>,
/// Deploy flag that opens the SSO discovery domain gate to *any* domain.
/// When `Some(true)`, `sso_discoverable_domains` (and its built-in
/// `is_production` defaults) no longer restrict which domains may be
/// discovered as SSO providers — every domain is accepted. `None` /
/// `Some(false)` leave the allowlist in force. The strict-`https` posture
/// is unaffected: a domain must still be on the explicit
/// `sso_discoverable_domains` list to serve discovery over plain `http`.
pub sso_allow_any_domain: Option<bool>,
/// One-shot backfill of the `sso_domain` / `sso_name` fields on stored
/// `OpenIdCredential`s (see `docs/ongoing/openid-sso-prod-readiness.md`
/// §8.6). When `Some`, a batched timer-driven migration stamps every
Expand Down
Loading