diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 0c1063607..a11bdfdd9 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -300,6 +300,7 @@ pub async fn config_sync( fetch_userinfo: provider.fetch_userinfo, userinfo_signed_response_alg: provider.userinfo_signed_response_alg, response_mode, + allow_existing_users: provider.allow_existing_users, additional_authorization_parameters: provider .additional_authorization_parameters .into_iter() diff --git a/crates/config/src/sections/upstream_oauth2.rs b/crates/config/src/sections/upstream_oauth2.rs index aa6a27254..9bc2b3bf9 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -110,6 +110,17 @@ impl ConfigurationSection for UpstreamOAuth2Config { } } } + + if provider.allow_existing_users + && !matches!( + provider.claims_imports.localpart.action, + ImportAction::Force | ImportAction::Require + ) + { + return annotate(figment::Error::custom( + "When `allow_existing_users` is true, localpart claim import must be either `force` or `require`", + )); + } } Ok(()) @@ -411,6 +422,7 @@ fn is_default_scope(scope: &str) -> bool { /// Configuration for one upstream OAuth 2 provider. #[skip_serializing_none] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[allow(clippy::struct_excessive_bools)] pub struct Provider { /// Whether this provider is enabled. /// @@ -571,6 +583,13 @@ pub struct Provider { #[serde(default, skip_serializing_if = "ClaimsImports::is_default")] pub claims_imports: ClaimsImports, + /// Whether to allow a user logging in via OIDC to match a pre-existing + /// account instead of failing. This could be used if switching from + /// password logins to OIDC. + //Defaults to false. + #[serde(default)] + pub allow_existing_users: bool, + /// Additional parameters to include in the authorization request /// /// Orders of the keys are not preserved. diff --git a/crates/data-model/src/upstream_oauth2/provider.rs b/crates/data-model/src/upstream_oauth2/provider.rs index 7362d807b..6a70d95bf 100644 --- a/crates/data-model/src/upstream_oauth2/provider.rs +++ b/crates/data-model/src/upstream_oauth2/provider.rs @@ -240,6 +240,7 @@ pub struct UpstreamOAuthProvider { pub created_at: DateTime, pub disabled_at: Option>, pub claims_imports: ClaimsImports, + pub allow_existing_users: bool, pub additional_authorization_parameters: Vec<(String, String)>, pub forward_login_hint: bool, } diff --git a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs index 6696a7109..ea626328b 100644 --- a/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs +++ b/crates/handlers/src/admin/v1/upstream_oauth_links/mod.rs @@ -46,6 +46,7 @@ mod test_utils { token_endpoint_override: None, userinfo_endpoint_override: None, jwks_uri_override: None, + allow_existing_users: true, additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index 6c1b7de63..ef770aece 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -425,6 +425,7 @@ mod tests { created_at: clock.now(), disabled_at: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), + allow_existing_users: false, additional_authorization_parameters: Vec::new(), forward_login_hint: false, }; diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index d95854faa..64cad8f7c 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -473,7 +473,9 @@ pub(crate) async fn get( .await .map_err(RouteError::HomeserverConnection)?; - if maybe_existing_user.is_some() || !is_available { + if !provider.allow_existing_users + && (maybe_existing_user.is_some() || !is_available) + { if let Some(existing_user) = maybe_existing_user { // The mapper returned a username which already exists, but isn't // linked to this upstream user. @@ -485,7 +487,7 @@ pub(crate) async fn get( .with_code("User exists") .with_description(format!( r"Upstream account provider returned {localpart:?} as username, - which is not linked to that upstream account" + which is not linked to that upstream account. Homeserver does not allow linking existing account" )) .with_language(&locale); @@ -507,7 +509,13 @@ pub(crate) async fn get( }) .await?; - if res.valid() { + if provider.allow_existing_users && maybe_existing_user.is_some(){ + // use existing user if allowed + ctx + .with_existing_user(maybe_existing_user.unwrap()) + .with_localpart(localpart, true) + } + else if res.valid() { // The username passes the policy check, add it to the context ctx.with_localpart( localpart, @@ -749,15 +757,16 @@ pub(crate) async fn post( mas_templates::UpstreamRegisterFormField::Username, FieldError::Required, ); - } else if repo.user().exists(&username).await? { + } else if !provider.allow_existing_users && repo.user().exists(&username).await? { form_state.add_error_on_field( mas_templates::UpstreamRegisterFormField::Username, FieldError::Exists, ); - } else if !homeserver - .is_localpart_available(&username) - .await - .map_err(RouteError::HomeserverConnection)? + } else if !provider.allow_existing_users + && !homeserver + .is_localpart_available(&username) + .await + .map_err(RouteError::HomeserverConnection)? { // The user already exists on the homeserver tracing::warn!( @@ -836,27 +845,41 @@ pub(crate) async fn post( ) .into_response()); } + + let mut existing_user: Option = None; - REGISTRATION_COUNTER.add(1, &[KeyValue::new(PROVIDER, provider.id.to_string())]); - - // Now we can create the user - let user = repo.user().add(&mut rng, &clock, username).await?; - - if let Some(terms_url) = &site_config.tos_uri { - repo.user_terms() - .accept_terms(&mut rng, &clock, &user, terms_url.clone()) - .await?; + //search and use existing users if allowed + if provider.allow_existing_users { + existing_user = repo.user().find_by_username(&username).await?; } - // And schedule the job to provision it - let mut job = ProvisionUserJob::new(&user); - - // If we have a display name, set it during provisioning - if let Some(name) = display_name { - job = job.set_display_name(name); - } + + let user = if existing_user.is_some(){ + existing_user.unwrap() + } else { + REGISTRATION_COUNTER.add(1, &[KeyValue::new(PROVIDER, provider.id.to_string())]); + + // Now we can create the user + let user = repo.user().add(&mut rng, &clock, username).await?; + + if let Some(terms_url) = &site_config.tos_uri { + repo.user_terms() + .accept_terms(&mut rng, &clock, &user, terms_url.clone()) + .await?; + } - repo.queue_job().schedule_job(&mut rng, &clock, job).await?; + // And schedule the job to provision it + let mut job = ProvisionUserJob::new(&user); + + // If we have a display name, set it during provisioning + if let Some(name) = display_name { + job = job.set_display_name(name); + } + + repo.queue_job().schedule_job(&mut rng, &clock, job).await?; + + user + }; // If we have an email, add it to the user if let Some(email) = email { @@ -982,6 +1005,7 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_existing_users: true, additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, @@ -1095,4 +1119,374 @@ mod tests { assert_eq!(email.email, "john@example.com"); } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_existing_account(pool: PgPool) { + #[allow(clippy::disallowed_methods)] + let timestamp = chrono::Utc::now().timestamp_millis(); + + //suffix timestamp to generate unique test data + let existing_username = format!("{}{}", "john",timestamp); + let existing_email = format!("{}@{}", existing_username, "example.com"); + + //existing username matches oidc username + let oidc_username = existing_username.clone(); + + //oidc email is different from existing email + let oidc_email: String = format!("{}{}@{}", "any_email", timestamp,"example.com"); + + //generate unique subject + let subject = format!("{}+{}", "subject", timestamp); + + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let mut rng = state.rng(); + let cookies = CookieHelper::new(); + + let claims_imports = UpstreamOAuthProviderClaimsImports { + localpart: UpstreamOAuthProviderImportPreference { + action: mas_data_model::UpstreamOAuthProviderImportAction::Require, + template: None, + }, + email: UpstreamOAuthProviderImportPreference { + action: mas_data_model::UpstreamOAuthProviderImportAction::Require, + template: None, + }, + ..UpstreamOAuthProviderClaimsImports::default() + }; + + let id_token = serde_json::json!({ + "preferred_username": oidc_username, + "email": oidc_email, + "email_verified": true, + }); + + // Grab a key to sign the id_token + // We could generate a key on the fly, but because we have one available here, + // why not use it? + let key = state + .key_store + .signing_key_for_algorithm(&JsonWebSignatureAlg::Rs256) + .unwrap(); + + let signer = key + .params() + .signing_key_for_alg(&JsonWebSignatureAlg::Rs256) + .unwrap(); + let header = JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256); + let id_token = Jwt::sign_with_rng(&mut rng, header, id_token, &signer).unwrap(); + + // Provision a provider and a link + let mut repo = state.repository().await.unwrap(); + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + UpstreamOAuthProviderParams { + issuer: Some("https://example.com/".to_owned()), + human_name: Some("Example Ltd.".to_owned()), + brand_name: None, + scope: Scope::from_iter([OPENID]), + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, + token_endpoint_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + client_id: "client".to_owned(), + encrypted_client_secret: None, + claims_imports, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + jwks_uri_override: None, + discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, + response_mode: None, + allow_existing_users: true, + additional_authorization_parameters: Vec::new(), + forward_login_hint: false, + ui_order: 0, + }, + ) + .await + .unwrap(); + + let session = repo + .upstream_oauth_session() + .add( + &mut rng, + &state.clock, + &provider, + "state".to_owned(), + None, + Some("nonce".to_owned()), + ) + .await + .unwrap(); + + let link = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider, + subject.clone(), + None, + ) + .await + .unwrap(); + + let session = repo + .upstream_oauth_session() + .complete_with_link( + &state.clock, + session, + &link, + Some(id_token.into_string()), + None, + None, + ) + .await + .unwrap(); + + //create a user with an email + let user = repo + .user() + .add(&mut rng, &state.clock, existing_username.clone()) + .await + .unwrap(); + + let _user_email = repo + .user_email() + .add(&mut rng, &state.clock, &user, existing_email.clone()) + .await; + + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let upstream_sessions = UpstreamSessionsCookie::default() + .add(session.id, provider.id, "state".to_owned(), None) + .add_link_to_session(session.id, link.id) + .unwrap(); + let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); + cookies.import(cookie_jar); + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + // Extract the CSRF token from the response body + let csrf_token = response + .body() + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('\"') + .next() + .unwrap(); + + let request = Request::post(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).form( + serde_json::json!({ + "csrf": csrf_token, + "action": "register", + "import_email": "on", + "accept_terms": "on", + }), + ); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::SEE_OTHER); + + // Check that the existing user has the oidc link + let mut repo = state.repository().await.unwrap(); + + let link = repo + .upstream_oauth_link() + .find_by_subject(&provider, &subject) + .await + .unwrap() + .expect("link exists"); + + assert_eq!(link.user_id, Some(user.id)); + + let page = repo + .user_email() + .list(UserEmailFilter::new().for_user(&user), Pagination::first(1)) + .await + .unwrap(); + + //check that the existing user email is updated by oidc email + assert_eq!(page.edges.len(), 1); + let email = page.edges.first().expect("email exists"); + + assert_eq!(email.email, oidc_email); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_link_existing_account_when_not_allowed(pool: PgPool) { + #[allow(clippy::disallowed_methods)] + let timestamp = chrono::Utc::now().timestamp_millis(); + + //suffix timestamp to generate unique test data + let existing_username = format!("{}{}", "john",timestamp); + let existing_email = format!("{}@{}", existing_username, "example.com"); + + //existing username matches oidc username + let oidc_username = existing_username.clone(); + + //oidc email is different from existing email + let oidc_email: String = format!("{}{}@{}", "any_email", timestamp,"example.com"); + + let subject = format!("{}+{}", "subject", timestamp); + + setup(); + let state = TestState::from_pool(pool).await.unwrap(); + let mut rng = state.rng(); + let cookies = CookieHelper::new(); + + let claims_imports = UpstreamOAuthProviderClaimsImports { + localpart: UpstreamOAuthProviderImportPreference { + action: mas_data_model::UpstreamOAuthProviderImportAction::Require, + template: None, + }, + email: UpstreamOAuthProviderImportPreference { + action: mas_data_model::UpstreamOAuthProviderImportAction::Require, + template: None, + }, + ..UpstreamOAuthProviderClaimsImports::default() + }; + + let id_token = serde_json::json!({ + "preferred_username": oidc_username, + "email": oidc_email, + "email_verified": true, + }); + + // Grab a key to sign the id_token + // We could generate a key on the fly, but because we have one available here, + // why not use it? + let key = state + .key_store + .signing_key_for_algorithm(&JsonWebSignatureAlg::Rs256) + .unwrap(); + + let signer = key + .params() + .signing_key_for_alg(&JsonWebSignatureAlg::Rs256) + .unwrap(); + let header = JsonWebSignatureHeader::new(JsonWebSignatureAlg::Rs256); + let id_token = Jwt::sign_with_rng(&mut rng, header, id_token, &signer).unwrap(); + + // Provision a provider and a link + let mut repo = state.repository().await.unwrap(); + let provider = repo + .upstream_oauth_provider() + .add( + &mut rng, + &state.clock, + UpstreamOAuthProviderParams { + issuer: Some("https://example.com/".to_owned()), + human_name: Some("Example Ltd.".to_owned()), + brand_name: None, + scope: Scope::from_iter([OPENID]), + token_endpoint_auth_method: UpstreamOAuthProviderTokenAuthMethod::None, + token_endpoint_signing_alg: None, + id_token_signed_response_alg: JsonWebSignatureAlg::Rs256, + client_id: "client".to_owned(), + encrypted_client_secret: None, + claims_imports, + authorization_endpoint_override: None, + token_endpoint_override: None, + userinfo_endpoint_override: None, + fetch_userinfo: false, + userinfo_signed_response_alg: None, + jwks_uri_override: None, + discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, + pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, + response_mode: None, + allow_existing_users: false, + additional_authorization_parameters: Vec::new(), + forward_login_hint: false, + ui_order: 0, + }, + ) + .await + .unwrap(); + + let session = repo + .upstream_oauth_session() + .add( + &mut rng, + &state.clock, + &provider, + "state".to_owned(), + None, + Some("nonce".to_owned()), + ) + .await + .unwrap(); + + let link = repo + .upstream_oauth_link() + .add( + &mut rng, + &state.clock, + &provider, + subject.clone(), + None, + ) + .await + .unwrap(); + + let session = repo + .upstream_oauth_session() + .complete_with_link( + &state.clock, + session, + &link, + Some(id_token.into_string()), + None, + None, + ) + .await + .unwrap(); + + //create a user with an email + let user = repo + .user() + .add(&mut rng, &state.clock, existing_username.clone()) + .await + .unwrap(); + + let _user_email = repo + .user_email() + .add(&mut rng, &state.clock, &user, existing_email.clone()) + .await; + + repo.save().await.unwrap(); + + let cookie_jar = state.cookie_jar(); + let upstream_sessions = UpstreamSessionsCookie::default() + .add(session.id, provider.id, "state".to_owned(), None) + .add_link_to_session(session.id, link.id) + .unwrap(); + let cookie_jar = upstream_sessions.save(cookie_jar, &state.clock); + cookies.import(cookie_jar); + + let request = Request::get(&*mas_router::UpstreamOAuth2Link::new(link.id).path()).empty(); + let request = cookies.with_cookies(request); + let response = state.request(request).await; + cookies.save_cookies(&response); + response.assert_status(StatusCode::OK); + response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8"); + + assert!(response + .body().contains("Unexpected error")); + + } } diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 87884ddba..051259e9b 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -497,6 +497,7 @@ mod test { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_existing_users: true, additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, @@ -539,6 +540,7 @@ mod test { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_existing_users: true, additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 1, diff --git a/crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json b/crates/storage-pg/.sqlx/query-4482a37fb2af02d4db0a72ce9d5af71543603851a902696e695ddd5719d9f916.json similarity index 87% rename from crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json rename to crates/storage-pg/.sqlx/query-4482a37fb2af02d4db0a72ce9d5af71543603851a902696e695ddd5719d9f916.json index d544590c4..fa26f21e8 100644 --- a/crates/storage-pg/.sqlx/query-e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917.json +++ b/crates/storage-pg/.sqlx/query-4482a37fb2af02d4db0a72ce9d5af71543603851a902696e695ddd5719d9f916.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint\n FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ORDER BY ui_order ASC, upstream_oauth_provider_id ASC\n ", + "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n allow_existing_users,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint\n FROM upstream_oauth_providers\n WHERE disabled_at IS NULL\n ORDER BY ui_order ASC, upstream_oauth_provider_id ASC\n ", "describe": { "columns": [ { @@ -115,11 +115,16 @@ }, { "ordinal": 22, + "name": "allow_existing_users", + "type_info": "Bool" + }, + { + "ordinal": 23, "name": "additional_parameters: Json>", "type_info": "Jsonb" }, { - "ordinal": 23, + "ordinal": 24, "name": "forward_login_hint", "type_info": "Bool" } @@ -150,9 +155,10 @@ false, false, true, + false, true, false ] }, - "hash": "e6d66a7980933c12ab046958e02d419129ef52ac45bea4345471838016cae917" + "hash": "4482a37fb2af02d4db0a72ce9d5af71543603851a902696e695ddd5719d9f916" } diff --git a/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json b/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json deleted file mode 100644 index a7b63ca21..000000000 --- a/crates/storage-pg/.sqlx/query-585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters,\n forward_login_hint,\n ui_order,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,\n $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23, $24)\n ON CONFLICT (upstream_oauth_provider_id)\n DO UPDATE\n SET\n issuer = EXCLUDED.issuer,\n human_name = EXCLUDED.human_name,\n brand_name = EXCLUDED.brand_name,\n scope = EXCLUDED.scope,\n token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,\n token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg,\n id_token_signed_response_alg = EXCLUDED.id_token_signed_response_alg,\n fetch_userinfo = EXCLUDED.fetch_userinfo,\n userinfo_signed_response_alg = EXCLUDED.userinfo_signed_response_alg,\n disabled_at = NULL,\n client_id = EXCLUDED.client_id,\n encrypted_client_secret = EXCLUDED.encrypted_client_secret,\n claims_imports = EXCLUDED.claims_imports,\n authorization_endpoint_override = EXCLUDED.authorization_endpoint_override,\n token_endpoint_override = EXCLUDED.token_endpoint_override,\n userinfo_endpoint_override = EXCLUDED.userinfo_endpoint_override,\n jwks_uri_override = EXCLUDED.jwks_uri_override,\n discovery_mode = EXCLUDED.discovery_mode,\n pkce_mode = EXCLUDED.pkce_mode,\n response_mode = EXCLUDED.response_mode,\n additional_parameters = EXCLUDED.additional_parameters,\n forward_login_hint = EXCLUDED.forward_login_hint,\n ui_order = EXCLUDED.ui_order\n RETURNING created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Bool", - "Text", - "Text", - "Text", - "Jsonb", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Text", - "Jsonb", - "Bool", - "Int4", - "Timestamptz" - ] - }, - "nullable": [ - false - ] - }, - "hash": "585a1e78834c953c80a0af9215348b0f551b16f4cb57c022b50212cfc3d8431f" -} diff --git a/crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json b/crates/storage-pg/.sqlx/query-964695a9656d98235640af45893d2a01839689a7b3f8723561fbe0cd2087a9aa.json similarity index 88% rename from crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json rename to crates/storage-pg/.sqlx/query-964695a9656d98235640af45893d2a01839689a7b3f8723561fbe0cd2087a9aa.json index 7c1a26a86..af2e5c741 100644 --- a/crates/storage-pg/.sqlx/query-a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89.json +++ b/crates/storage-pg/.sqlx/query-964695a9656d98235640af45893d2a01839689a7b3f8723561fbe0cd2087a9aa.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", + "query": "\n SELECT\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n client_id,\n encrypted_client_secret,\n token_endpoint_signing_alg,\n token_endpoint_auth_method,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n created_at,\n disabled_at,\n claims_imports as \"claims_imports: Json\",\n jwks_uri_override,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n allow_existing_users,\n additional_parameters as \"additional_parameters: Json>\",\n forward_login_hint\n FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", "describe": { "columns": [ { @@ -115,11 +115,16 @@ }, { "ordinal": 22, + "name": "allow_existing_users", + "type_info": "Bool" + }, + { + "ordinal": 23, "name": "additional_parameters: Json>", "type_info": "Jsonb" }, { - "ordinal": 23, + "ordinal": 24, "name": "forward_login_hint", "type_info": "Bool" } @@ -152,9 +157,10 @@ false, false, true, + false, true, false ] }, - "hash": "a82b87ccfaa1de9a8e6433aaa67382fbb5029d5f7adf95aaa0decd668d25ba89" + "hash": "964695a9656d98235640af45893d2a01839689a7b3f8723561fbe0cd2087a9aa" } diff --git a/crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json b/crates/storage-pg/.sqlx/query-a299e2e9a538e73b4e2c5307db49523b6109005186448e4efbaf49e82e5787e2.json similarity index 76% rename from crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json rename to crates/storage-pg/.sqlx/query-a299e2e9a538e73b4e2c5307db49523b6109005186448e4efbaf49e82e5787e2.json index 9944e855b..10e81858d 100644 --- a/crates/storage-pg/.sqlx/query-a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c.json +++ b/crates/storage-pg/.sqlx/query-a299e2e9a538e73b4e2c5307db49523b6109005186448e4efbaf49e82e5787e2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n forward_login_hint,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)\n ", + "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n allow_existing_users,\n forward_login_hint,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)\n ", "describe": { "columns": [], "parameters": { @@ -26,10 +26,11 @@ "Text", "Text", "Bool", + "Bool", "Timestamptz" ] }, "nullable": [] }, - "hash": "a711f4c6fa38b98c960ee565038d42ea16db436352b19fcd3b2c620c73d9cc0c" + "hash": "a299e2e9a538e73b4e2c5307db49523b6109005186448e4efbaf49e82e5787e2" } diff --git a/crates/storage-pg/.sqlx/query-c7e1d68f42e0a190d5fff3eeb6b24b6c9c5bcf3159bf1ac57408681a0fac8a70.json b/crates/storage-pg/.sqlx/query-c7e1d68f42e0a190d5fff3eeb6b24b6c9c5bcf3159bf1ac57408681a0fac8a70.json new file mode 100644 index 000000000..af5e41edd --- /dev/null +++ b/crates/storage-pg/.sqlx/query-c7e1d68f42e0a190d5fff3eeb6b24b6c9c5bcf3159bf1ac57408681a0fac8a70.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO upstream_oauth_providers (\n upstream_oauth_provider_id,\n issuer,\n human_name,\n brand_name,\n scope,\n token_endpoint_auth_method,\n token_endpoint_signing_alg,\n id_token_signed_response_alg,\n fetch_userinfo,\n userinfo_signed_response_alg,\n client_id,\n encrypted_client_secret,\n claims_imports,\n authorization_endpoint_override,\n token_endpoint_override,\n userinfo_endpoint_override,\n jwks_uri_override,\n discovery_mode,\n pkce_mode,\n response_mode,\n allow_existing_users,\n additional_parameters,\n forward_login_hint,\n ui_order,\n created_at\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,\n $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23, $24, $25)\n ON CONFLICT (upstream_oauth_provider_id)\n DO UPDATE\n SET\n issuer = EXCLUDED.issuer,\n human_name = EXCLUDED.human_name,\n brand_name = EXCLUDED.brand_name,\n scope = EXCLUDED.scope,\n token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,\n token_endpoint_signing_alg = EXCLUDED.token_endpoint_signing_alg,\n id_token_signed_response_alg = EXCLUDED.id_token_signed_response_alg,\n fetch_userinfo = EXCLUDED.fetch_userinfo,\n userinfo_signed_response_alg = EXCLUDED.userinfo_signed_response_alg,\n disabled_at = NULL,\n client_id = EXCLUDED.client_id,\n encrypted_client_secret = EXCLUDED.encrypted_client_secret,\n claims_imports = EXCLUDED.claims_imports,\n authorization_endpoint_override = EXCLUDED.authorization_endpoint_override,\n token_endpoint_override = EXCLUDED.token_endpoint_override,\n userinfo_endpoint_override = EXCLUDED.userinfo_endpoint_override,\n jwks_uri_override = EXCLUDED.jwks_uri_override,\n discovery_mode = EXCLUDED.discovery_mode,\n pkce_mode = EXCLUDED.pkce_mode,\n response_mode = EXCLUDED.response_mode,\n allow_existing_users = EXCLUDED.allow_existing_users,\n additional_parameters = EXCLUDED.additional_parameters,\n forward_login_hint = EXCLUDED.forward_login_hint,\n ui_order = EXCLUDED.ui_order\n RETURNING created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Text", + "Text", + "Text", + "Jsonb", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Jsonb", + "Bool", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c7e1d68f42e0a190d5fff3eeb6b24b6c9c5bcf3159bf1ac57408681a0fac8a70" +} diff --git a/crates/storage-pg/migrations/20250311090920_upstream_oauth_providers_allow_existing_users.sql b/crates/storage-pg/migrations/20250311090920_upstream_oauth_providers_allow_existing_users.sql new file mode 100644 index 000000000..33bb87bc3 --- /dev/null +++ b/crates/storage-pg/migrations/20250311090920_upstream_oauth_providers_allow_existing_users.sql @@ -0,0 +1,7 @@ +-- Copyright 2024 The Matrix.org Foundation C.I.C. +-- +-- SPDX-License-Identifier: AGPL-3.0-only +-- Please see LICENSE in the repository root for full details. + +ALTER TABLE "upstream_oauth_providers" + ADD COLUMN "allow_existing_users" BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/storage-pg/src/iden.rs b/crates/storage-pg/src/iden.rs index 76067b2fa..189d05b06 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -124,6 +124,7 @@ pub enum UpstreamOAuthProviders { TokenEndpointOverride, AuthorizationEndpointOverride, UserinfoEndpointOverride, + AllowExistingUsers, } #[derive(sea_query::Iden)] diff --git a/crates/storage-pg/src/upstream_oauth2/mod.rs b/crates/storage-pg/src/upstream_oauth2/mod.rs index a5cda570b..0aa164a00 100644 --- a/crates/storage-pg/src/upstream_oauth2/mod.rs +++ b/crates/storage-pg/src/upstream_oauth2/mod.rs @@ -75,6 +75,7 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_existing_users: false, additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, @@ -323,6 +324,7 @@ mod tests { discovery_mode: mas_data_model::UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: mas_data_model::UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_existing_users: false, additional_authorization_parameters: Vec::new(), forward_login_hint: false, ui_order: 0, diff --git a/crates/storage-pg/src/upstream_oauth2/provider.rs b/crates/storage-pg/src/upstream_oauth2/provider.rs index 879d7c658..6680b013b 100644 --- a/crates/storage-pg/src/upstream_oauth2/provider.rs +++ b/crates/storage-pg/src/upstream_oauth2/provider.rs @@ -69,6 +69,7 @@ struct ProviderLookup { discovery_mode: String, pkce_mode: String, response_mode: Option, + allow_existing_users: bool, additional_parameters: Option>>, forward_login_hint: bool, } @@ -217,6 +218,7 @@ impl TryFrom for UpstreamOAuthProvider { discovery_mode, pkce_mode, response_mode, + allow_existing_users: value.allow_existing_users, additional_authorization_parameters, forward_login_hint: value.forward_login_hint, }) @@ -276,6 +278,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_existing_users, additional_parameters as "additional_parameters: Json>", forward_login_hint FROM upstream_oauth_providers @@ -339,10 +342,11 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_existing_users, forward_login_hint, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) "#, Uuid::from(id), params.issuer.as_deref(), @@ -379,6 +383,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { params.discovery_mode.as_str(), params.pkce_mode.as_str(), params.response_mode.as_ref().map(ToString::to_string), + params.allow_existing_users, params.forward_login_hint, created_at, ) @@ -409,6 +414,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode: params.discovery_mode, pkce_mode: params.pkce_mode, response_mode: params.response_mode, + allow_existing_users: params.allow_existing_users, additional_authorization_parameters: params.additional_authorization_parameters, forward_login_hint: params.forward_login_hint, }) @@ -522,13 +528,14 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_existing_users, additional_parameters, forward_login_hint, ui_order, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, - $21, $22, $23, $24) + $21, $22, $23, $24, $25) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -552,6 +559,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode = EXCLUDED.discovery_mode, pkce_mode = EXCLUDED.pkce_mode, response_mode = EXCLUDED.response_mode, + allow_existing_users = EXCLUDED.allow_existing_users, additional_parameters = EXCLUDED.additional_parameters, forward_login_hint = EXCLUDED.forward_login_hint, ui_order = EXCLUDED.ui_order @@ -592,6 +600,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { params.discovery_mode.as_str(), params.pkce_mode.as_str(), params.response_mode.as_ref().map(ToString::to_string), + params.allow_existing_users, Json(¶ms.additional_authorization_parameters) as _, params.forward_login_hint, params.ui_order, @@ -624,6 +633,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode: params.discovery_mode, pkce_mode: params.pkce_mode, response_mode: params.response_mode, + allow_existing_users: params.allow_existing_users, additional_authorization_parameters: params.additional_authorization_parameters, forward_login_hint: params.forward_login_hint, }) @@ -829,6 +839,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { )), ProviderLookupIden::ResponseMode, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::AllowExistingUsers, + )), + ProviderLookupIden::AllowExistingUsers, + ) .expr_as( Expr::col(( UpstreamOAuthProviders::Table, @@ -935,6 +952,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_existing_users, additional_parameters as "additional_parameters: Json>", forward_login_hint FROM upstream_oauth_providers diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index ac6553b88..5fb9c42ea 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -93,6 +93,9 @@ pub struct UpstreamOAuthProviderParams { /// What response mode it should ask pub response_mode: Option, + /// Whether to allow existing users to be linked to the provider + pub allow_existing_users: bool, + /// Additional parameters to include in the authorization request pub additional_authorization_parameters: Vec<(String, String)>, diff --git a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap index a368aa9a5..3a4d50793 100644 --- a/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap +++ b/crates/syn2mas/src/mas_writer/snapshots/syn2mas__mas_writer__test__write_user_with_upstream_provider_link.snap @@ -11,6 +11,7 @@ upstream_oauth_links: user_id: 00000000-0000-0000-0000-000000000001 upstream_oauth_providers: - additional_parameters: ~ + allow_existing_users: "false" authorization_endpoint_override: ~ brand_name: ~ claims_imports: "{}" diff --git a/crates/syn2mas/src/synapse_reader/config/oidc.rs b/crates/syn2mas/src/synapse_reader/config/oidc.rs index 9eea0a9be..ad696d92c 100644 --- a/crates/syn2mas/src/synapse_reader/config/oidc.rs +++ b/crates/syn2mas/src/synapse_reader/config/oidc.rs @@ -344,6 +344,7 @@ impl OidcProvider { response_mode, claims_imports, additional_authorization_parameters, + allow_existing_users: true, forward_login_hint: self.forward_login_hint, }) } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 54b2f193d..b9a763603 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1366,6 +1366,7 @@ pub struct UpstreamRegister { imported_email: Option, force_email: bool, form_state: FormState, + existing_user: Option, } impl UpstreamRegister { @@ -1386,6 +1387,7 @@ impl UpstreamRegister { imported_email: None, force_email: false, form_state: FormState::default(), + existing_user: None, } } @@ -1447,6 +1449,15 @@ impl UpstreamRegister { pub fn with_form_state(self, form_state: FormState) -> Self { Self { form_state, ..self } } + + /// Set the imported email + #[must_use] + pub fn with_existing_user(self, user: User) -> Self { + Self { + existing_user: Some(user), + ..self + } + } } impl TemplateContext for UpstreamRegister { @@ -1484,6 +1495,7 @@ impl TemplateContext for UpstreamRegister { discovery_mode: UpstreamOAuthProviderDiscoveryMode::Oidc, pkce_mode: UpstreamOAuthProviderPkceMode::Auto, response_mode: None, + allow_existing_users: false, additional_authorization_parameters: Vec::new(), forward_login_hint: false, created_at: now, diff --git a/docs/config.schema.json b/docs/config.schema.json index def447302..cbaae1d33 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2111,6 +2111,11 @@ } ] }, + "allow_existing_users": { + "description": "Whether to allow a user logging in via OIDC to match a pre-existing account instead of failing. This could be used if switching from password logins to OIDC.", + "default": false, + "type": "boolean" + }, "additional_authorization_parameters": { "description": "Additional parameters to include in the authorization request\n\nOrders of the keys are not preserved.", "type": "object", diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2303e889e..453e92f35 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -770,6 +770,10 @@ upstream_oauth2: # This helps end user identify what account they are using account_name: #template: "@{{ user.preferred_username }}" + # set to true to allow a user logging in via OIDC to match a pre-existing account instead of failing. + # Account matching is based on the localpart template which needs to be set to require or force. + # This could be used if switching from password logins to OIDC. Defaults to false. + allow_existing_users: true ``` ## `experimental` diff --git a/docs/setup/sso.md b/docs/setup/sso.md index 0f3994825..2672e7bec 100644 --- a/docs/setup/sso.md +++ b/docs/setup/sso.md @@ -65,6 +65,23 @@ The template has the following variables available: - `user`: an object which contains the claims from both the `id_token` and the `userinfo` endpoint - `extra_callback_parameters`: an object with the additional parameters the provider sent to the redirect URL + +## Allow linking existing user accounts + +The authentication service supports linking external provider identities to existing local user accounts. + +To enable this behavior, the following option must be explicitly set in the provider configuration: + +* `allow_existing_users: true` *(default: false)* : when a user authenticates with the provider for the first time, the system checks whether a local user already exists with a `localpart` matching the attribute mapping `localpart` , _by default `{{ user.preferred_username }}`_. If a match is found, the external identity is linked to the existing local account. + +To enable this option, the `localpart` mapping must be set to either `force` or `require`. + +> ⚠️ **Security Notice** +> Enabling this option can introduce a risk of account takeover. +> +> To mitigate this risk, ensure that this option is only enabled for identity providers where you can guarantee that the attribute mapping `localpart` will reliably and uniquely correspond to the intended local user account. + + ## Multiple providers behaviour Multiple authentication methods can be configured at the same time, in which case the authentication service will let the user choose which one to use. diff --git a/templates/pages/upstream_oauth2/do_register.html b/templates/pages/upstream_oauth2/do_register.html index c9b893542..188631c5a 100644 --- a/templates/pages/upstream_oauth2/do_register.html +++ b/templates/pages/upstream_oauth2/do_register.html @@ -11,7 +11,24 @@ {% from "components/idp_brand.html" import logo %} {% block content %} - {% if force_localpart %} + {% if existing_user %} +
+
+ {{ icon.download() }} +
+ +
+

+ {# todo translate #} + Link your existing account +

+

+ {# todo translate #} + We have detected you have an existing account, confirm you want to use this account +

+
+
+ {% elif force_localpart %}
{{ icon.download() }} @@ -188,8 +205,11 @@

{% endcall %} {% endif %} - - {{ button.button(text=_("action.create_account")) }} + {% if existing_user %} + {{ button.button(text=_("mas.upstream_oauth2.register.link_existing")) }} + {% else %} + {{ button.button(text=_("action.create_account")) }} + {% endif %} {# Leave this for now as we don't have that fully designed yet