diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 1aa8f7402..9d810a7f3 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -45,6 +45,12 @@ fn map_import_on_conflict( mas_config::UpstreamOAuth2OnConflict::Add => { mas_data_model::UpstreamOAuthProviderOnConflict::Add } + mas_config::UpstreamOAuth2OnConflict::Replace => { + mas_data_model::UpstreamOAuthProviderOnConflict::Replace + } + mas_config::UpstreamOAuth2OnConflict::Set => { + mas_data_model::UpstreamOAuthProviderOnConflict::Set + } mas_config::UpstreamOAuth2OnConflict::Fail => { mas_data_model::UpstreamOAuthProviderOnConflict::Fail } diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index 53eae7a1b..8c7d6dda2 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -206,13 +206,20 @@ impl ImportAction { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum OnConflict { - /// Fails the sso login on conflict + /// Fails the upstream OAuth 2.0 login on conflict #[default] Fail, - /// Adds the oauth identity link, regardless of whether there is an existing - /// link or not + /// Adds the upstream OAuth 2.0 identity link, regardless of whether there + /// is an existing link or not Add, + + /// Replace any existing upstream OAuth 2.0 identity link + Replace, + + /// Adds the upstream OAuth 2.0 identity link *only* if there is no existing + /// link for this provider on the matching user + Set, } impl OnConflict { diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index be42cb5a5..671393910 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -415,11 +415,18 @@ impl ImportAction { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum OnConflict { - /// Fails the upstream OAuth 2.0 login + /// Fails the upstream OAuth 2.0 login on conflict #[default] Fail, - /// Adds the upstream account link, regardless of whether there is an - /// existing link or not + /// Adds the upstream OAuth 2.0 identity link, regardless of whether there + /// is an existing link or not Add, + + /// Replace any existing upstream OAuth 2.0 identity link + Replace, + + /// Adds the upstream OAuth 2.0 identity link *only* if there is no existing + /// link for this provider on the matching user + Set, } diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index c794eff3e..b0eef5530 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -25,8 +25,10 @@ use mas_matrix::HomeserverConnection; use mas_policy::Policy; use mas_router::UrlBuilder; use mas_storage::{ - BoxRepository, RepositoryAccess, - upstream_oauth2::{UpstreamOAuthLinkRepository, UpstreamOAuthSessionRepository}, + BoxRepository, Pagination, RepositoryAccess, + upstream_oauth2::{ + UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository, UpstreamOAuthSessionRepository, + }, user::{BrowserSessionRepository, UserEmailRepository, UserRepository}, }; use mas_templates::{ @@ -585,6 +587,92 @@ pub(crate) async fn get( .associate_to_user(&link, &existing_user) .await?; } + + // We matched anexisting user and the conflict resolution is to replace any + // link on the existing user with this one + UpstreamOAuthProviderOnConflict::Replace => { + // Find existing links for this provider and user + let filter = UpstreamOAuthLinkFilter::new() + .for_provider(&provider) + .for_user(&existing_user); + let mut cursor = Pagination::first(100); + let mut removed = 0; + loop { + let page = repo.upstream_oauth_link().list(filter, cursor).await?; + for edge in page.edges { + // Remove any existing links for this provider and user + repo.upstream_oauth_link().remove(&clock, edge.node).await?; + cursor = cursor.after(edge.cursor); + removed += 1; + } + + if !page.has_next_page { + break; + } + } + + if removed > 0 { + tracing::warn!( + user.id = %existing_user.id, + upstream_oauth_provider.id = %provider.id, + upstream_oauth_link.id = %link.id, + upstream_oauth_link.subject = link.subject, + "Upstream account mapped localpart {localpart:?} matched an existing user, replaced {removed} links" + ); + } else { + tracing::info!( + user.id = %existing_user.id, + upstream_oauth_provider.id = %provider.id, + upstream_oauth_link.id = %link.id, + upstream_oauth_link.subject = link.subject, + "Upstream account mapped localpart {localpart:?} matched an existing user, linking" + ); + } + + // Add link to the user + repo.upstream_oauth_link() + .associate_to_user(&link, &existing_user) + .await?; + } + + // We matched an existing user and the conflict resolution is link to the + // existing user *only if* there is no existing link on that user + UpstreamOAuthProviderOnConflict::Set => { + // Find existing links for this provider and user + let filter = UpstreamOAuthLinkFilter::new() + .for_provider(&provider) + .for_user(&existing_user); + + let count = repo.upstream_oauth_link().count(filter).await?; + if count > 0 { + tracing::warn!( + upstream_oauth_provider.id = %provider.id, + upstream_oauth_link.id = %link.id, + user.id = %existing_user.id, + "Upstream provider returned a localpart {localpart:?} which is already used by another user. That user already has a ({count}) link to this provider, which isn't allowed by the conflict resolution" + ); + + // TODO: translate + let ctx = ErrorContext::new() + .with_code("User exists") + .with_description(format!( + r"Upstream account provider returned {localpart:?} as username, + which is not linked to another existing upstream account. + Your homeserver does not allow replacing upstream account links automatically." + )) + .with_language(&locale); + + return Ok(( + cookie_jar, + Html(templates.render_error(&ctx)?).into_response(), + )); + } + + // Add link to the user + repo.upstream_oauth_link() + .associate_to_user(&link, &existing_user) + .await?; + } } // Now that we've resolved the conflict, log in that existing user diff --git a/docs/config.schema.json b/docs/config.schema.json index cda68f145..9c5f39313 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2572,14 +2572,24 @@ "description": "How to handle an existing localpart claim", "oneOf": [ { - "description": "Fails the sso login on conflict", + "description": "Fails the upstream OAuth 2.0 login on conflict", "type": "string", "const": "fail" }, { - "description": "Adds the oauth identity link, regardless of whether there is an existing\n link or not", + "description": "Adds the upstream OAuth 2.0 identity link, regardless of whether there\n is an existing link or not", "type": "string", "const": "add" + }, + { + "description": "Replace any existing upstream OAuth 2.0 identity link", + "type": "string", + "const": "replace" + }, + { + "description": "Adds the upstream OAuth 2.0 identity link *only* if there is no existing\n link for this provider on the matching user", + "type": "string", + "const": "set" } ] },