From 93aee26e115af71d7b601c045aae45985b0e1fd9 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Wed, 4 Mar 2026 09:00:26 +0000 Subject: [PATCH] Add option for trusted providers to associate with existing accounts. (fixes #252) Signed-off-by: Jason Volk --- src/api/client/session/sso.rs | 22 ++++++++++++++++------ src/core/config/mod.rs | 19 +++++++++++++++++++ tuwunel-example.toml | 17 +++++++++++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/api/client/session/sso.rs b/src/api/client/session/sso.rs index ab45f3d9..8a825682 100644 --- a/src/api/client/session/sso.rs +++ b/src/api/client/session/sso.rs @@ -636,14 +636,14 @@ async fn decide_user_id( ]; for choice in choices.into_iter().flatten() { - if let Some(user_id) = try_user_id(services, &choice, false).await { + if let Some(user_id) = try_user_id(services, provider, &choice, false).await { return Ok(user_id); } } let length = Some(15..23); let unique_id = truncate_deterministic(unique_id, length).to_lowercase(); - if let Some(user_id) = try_user_id(services, &unique_id, true).await { + if let Some(user_id) = try_user_id(services, provider, &unique_id, true).await { return Ok(user_id); } @@ -653,8 +653,9 @@ async fn decide_user_id( #[tracing::instrument(level = "debug", skip_all, fields(username))] async fn try_user_id( services: &Services, + provider: &Provider, username: &str, - may_exist: bool, + unique_id: bool, ) -> Option { let server_name = services.globals.server_name(); let user_id = parse_user_id(server_name, username) @@ -671,7 +672,15 @@ async fn try_user_id( } if services.users.exists(&user_id).await { - debug_warn!(?username, "Username exists."); + if provider.trusted { + info!( + ?username, + provider = ?provider.brand, + "Authorizing trusted provider access to existing account." + ); + + return Some(user_id); + } if services .users @@ -680,11 +689,12 @@ async fn try_user_id( .ok() .is_none_or(|origin| origin != "sso") { - debug_warn!(?username, "Username has non-sso origin."); + debug_warn!(?username, "Existing username has non-sso origin."); return None; } - if !may_exist { + if !unique_id { + debug_warn!(?username, "Username exists."); return None; } } diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 60bec4dc..5b3504df 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -2741,6 +2741,25 @@ pub struct IdentityProvider { #[serde(default)] pub userid_claims: BTreeSet, + /// Trusted providers can cause username conflicts (i.e. account hijacking) + /// but this is precisely how an existing matrix account can be associated + /// with a provider. When this option is set to true, the way we compute a + /// Matrix UserId from userinfo claims is inverted: we find the first + /// matching user and grant access to it. Whereas by default, when set to + /// false, we skip matching users and register the first available username; + /// falling-back to random characters to avoid conflicts. + /// + /// Only set this option to true for providers you self-host and control. + /// Never set this option to true for the public providers such as GitHub, + /// GitLab, etc. + /// + /// Note that associating an existing user with an untrusted provider is + /// still possible but only with the command '!admin query oauth associate'. + /// + /// default: false + #[serde(default)] + pub trusted: bool, + /// Optional extra path components after the issuer_url leading to the /// location of the `.well-known` directory used for discovery. If the path /// starts with a slash it will be treated as absolute, meaning overwriting diff --git a/tuwunel-example.toml b/tuwunel-example.toml index 9e271cfc..79031c2b 100644 --- a/tuwunel-example.toml +++ b/tuwunel-example.toml @@ -2343,6 +2343,23 @@ # #userid_claims = [] +# Trusted providers can cause username conflicts (i.e. account hijacking) +# but this is precisely how an existing matrix account can be associated +# with a provider. When this option is set to true, the way we compute a +# Matrix UserId from userinfo claims is inverted: we find the first +# matching user and grant access to it. Whereas by default, when set to +# false, we skip matching users and register the first available username; +# falling-back to random characters to avoid conflicts. +# +# Only set this option to true for providers you self-host and control. +# Never set this option to true for the public providers such as GitHub, +# GitLab, etc. +# +# Note that associating an existing user with an untrusted provider is +# still possible but only with the command '!admin query oauth associate'. +# +#trusted = false + # Optional extra path components after the issuer_url leading to the # location of the `.well-known` directory used for discovery. If the path # starts with a slash it will be treated as absolute, meaning overwriting