diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 17adc0012..2c409cf60 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -3,8 +3,9 @@ name: Build on: push: branches: - - main + - main_tchap - "release/**" + - "test/**" tags: - "v*" @@ -22,9 +23,9 @@ env: CARGO_NET_GIT_FETCH_WITH_CLI: "true" SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" - IMAGE: ghcr.io/element-hq/matrix-authentication-service - IMAGE_SYN2MAS: ghcr.io/element-hq/matrix-authentication-service/syn2mas - BUILDCACHE: ghcr.io/element-hq/matrix-authentication-service/buildcache + IMAGE: ghcr.io/tchapgouv/matrix-authentication-service + IMAGE_SYN2MAS: ghcr.io/tchapgouv/matrix-authentication-service/syn2mas + BUILDCACHE: ghcr.io/tchapgouv/matrix-authentication-service/buildcache DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index jobs: @@ -313,7 +314,7 @@ jobs: # Only sign on tags and on commits on main branch if: | github.event_name != 'pull_request' - && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') + && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main_tchap') env: REGULAR_DIGEST: ${{ steps.output.outputs.metadata && fromJSON(steps.output.outputs.metadata).regular.digest }} @@ -329,7 +330,7 @@ jobs: syn2mas: name: Release syn2mas on NPM runs-on: ubuntu-24.04 - if: github.event_name != 'pull_request' + if: 'false' permissions: contents: read @@ -422,7 +423,7 @@ jobs: unstable: name: Update the unstable release - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main_tchap' runs-on: ubuntu-24.04 needs: diff --git a/Cargo.lock b/Cargo.lock index e4a179e53..6df04ec2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3340,6 +3340,7 @@ dependencies = [ "serde_with", "sha2", "sqlx", + "tchap", "thiserror 2.0.12", "time", "tokio", @@ -6205,6 +6206,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +[[package]] +name = "tchap" +version = "0.1.0" +dependencies = [ + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "url", +] + [[package]] name = "tempfile" version = "3.15.0" diff --git a/Cargo.toml b/Cargo.toml index f2bd90196..3b1e2832c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,13 @@ mas-tower = { path = "./crates/tower/", version = "=0.15.0-rc.0" } oauth2-types = { path = "./crates/oauth2-types/", version = "=0.15.0-rc.0" } syn2mas = { path = "./crates/syn2mas", version = "=0.15.0-rc.0" } +# :tchap: +# [workspace.dependencies.tchap] +# path = "./crates/tchap" +# version = "=0.1.0" +# :tchap:end + + # OpenAPI schema generation and validation [workspace.dependencies.aide] version = "0.14.2" diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 36f9568e8..628ff049a 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 623a97c14..8881784f4 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -543,6 +543,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 b81704661..ea115bd41 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)>, } diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 65c7bbb6f..5553b5020 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -106,6 +106,11 @@ mas-templates.workspace = true oauth2-types.workspace = true zxcvbn = "3.1.0" +# tchap.workspace = true +#:tchap: +tchap = { path = "../tchap", version = "=0.1.0" } +#:tchap:end + [dev-dependencies] insta.workspace = true tracing-subscriber.workspace = true 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 12792e3a6..67c0d835a 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(), ui_order: 0, } diff --git a/crates/handlers/src/upstream_oauth2/cache.rs b/crates/handlers/src/upstream_oauth2/cache.rs index 02a202745..10b00d802 100644 --- a/crates/handlers/src/upstream_oauth2/cache.rs +++ b/crates/handlers/src/upstream_oauth2/cache.rs @@ -422,6 +422,7 @@ mod tests { created_at: clock.now(), disabled_at: None, claims_imports: UpstreamOAuthProviderClaimsImports::default(), + allow_existing_users: false, additional_authorization_parameters: Vec::new(), }; diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index cacba650a..bc44c013c 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -37,10 +37,13 @@ use mas_templates::{ use minijinja::Environment; use opentelemetry::{Key, KeyValue, metrics::Counter}; use serde::{Deserialize, Serialize}; +//:tchap: +use tchap::{self, EmailAllowedResult}; use thiserror::Error; use tracing::warn; use ulid::Ulid; +//:tchap: end use super::{ UpstreamSessionsCookie, template::{AttributeMappingContext, environment}, @@ -434,7 +437,53 @@ pub(crate) async fn get( &context, provider.claims_imports.email.is_required(), )? { - Some(value) => ctx.with_email(value, provider.claims_imports.email.is_forced()), + Some(value) => { + //:tchap: + let server_name = homeserver.homeserver(); + let email_result = check_email_allowed(&value, &server_name).await; + + match email_result { + EmailAllowedResult::Allowed => { + // Email is allowed, continue + } + EmailAllowedResult::WrongServer => { + // Email is mapped to a different server + let ctx = ErrorContext::new() + .with_code("wrong_server") + .with_description(format!( + "Votre adresse mail {} est associée à un autre serveur.", + value + )) + .with_details(format!("Veuillez-vous contacter le support de Tchap support@tchap.beta.gouv.fr")) + .with_language(&locale); + + //return error template + return Ok(( + cookie_jar, + Html(templates.render_error(&ctx)?).into_response(), + )); + } + EmailAllowedResult::InvitationMissing => { + // Server requires an invitation that is not present + let ctx = ErrorContext::new() + .with_code("invitation_missing") + .with_description(format!( + "Vous avez besoin d'une invitation pour accéder à Tchap." + )) + .with_details(format!("Les partenaires externes peuvent accéder à Tchap uniquement avec une invitation d'un agent public.")) + .with_language(&locale); + + //return error template + return Ok(( + cookie_jar, + Html(templates.render_error(&ctx)?).into_response(), + )); + } + } + //:tchap: end + + ctx.with_email(value, provider.claims_imports.email.is_forced()) + } None => ctx, } }; @@ -465,7 +514,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. @@ -742,15 +793,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!( @@ -830,10 +882,22 @@ pub(crate) async fn post( .into_response()); } - 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?; + let user = if provider.allow_existing_users { + // If the provider allows existing users, we can use the existing user + let existing_user = repo.user().find_by_username(&username).await?; + if existing_user.is_some() { + existing_user.unwrap() + } else { + REGISTRATION_COUNTER + .add(1, &[KeyValue::new(PROVIDER, provider.id.to_string())]); + // This case should not happen + repo.user().add(&mut rng, &clock, username).await? + } + } else { + REGISTRATION_COUNTER.add(1, &[KeyValue::new(PROVIDER, provider.id.to_string())]); + // Now we can create the user + repo.user().add(&mut rng, &clock, username).await? + }; if let Some(terms_url) = &site_config.tos_uri { repo.user_terms() @@ -889,6 +953,19 @@ pub(crate) async fn post( Ok((cookie_jar, post_auth_action.go_next(&url_builder)).into_response()) } +//:tchap: +///real function used when not testing +#[cfg(not(test))] +async fn check_email_allowed(email: &str, server_name: &str) -> EmailAllowedResult { + tchap::is_email_allowed(email, server_name).await +} +///mock function used when testing +#[cfg(test)] +async fn check_email_allowed(_email: &str, _server_name: &str) -> EmailAllowedResult { + EmailAllowedResult::Allowed +} +//:tchap:end + #[cfg(test)] mod tests { use hyper::{Request, StatusCode, header::CONTENT_TYPE}; @@ -975,6 +1052,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(), ui_order: 0, }, diff --git a/crates/handlers/src/upstream_oauth2/template.rs b/crates/handlers/src/upstream_oauth2/template.rs index cdd193f09..a56d18fb6 100644 --- a/crates/handlers/src/upstream_oauth2/template.rs +++ b/crates/handlers/src/upstream_oauth2/template.rs @@ -11,6 +11,7 @@ use minijinja::{ Environment, Error, ErrorKind, Value, value::{Enumerator, Object}, }; +use tchap; /// Context passed to the attribute mapping template /// @@ -187,6 +188,12 @@ pub fn environment() -> Environment<'static> { env.add_filter("tlvdecode", tlvdecode); env.add_filter("string", string); env.add_filter("from_json", from_json); + + // Add Tchap-specific filters, this could be a generic config submitted + // to upstream allowing all users to add their own filters without upstream code modifications + // tester les fonctions async pour le reseau + env.add_filter("email_to_display_name", |s: &str| tchap::email_to_display_name(s)); + env.add_filter("email_to_mxid_localpart", |s: &str| tchap::email_to_mxid_localpart(s)); env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback); diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 869e9a89d..d90b37579 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -494,6 +494,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(), ui_order: 0, }, @@ -535,6 +536,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(), ui_order: 1, }, diff --git a/crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json b/crates/storage-pg/.sqlx/query-0ffcf354f8b7f00691812d4b8d86999d97ebe799d87737b4dfec1585edc0d0f9.json similarity index 88% rename from crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json rename to crates/storage-pg/.sqlx/query-0ffcf354f8b7f00691812d4b8d86999d97ebe799d87737b4dfec1585edc0d0f9.json index 65b97215c..259df2ea7 100644 --- a/crates/storage-pg/.sqlx/query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json +++ b/crates/storage-pg/.sqlx/query-0ffcf354f8b7f00691812d4b8d86999d97ebe799d87737b4dfec1585edc0d0f9.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 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 FROM upstream_oauth_providers\n WHERE upstream_oauth_provider_id = $1\n ", "describe": { "columns": [ { @@ -115,6 +115,11 @@ }, { "ordinal": 22, + "name": "allow_existing_users", + "type_info": "Bool" + }, + { + "ordinal": 23, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -147,8 +152,9 @@ false, false, true, + false, true ] }, - "hash": "1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e" + "hash": "0ffcf354f8b7f00691812d4b8d86999d97ebe799d87737b4dfec1585edc0d0f9" } diff --git a/crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json b/crates/storage-pg/.sqlx/query-6e14a326d9c75e0ee0cb7d450badbd180cbb1f74749d4859e2fad5c48a1ef2bd.json similarity index 77% rename from crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json rename to crates/storage-pg/.sqlx/query-6e14a326d9c75e0ee0cb7d450badbd180cbb1f74749d4859e2fad5c48a1ef2bd.json index 1a2a19d81..4f0a83bf7 100644 --- a/crates/storage-pg/.sqlx/query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json +++ b/crates/storage-pg/.sqlx/query-6e14a326d9c75e0ee0cb7d450badbd180cbb1f74749d4859e2fad5c48a1ef2bd.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 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)\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 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 ", "describe": { "columns": [], "parameters": { @@ -25,10 +25,11 @@ "Text", "Text", "Text", + "Bool", "Timestamptz" ] }, "nullable": [] }, - "hash": "e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9" + "hash": "6e14a326d9c75e0ee0cb7d450badbd180cbb1f74749d4859e2fad5c48a1ef2bd" } diff --git a/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json b/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json deleted file mode 100644 index 7ab023046..000000000 --- a/crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json +++ /dev/null @@ -1,44 +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 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)\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 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", - "Int4", - "Timestamptz" - ] - }, - "nullable": [ - false - ] - }, - "hash": "72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a" -} diff --git a/crates/storage-pg/.sqlx/query-922eba626e453a12eb58ba460465de12d3f72073844306210b3aeaf3247db06c.json b/crates/storage-pg/.sqlx/query-922eba626e453a12eb58ba460465de12d3f72073844306210b3aeaf3247db06c.json new file mode 100644 index 000000000..556a9ceb8 --- /dev/null +++ b/crates/storage-pg/.sqlx/query-922eba626e453a12eb58ba460465de12d3f72073844306210b3aeaf3247db06c.json @@ -0,0 +1,45 @@ +{ + "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 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, $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 allow_existing_users = EXCLUDED.allow_existing_users,\n additional_parameters = EXCLUDED.additional_parameters,\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", + "Int4", + "Timestamptz" + ] + }, + "nullable": [ + false + ] + }, + "hash": "922eba626e453a12eb58ba460465de12d3f72073844306210b3aeaf3247db06c" +} diff --git a/crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json b/crates/storage-pg/.sqlx/query-e5565422d418b8be291b96763ab1d50f4225c54f889e67521c40ac9fa3c9f71a.json similarity index 87% rename from crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json rename to crates/storage-pg/.sqlx/query-e5565422d418b8be291b96763ab1d50f4225c54f889e67521c40ac9fa3c9f71a.json index b929df201..de03f6da3 100644 --- a/crates/storage-pg/.sqlx/query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json +++ b/crates/storage-pg/.sqlx/query-e5565422d418b8be291b96763ab1d50f4225c54f889e67521c40ac9fa3c9f71a.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 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 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,6 +115,11 @@ }, { "ordinal": 22, + "name": "allow_existing_users", + "type_info": "Bool" + }, + { + "ordinal": 23, "name": "additional_parameters: Json>", "type_info": "Jsonb" } @@ -145,8 +150,9 @@ false, false, true, + false, true ] }, - "hash": "c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178" + "hash": "e5565422d418b8be291b96763ab1d50f4225c54f889e67521c40ac9fa3c9f71a" } 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 71e6f7591..b02eb0f3b 100644 --- a/crates/storage-pg/src/iden.rs +++ b/crates/storage-pg/src/iden.rs @@ -122,6 +122,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 d802e9bdb..5a88eaf2f 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(), ui_order: 0, }, @@ -322,6 +323,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(), ui_order: 0, }, diff --git a/crates/storage-pg/src/upstream_oauth2/provider.rs b/crates/storage-pg/src/upstream_oauth2/provider.rs index 2e5f7233f..1186344a3 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>>, } @@ -216,6 +217,7 @@ impl TryFrom for UpstreamOAuthProvider { discovery_mode, pkce_mode, response_mode, + allow_existing_users: value.allow_existing_users, additional_authorization_parameters, }) } @@ -274,6 +276,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_existing_users, additional_parameters as "additional_parameters: Json>" FROM upstream_oauth_providers WHERE upstream_oauth_provider_id = $1 @@ -336,9 +339,10 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_existing_users, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) "#, Uuid::from(id), params.issuer.as_deref(), @@ -375,6 +379,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, created_at, ) .traced() @@ -404,6 +409,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, }) } @@ -516,12 +522,12 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_existing_users, additional_parameters, 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) + $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) ON CONFLICT (upstream_oauth_provider_id) DO UPDATE SET @@ -545,6 +551,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, ui_order = EXCLUDED.ui_order RETURNING created_at @@ -584,6 +591,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.ui_order, created_at, @@ -615,6 +623,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, }) } @@ -826,6 +835,13 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { )), ProviderLookupIden::AdditionalParameters, ) + .expr_as( + Expr::col(( + UpstreamOAuthProviders::Table, + UpstreamOAuthProviders::AllowExistingUsers, + )), + ProviderLookupIden::AllowExistingUsers, + ) .from(UpstreamOAuthProviders::Table) .apply_filter(filter) .generate_pagination( @@ -918,6 +934,7 @@ impl UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<'_> { discovery_mode, pkce_mode, response_mode, + allow_existing_users, additional_parameters as "additional_parameters: Json>" FROM upstream_oauth_providers WHERE disabled_at IS NULL diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index 673050a8f..6044a162c 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 1fbf6a100..527988853 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/tchap/Cargo.toml b/crates/tchap/Cargo.toml new file mode 100644 index 000000000..df039f2a1 --- /dev/null +++ b/crates/tchap/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tchap" +version = "0.1.0" +description = "Tchap-specific functionality for Matrix Authentication Service" +license = "MIT" +edition.workspace = true + + +[dependencies] +reqwest.workspace = true +serde_json.workspace = true +tracing.workspace = true +tokio.workspace = true +url.workspace = true +serde.workspace = true \ No newline at end of file diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs new file mode 100644 index 000000000..9cf6f9700 --- /dev/null +++ b/crates/tchap/src/identity_client.rs @@ -0,0 +1,93 @@ +//! This module provides utilities for interacting with the Matrix identity +//! server API. + +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tracing::info; +use url::Url; + +/// Configuration for Tchap-specific functionality +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TchapConfig { + /// The base URL of the identity server API + pub identity_server_url: Url, +} + +fn default_identity_server_url() -> Url { + // Try to read the TCHAP_IDENTITY_SERVER_URL environment variable + match std::env::var("TCHAP_IDENTITY_SERVER_URL") { + Ok(url_str) => { + // Attempt to parse the URL from the environment variable + match Url::parse(&url_str) { + Ok(url) => { + // Success: use the URL from the environment variable + return url; + } + Err(err) => { + // Parsing error: log a warning and use the default value + tracing::warn!( + "The TCHAP_IDENTITY_SERVER_URL environment variable contains an invalid URL: {}. Using default value.", + err + ); + } + } + } + Err(std::env::VarError::NotPresent) => { + // Variable not defined: use the default value without warning + } + Err(std::env::VarError::NotUnicode(_)) => { + // Variable contains non-Unicode characters: log a warning + tracing::warn!( + "The TCHAP_IDENTITY_SERVER_URL environment variable contains non-Unicode characters. Using default value." + ); + } + } + + // Default value if the environment variable is not defined or invalid + Url::parse("http://localhost:8090").unwrap() +} + +impl Default for TchapConfig { + fn default() -> Self { + Self { + identity_server_url: default_identity_server_url(), + } + } +} +/// Queries the identity server for information about an email address +/// +/// # Parameters +/// +/// * `email`: The email address to check/// +/// # Returns +/// +/// A Result containing either the JSON response or an error +pub async fn query_identity_server(email: &str) -> Result { + let identity_server_url = default_identity_server_url(); + + // Construct the URL with the email address + let url = format!( + "{}_matrix/identity/api/v1/internal-info?medium=email&address={}", + identity_server_url, email + ); + + info!("Making request to identity server: {}", url); + + // Create a client with a timeout + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap_or_default(); + + + // Make the HTTP request asynchronously + // should use mas-http instead like SynapseConnection + #[allow(clippy::disallowed_methods)] + let response = client.get(&url).send().await?; + + // Parse the JSON response + let json = response.json::().await?; + + Ok(json) +} diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs new file mode 100644 index 000000000..1aff1370f --- /dev/null +++ b/crates/tchap/src/lib.rs @@ -0,0 +1,280 @@ +//! Tchap-specific functionality for Matrix Authentication Service +//! +extern crate tracing; +use tracing::info; + +mod identity_client; + +/// Result of checking if an email is allowed on a server +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EmailAllowedResult { + /// Email is allowed on this server + Allowed, + /// Email is mapped to a different server + WrongServer, + /// Server requires an invitation that is not present + InvitationMissing, +} + +/// Checks if an email address is allowed to be associated in the current server +/// +/// This function makes an asynchronous GET request to the Matrix identity +/// server API to retrieve information about the home server associated with an +/// email address, then applies logic to determine if the email is allowed. +/// +/// # Parameters +/// +/// * `email`: The email address to check +/// * `server_name`: The name of the server to check against +/// +/// # Returns +/// +/// An `EmailAllowedResult` indicating whether the email is allowed and if not, +/// why +#[must_use] +pub async fn is_email_allowed(email: &str, server_name: &str) -> EmailAllowedResult { + // Query the identity server + match identity_client::query_identity_server(email).await { + Ok(json) => { + let hs = json.get("hs"); + + // Check if "hs" is in the response or if hs different from server_name + if hs.is_none() || hs.unwrap() != server_name { + // Email is mapped to a different server or no server at all + return EmailAllowedResult::WrongServer; + } + + info!("hs: {} ", hs.unwrap()); + + // Check if requires_invite is true and invited is false + let requires_invite = json + .get("requires_invite") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let invited = json + .get("invited") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + info!("requires_invite: {} invited: {}", requires_invite, invited); + + if requires_invite && !invited { + // Requires an invite but hasn't been invited + return EmailAllowedResult::InvitationMissing; + } + + // All checks passed + EmailAllowedResult::Allowed + } + Err(err) => { + // Log the error and return WrongServer as a default error + eprintln!("HTTP request failed: {}", err); + EmailAllowedResult::WrongServer + } + } +} + + + + +/// Capitalise parts of a name containing different words, including those +/// separated by hyphens. +/// +/// For example, 'John-Doe' +/// +/// # Parameters +/// +/// * `name`: The name to parse +/// +/// # Returns +/// +/// The capitalized name +#[must_use] +pub fn cap(name: &str) -> String { + if name.is_empty() { + return name.to_string(); + } + + // Split the name by whitespace then hyphens, capitalizing each part then + // joining it back together. + let capitalized_name = name + .split_whitespace() + .map(|space_part| { + space_part + .split('-') + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + None => String::new(), + Some(first_char) => { + let first_char_upper = first_char.to_uppercase().collect::(); + let rest: String = chars.collect(); + format!("{}{}", first_char_upper, rest) + } + } + }) + .collect::>() + .join("-") + }) + .collect::>() + .join(" "); + + capitalized_name +} + +/// Generate a Matrix ID localpart from an email address. +/// +/// This function: +/// 1. Replaces "@" with "-" in the email address +/// 2. Converts the email to lowercase +/// 3. Filters out any characters that are not allowed in a Matrix ID localpart +/// +/// The allowed characters are: lowercase ASCII letters, digits, and "_-./=" +/// +/// # Parameters +/// +/// * `address`: The email address to process +/// +/// # Returns +/// +/// A valid Matrix ID localpart derived from the email address +#[must_use] +pub fn email_to_mxid_localpart(address: &str) -> String { + // Define the allowed characters for a Matrix ID localpart + const ALLOWED_CHARS: &str = "abcdefghijklmnopqrstuvwxyz0123456789_-./="; + + // Replace "@" with "-" and convert to lowercase + let processed = address.replace('@', "-").to_lowercase(); + + // Filter out any characters that are not allowed + processed.chars().filter(|c| ALLOWED_CHARS.contains(*c)).collect() +} + +/// Generate a display name from an email address based on specific rules. +/// +/// This function: +/// 1. Replaces dots with spaces in the username part +/// 2. Determines the organization based on domain rules: +/// - gouv.fr emails use the subdomain or "gouv" if none +/// - other emails use the second-level domain +/// 3. Returns a display name in the format "Username [Organization]" +/// +/// # Parameters +/// +/// * `address`: The email address to process +/// +/// # Returns +/// +/// The formatted display name +#[must_use] +pub fn email_to_display_name(address: &str) -> String { + // Split the part before and after the @ in the email. + // Replace all . with spaces in the first part + let parts: Vec<&str> = address.split('@').collect(); + if parts.len() != 2 { + return String::new(); + } + + let username = parts[0].replace('.', " "); + let domain = parts[1]; + + // Figure out which org this email address belongs to + let domain_parts: Vec<&str> = domain.split('.').collect(); + + let org = if domain_parts.len() >= 2 && domain_parts[domain_parts.len() - 2] == "gouv" && domain_parts[domain_parts.len() - 1] == "fr" { + // Is this is a ...gouv.fr address, set the org to whatever is before + // gouv.fr. If there isn't anything (a @gouv.fr email) simply mark their + // org as "gouv" + if domain_parts.len() > 2 { + domain_parts[domain_parts.len() - 3] + } else { + "gouv" + } + } else if domain_parts.len() >= 2 { + // Otherwise, mark their org as the email's second-level domain name + domain_parts[domain_parts.len() - 2] + } else { + "" + }; + + // Format the display name + format!("{} [{}]", cap(&username), cap(org)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cap() { + assert_eq!(cap("john"), "John"); + assert_eq!(cap("john-doe"), "John-Doe"); + assert_eq!(cap("john doe"), "John Doe"); + assert_eq!(cap("john-doe smith"), "John-Doe Smith"); + assert_eq!(cap(""), ""); + } + + #[test] + fn test_email_to_display_name() { + // Test gouv.fr email with subdomain + assert_eq!( + email_to_display_name("jane.smith@example.gouv.fr"), + "Jane Smith [Example]" + ); + + // Test gouv.fr email without subdomain + assert_eq!( + email_to_display_name("user@gouv.fr"), + "User [Gouv]" + ); + + // Test gouv.fr email with subdomain + assert_eq!( + email_to_display_name("user@gendarmerie.gouv.fr"), + "User [Gendarmerie]" + ); + + // Test gouv.fr email with subdomain + assert_eq!( + email_to_display_name("user@gendarmerie.interieur.gouv.fr"), + "User [Interieur]" + ); + + // Test regular email + assert_eq!( + email_to_display_name("contact@example.com"), + "Contact [Example]" + ); + + // Test invalid email + assert_eq!(email_to_display_name("invalid-email"), ""); + } + + #[test] + fn test_email_to_mxid_localpart() { + // Test basic email + assert_eq!( + email_to_mxid_localpart("john.doe@example.com"), + "john.doe-example.com" + ); + + // Test with uppercase letters + assert_eq!( + email_to_mxid_localpart("John.Doe@Example.com"), + "john.doe-example.com" + ); + + // Test with special characters + assert_eq!( + email_to_mxid_localpart("user+tag@domain.com"), + "usertag-domain.com" + ); + + // Test with invalid characters + assert_eq!( + email_to_mxid_localpart("user!#$%^&*()@domain.com"), + "user-domain.com" + ); + } +} diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index b6661d540..fbb83f19e 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1436,6 +1436,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(), created_at: now, disabled_at: None, diff --git a/docs/config.schema.json b/docs/config.schema.json index 165cf947d..65826e41f 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2094,6 +2094,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 30dbbfca9..6fd4d080b 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -770,6 +770,9 @@ 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. + # This could be used if switching from password logins to OIDC. Defaults to false. + allow_existing_users: true ``` ## `experimental`