From f5a3404ba6308d9509b15820a145b51a1d820f2c Mon Sep 17 00:00:00 2001 From: mcalinghee Date: Tue, 11 Mar 2025 10:17:38 +0100 Subject: [PATCH 01/28] allow importing existing users when the localpart matches in upstream OAuth 2.0 logins --- crates/cli/src/sync.rs | 1 + crates/config/src/sections/upstream_oauth2.rs | 7 +++ .../src/upstream_oauth2/provider.rs | 1 + .../src/admin/v1/upstream_oauth_links/mod.rs | 1 + crates/handlers/src/upstream_oauth2/cache.rs | 1 + crates/handlers/src/upstream_oauth2/link.rs | 36 ++++++++++----- crates/handlers/src/views/login.rs | 2 + ...999d97ebe799d87737b4dfec1585edc0d0f9.json} | 10 ++++- ...bd180cbb1f74749d4859e2fad5c48a1ef2bd.json} | 5 ++- ...45b647bb6637e55b662a5a548aa3308c62a8a.json | 44 ------------------ ...5de12d3f72073844306210b3aeaf3247db06c.json | 45 +++++++++++++++++++ ...d50f4225c54f889e67521c40ac9fa3c9f71a.json} | 10 ++++- ...m_oauth_providers_allow_existing_users.sql | 7 +++ crates/storage-pg/src/iden.rs | 1 + crates/storage-pg/src/upstream_oauth2/mod.rs | 2 + .../src/upstream_oauth2/provider.rs | 23 ++++++++-- .../storage/src/upstream_oauth2/provider.rs | 3 ++ ...rite_user_with_upstream_provider_link.snap | 1 + crates/templates/src/context.rs | 1 + docs/config.schema.json | 5 +++ docs/reference/configuration.md | 3 ++ 21 files changed, 146 insertions(+), 63 deletions(-) rename crates/storage-pg/.sqlx/{query-1d758df58ccfead4cb39ee8f88f60b382b7881e9c4ead31ff257ff5ff4414b6e.json => query-0ffcf354f8b7f00691812d4b8d86999d97ebe799d87737b4dfec1585edc0d0f9.json} (88%) rename crates/storage-pg/.sqlx/{query-e25af41189846e26da99e5d8a1462eab5efe330f60ef8c6c813c747424ba7ec9.json => query-6e14a326d9c75e0ee0cb7d450badbd180cbb1f74749d4859e2fad5c48a1ef2bd.json} (77%) delete mode 100644 crates/storage-pg/.sqlx/query-72de26d5e3c56f4b0658685a95b45b647bb6637e55b662a5a548aa3308c62a8a.json create mode 100644 crates/storage-pg/.sqlx/query-922eba626e453a12eb58ba460465de12d3f72073844306210b3aeaf3247db06c.json rename crates/storage-pg/.sqlx/{query-c1e55ffd09181c0d8ddd0df2843690aeae4a20329045ab23639181a0d0903178.json => query-e5565422d418b8be291b96763ab1d50f4225c54f889e67521c40ac9fa3c9f71a.json} (87%) create mode 100644 crates/storage-pg/migrations/20250311090920_upstream_oauth_providers_allow_existing_users.sql diff --git a/crates/cli/src/sync.rs b/crates/cli/src/sync.rs index 647ef2635..f2fdb41a8 100644 --- a/crates/cli/src/sync.rs +++ b/crates/cli/src/sync.rs @@ -292,6 +292,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 98b5f3c3c..c87045293 100644 --- a/crates/config/src/sections/upstream_oauth2.rs +++ b/crates/config/src/sections/upstream_oauth2.rs @@ -536,6 +536,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/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..055a9bf05 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -465,7 +465,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 +744,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 +833,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() @@ -975,6 +990,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/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/templates/src/context.rs b/crates/templates/src/context.rs index 26ed200e1..29027de64 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1489,6 +1489,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 e49a75754..2a0d30c48 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` From e66d6369a5c067b737943d86fcbcbfa0eac38fdb Mon Sep 17 00:00:00 2001 From: Mathieu Velten Date: Thu, 10 Apr 2025 15:27:55 +0200 Subject: [PATCH 02/28] Tweak CI for Tchap --- .github/workflows/build.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 17adc0012..1ea0bc9c0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -3,7 +3,7 @@ name: Build on: push: branches: - - main + - tchap - "release/**" tags: - "v*" @@ -22,9 +22,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 +313,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/tchap') env: REGULAR_DIGEST: ${{ steps.output.outputs.metadata && fromJSON(steps.output.outputs.metadata).regular.digest }} @@ -329,7 +329,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 +422,7 @@ jobs: unstable: name: Update the unstable release - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/tchap' runs-on: ubuntu-24.04 needs: From 52b2c67dbea5aad7bdc20875a29f2bd7fc8c99b1 Mon Sep 17 00:00:00 2001 From: olivier Date: Wed, 23 Apr 2025 10:43:30 +0200 Subject: [PATCH 03/28] update CI for main_tchap --- .github/workflows/build.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1ea0bc9c0..90a1d8cc8 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -3,7 +3,7 @@ name: Build on: push: branches: - - tchap + - main_tchap - "release/**" tags: - "v*" @@ -313,7 +313,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/tchap') + && (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 }} @@ -422,7 +422,7 @@ jobs: unstable: name: Update the unstable release - if: github.ref == 'refs/heads/tchap' + if: github.ref == 'refs/heads/main_tchap' runs-on: ubuntu-24.04 needs: From bd6ebd8b7e9fecf67176c174be522d453057874e Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 17 Apr 2025 17:32:28 +0200 Subject: [PATCH 04/28] add error when email is not allowed --- Cargo.lock | 37 +++ crates/handlers/src/upstream_oauth2/link.rs | 25 +- crates/tchap/Cargo.toml | 11 + crates/tchap/src/lib.rs | 291 ++++++++++++++++++++ 4 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 crates/tchap/Cargo.toml create mode 100644 crates/tchap/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index e4a179e53..1e6a2c850 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1853,6 +1853,16 @@ dependencies = [ "writeable", ] +[[package]] +name = "flate2" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -6205,6 +6215,15 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +[[package]] +name = "tchap" +version = "0.1.0" +dependencies = [ + "serde_json", + "tracing", + "ureq", +] + [[package]] name = "tempfile" version = "3.15.0" @@ -6796,6 +6815,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots", +] + [[package]] name = "url" version = "2.5.4" diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index cacba650a..18e6053c2 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -40,6 +40,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::warn; use ulid::Ulid; +use tchap; use super::{ UpstreamSessionsCookie, @@ -434,7 +435,29 @@ 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 is_allowed = tchap::is_email_allowed(&value, &server_name); + if !is_allowed { + // L'email n'est pas autorisé, afficher un message d'erreur + let ctx = ErrorContext::new() + .with_code("Email not allowed") + .with_description(format!( + "L'adresse email {} n'est pas autorisée sur ce serveur.", + value + )) + .with_language(&locale); + + 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, } }; diff --git a/crates/tchap/Cargo.toml b/crates/tchap/Cargo.toml new file mode 100644 index 000000000..54afe9a62 --- /dev/null +++ b/crates/tchap/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "tchap" +version = "0.1.0" +description = "Tchap-specific functionality for Matrix Authentication Service" +license = "MIT" + + +[dependencies] +ureq = { version = "2.6", features = ["json"] } +serde_json = "1.0" +tracing = "0.1" \ No newline at end of file diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs new file mode 100644 index 000000000..2e6d3cc9d --- /dev/null +++ b/crates/tchap/src/lib.rs @@ -0,0 +1,291 @@ +extern crate tracing; +use tracing::{info, debug}; + + +/// 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)) +} + +/// Checks if an email address is allowed to be associated in the current server +/// +/// This function makes a synchronous 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. +/// +/// The API returns a JSON object with the following structure: +/// ```json +/// { +/// "hs": "string", +/// "requires_invite": true/false, +/// "invited": true/false +/// } +/// ``` +/// +/// # Parameters +/// +/// * `email`: The email address to check +/// +/// # Returns +/// +/// A boolean indicating whether the email is allowed +#[must_use] +pub fn is_email_allowed(email: &str, server_name: &str) -> bool { + // Construct the URL with the email address + let url = format!( + "http://localhost:8083/_matrix/identity/api/v1/info?medium=email&address={}", + email + ); + + info!("Checking if email {} is allowed on server {}", email, server_name); + info!("Making request to identity server: {}", url); + + // Make the HTTP request synchronously with a timeout + match ureq::get(&url) + .timeout(std::time::Duration::from_secs(5)) + .call() + { + Ok(response) => { + // Parse the JSON response + match response.into_json::() { + Ok(json) => { + + + let hs = json.get("hs"); + + // Check if "hs" is in the response or if hs different from server_value + if !hs.is_some() || hs.unwrap() != server_name{ + return false; + } + + 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 false; + } + + // All checks passed + true + }, + Err(err) => { + // Log the error and return false + eprintln!("Failed to parse JSON response: {}", err); + false + } + } + }, + Err(err) => { + // Log the error and return false + eprintln!("HTTP request failed: {}", err); + false + } + } +} + +#[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" + ); + } +} From 4334452054fb7e054a3aa657b1c3427845449b02 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 11:20:47 +0200 Subject: [PATCH 05/28] add EmailAllowedResult to differenciate errors --- Cargo.lock | 184 ++++++++++++++++---- Cargo.toml | 1 + crates/handlers/Cargo.toml | 2 + crates/handlers/src/upstream_oauth2/link.rs | 59 +++++-- crates/tchap/Cargo.toml | 6 +- crates/tchap/src/lib.rs | 66 ++++--- 6 files changed, 240 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e6a2c850..0d6c656e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,6 +1158,16 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.0" @@ -1853,16 +1863,6 @@ dependencies = [ "writeable", ] -[[package]] -name = "flate2" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "flume" version = "0.11.1" @@ -1886,6 +1886,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2376,6 +2391,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.11" @@ -3350,6 +3381,7 @@ dependencies = [ "serde_with", "sha2", "sqlx", + "tchap", "thiserror 2.0.12", "time", "tokio", @@ -3879,6 +3911,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "nom" version = "7.1.3" @@ -4087,12 +4136,50 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-sys" +version = "0.9.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.29.1" @@ -5031,11 +5118,13 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "ipnet", "js-sys", "log", "mime", + "native-tls", "once_cell", "percent-encoding", "pin-project-lite", @@ -5047,7 +5136,9 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper", + "system-configuration", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-socks", "tower", @@ -5231,7 +5322,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.2.0", ] [[package]] @@ -5258,7 +5349,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" dependencies = [ - "core-foundation", + "core-foundation 0.10.0", "core-foundation-sys", "jni", "log", @@ -5267,7 +5358,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.2.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.59.0", @@ -5436,6 +5527,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.2.0" @@ -5443,7 +5547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.0", "core-foundation-sys", "libc", "security-framework-sys", @@ -6209,6 +6313,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "target-lexicon" version = "0.13.2" @@ -6219,9 +6344,10 @@ checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" name = "tchap" version = "0.1.0" dependencies = [ + "reqwest", "serde_json", + "tokio", "tracing", - "ureq", ] [[package]] @@ -6406,6 +6532,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.2" @@ -6815,24 +6951,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "url", - "webpki-roots", -] - [[package]] name = "url" version = "2.5.4" diff --git a/Cargo.toml b/Cargo.toml index f2bd90196..784489d60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ mas-templates = { path = "./crates/templates/", version = "=0.15.0-rc.0" } 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 = { path = "./crates/tchap", version = "=0.1.0" } # OpenAPI schema generation and validation [workspace.dependencies.aide] diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 65c7bbb6f..b230af3e9 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -106,6 +106,8 @@ mas-templates.workspace = true oauth2-types.workspace = true zxcvbn = "3.1.0" +tchap.workspace = true + [dev-dependencies] insta.workspace = true tracing-subscriber.workspace = true diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 18e6053c2..e54ed9399 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -40,7 +40,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::warn; use ulid::Ulid; -use tchap; +use tchap::{self, EmailAllowedResult}; use super::{ UpstreamSessionsCookie, @@ -438,21 +438,48 @@ pub(crate) async fn get( Some(value) => { //:tchap let server_name = homeserver.homeserver(); - let is_allowed = tchap::is_email_allowed(&value, &server_name); - if !is_allowed { - // L'email n'est pas autorisé, afficher un message d'erreur - let ctx = ErrorContext::new() - .with_code("Email not allowed") - .with_description(format!( - "L'adresse email {} n'est pas autorisée sur ce serveur.", - value - )) - .with_language(&locale); - - return Ok(( - cookie_jar, - Html(templates.render_error(&ctx)?).into_response(), - )); + let email_result = + tchap::is_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!( + "L'adresse email {} est associée à un autre serveur.", + value + )) + .with_details(format!("Veuillez vous connecter au serveur approprié pour cette adresse email.")) + .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!( + "L'adresse email {} nécessite une invitation pour ce serveur.", + value + )) + .with_details(format!("Pour vous connecter à Tchap vous devez avoir une invitation.")) + .with_language(&locale); + + //return error template + return Ok(( + cookie_jar, + Html(templates.render_error(&ctx)?).into_response(), + )); + } } //:tchap: end diff --git a/crates/tchap/Cargo.toml b/crates/tchap/Cargo.toml index 54afe9a62..8d1748309 100644 --- a/crates/tchap/Cargo.toml +++ b/crates/tchap/Cargo.toml @@ -3,9 +3,11 @@ name = "tchap" version = "0.1.0" description = "Tchap-specific functionality for Matrix Authentication Service" license = "MIT" +edition = "2024" [dependencies] -ureq = { version = "2.6", features = ["json"] } +reqwest = { version = "0.12.15", features = ["json"] } serde_json = "1.0" -tracing = "0.1" \ No newline at end of file +tracing = "0.1" +tokio = { version = "1.44.2", features = ["time"] } diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs index 2e6d3cc9d..d0e736dcd 100644 --- a/crates/tchap/src/lib.rs +++ b/crates/tchap/src/lib.rs @@ -1,5 +1,19 @@ extern crate tracing; -use tracing::{info, debug}; +use tracing::info; + +use reqwest; +use std::time::Duration; + +/// 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, +} /// Capitalise parts of a name containing different words, including those @@ -128,28 +142,21 @@ pub fn email_to_display_name(address: &str) -> String { /// Checks if an email address is allowed to be associated in the current server /// -/// This function makes a synchronous GET request to the Matrix identity server API +/// 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. -/// -/// The API returns a JSON object with the following structure: -/// ```json -/// { -/// "hs": "string", -/// "requires_invite": true/false, -/// "invited": true/false -/// } /// ``` /// /// # Parameters /// /// * `email`: The email address to check +/// * `server_name`: The name of the server to check against /// /// # Returns /// -/// A boolean indicating whether the email is allowed +/// An `EmailAllowedResult` indicating whether the email is allowed and if not, why #[must_use] -pub fn is_email_allowed(email: &str, server_name: &str) -> bool { +pub async fn is_email_allowed(email: &str, server_name: &str) -> EmailAllowedResult { // Construct the URL with the email address let url = format!( "http://localhost:8083/_matrix/identity/api/v1/info?medium=email&address={}", @@ -159,22 +166,27 @@ pub fn is_email_allowed(email: &str, server_name: &str) -> bool { info!("Checking if email {} is allowed on server {}", email, server_name); info!("Making request to identity server: {}", url); - // Make the HTTP request synchronously with a timeout - match ureq::get(&url) - .timeout(std::time::Duration::from_secs(5)) - .call() + // 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 + match client.get(&url) + .send() + .await { Ok(response) => { // Parse the JSON response - match response.into_json::() { + match response.json::().await { Ok(json) => { - - let hs = json.get("hs"); // Check if "hs" is in the response or if hs different from server_value - if !hs.is_some() || hs.unwrap() != server_name{ - return false; + if !hs.is_some() || hs.unwrap() != server_name { + // Email is mapped to a different server or no server at all + return EmailAllowedResult::WrongServer; } info!("hs: {} ", hs.unwrap()); @@ -192,23 +204,23 @@ pub fn is_email_allowed(email: &str, server_name: &str) -> bool { if requires_invite && !invited { // Requires an invite but hasn't been invited - return false; + return EmailAllowedResult::InvitationMissing; } // All checks passed - true + EmailAllowedResult::Allowed }, Err(err) => { - // Log the error and return false + // Log the error and return WrongServer as a default error eprintln!("Failed to parse JSON response: {}", err); - false + EmailAllowedResult::WrongServer } } }, Err(err) => { - // Log the error and return false + // Log the error and return WrongServer as a default error eprintln!("HTTP request failed: {}", err); - false + EmailAllowedResult::WrongServer } } } From b16cbc6fa8e64498215e4ccdda6f207cdf16e55b Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 14:36:22 +0200 Subject: [PATCH 06/28] add tchap identity server as env var --- Cargo.lock | 2 ++ crates/tchap/Cargo.toml | 2 ++ crates/tchap/src/lib.rs | 58 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d6c656e3..5c03a4333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6345,9 +6345,11 @@ name = "tchap" version = "0.1.0" dependencies = [ "reqwest", + "serde", "serde_json", "tokio", "tracing", + "url", ] [[package]] diff --git a/crates/tchap/Cargo.toml b/crates/tchap/Cargo.toml index 8d1748309..0ad91288b 100644 --- a/crates/tchap/Cargo.toml +++ b/crates/tchap/Cargo.toml @@ -11,3 +11,5 @@ reqwest = { version = "0.12.15", features = ["json"] } serde_json = "1.0" tracing = "0.1" tokio = { version = "1.44.2", features = ["time"] } +url = { version = "2.3", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs index d0e736dcd..225af6dba 100644 --- a/crates/tchap/src/lib.rs +++ b/crates/tchap/src/lib.rs @@ -3,6 +3,57 @@ use tracing::info; use reqwest; use std::time::Duration; +use url::Url; +use serde::{Deserialize, Serialize}; + +/// 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 { + // Essayer de lire la variable d'environnement TCHAP_IDENTITY_SERVER_URL + match std::env::var("TCHAP_IDENTITY_SERVER_URL") { + Ok(url_str) => { + // Tenter de parser l'URL depuis la variable d'environnement + match Url::parse(&url_str) { + Ok(url) => { + // Succès : utiliser l'URL de la variable d'environnement + return url; + } + Err(err) => { + // Erreur de parsing : logger un avertissement et utiliser la valeur par défaut + tracing::warn!( + "La variable d'environnement TCHAP_IDENTITY_SERVER_URL contient une URL invalide : {}. Utilisation de la valeur par défaut.", + err + ); + } + } + } + Err(std::env::VarError::NotPresent) => { + // Variable non définie : utiliser la valeur par défaut sans avertissement + } + Err(std::env::VarError::NotUnicode(_)) => { + // Variable contient des caractères non-Unicode : logger un avertissement + tracing::warn!( + "La variable d'environnement TCHAP_IDENTITY_SERVER_URL contient des caractères non-Unicode. Utilisation de la valeur par défaut." + ); + } + } + + // Valeur par défaut si la variable d'environnement n'est pas définie ou invalide + Url::parse("http://localhost:8083").unwrap() +} + +impl Default for TchapConfig { + fn default() -> Self { + Self { + identity_server_url: default_identity_server_url(), + } + } +} /// Result of checking if an email is allowed on a server #[derive(Debug, Clone, PartialEq, Eq)] @@ -145,7 +196,6 @@ pub fn email_to_display_name(address: &str) -> String { /// 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 /// @@ -157,9 +207,13 @@ pub fn email_to_display_name(address: &str) -> String { /// 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 { + // Get the identity server URL from the environment variable or use the default + let identity_server_url = default_identity_server_url(); + // Construct the URL with the email address let url = format!( - "http://localhost:8083/_matrix/identity/api/v1/info?medium=email&address={}", + "{}_matrix/identity/api/v1/info?medium=email&address={}", + identity_server_url, email ); From 8afb123c132b052472c31d56ab1dcb633e4323bb Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 14:50:24 +0200 Subject: [PATCH 07/28] change text --- crates/handlers/src/upstream_oauth2/link.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index e54ed9399..d36cb7889 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -40,7 +40,9 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::warn; use ulid::Ulid; +//:tchap: use tchap::{self, EmailAllowedResult}; +//:tchap: end use super::{ UpstreamSessionsCookie, @@ -436,7 +438,7 @@ pub(crate) async fn get( provider.claims_imports.email.is_required(), )? { Some(value) => { - //:tchap + //:tchap: let server_name = homeserver.homeserver(); let email_result = tchap::is_email_allowed(&value, &server_name) @@ -451,10 +453,10 @@ pub(crate) async fn get( let ctx = ErrorContext::new() .with_code("wrong_server") .with_description(format!( - "L'adresse email {} est associée à un autre serveur.", + "Votre adresse mail {} est associée à un autre serveur.", value )) - .with_details(format!("Veuillez vous connecter au serveur approprié pour cette adresse email.")) + .with_details(format!("Veuillez-vous contacter le support de Tchap support@tchap.beta.gouv.fr")) .with_language(&locale); //return error template @@ -468,10 +470,9 @@ pub(crate) async fn get( let ctx = ErrorContext::new() .with_code("invitation_missing") .with_description(format!( - "L'adresse email {} nécessite une invitation pour ce serveur.", - value + "Vous avez besoin d'une invitation pour accéder à Tchap." )) - .with_details(format!("Pour vous connecter à Tchap vous devez avoir une invitation.")) + .with_details(format!("Les partenaires externes peuvent accéder à Tchap uniquement avec une invitation d'un agent public.")) .with_language(&locale); //return error template From b9ddaf9d3e7495e6ef47f1f889613f2ef80a8e9c Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 14:51:40 +0200 Subject: [PATCH 08/28] remove unused code --- crates/tchap/src/lib.rs | 202 ---------------------------------------- 1 file changed, 202 deletions(-) diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs index 225af6dba..02dfa6eee 100644 --- a/crates/tchap/src/lib.rs +++ b/crates/tchap/src/lib.rs @@ -66,131 +66,6 @@ pub enum EmailAllowedResult { InvitationMissing, } - -/// 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)) -} - /// 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 @@ -278,80 +153,3 @@ pub async fn is_email_allowed(email: &str, server_name: &str) -> EmailAllowedRes } } } - -#[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" - ); - } -} From 845079c59938d4f0b3c8d77e2a0ed21619f0ad3a Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 14:53:17 +0200 Subject: [PATCH 09/28] lint --- crates/handlers/src/upstream_oauth2/link.rs | 20 +++++----- crates/tchap/src/lib.rs | 41 +++++++++++---------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index d36cb7889..fb53d5f41 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -439,15 +439,13 @@ pub(crate) async fn get( )? { Some(value) => { //:tchap: - let server_name = homeserver.homeserver(); - let email_result = - tchap::is_email_allowed(&value, &server_name) - .await; - + let server_name = homeserver.homeserver(); + let email_result = tchap::is_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() @@ -458,13 +456,13 @@ pub(crate) async fn get( )) .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() @@ -474,7 +472,7 @@ pub(crate) async fn get( )) .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, @@ -483,9 +481,9 @@ pub(crate) async fn get( } } //:tchap: end - + ctx.with_email(value, provider.claims_imports.email.is_forced()) - }, + } None => ctx, } }; diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs index 02dfa6eee..d63e3c8ba 100644 --- a/crates/tchap/src/lib.rs +++ b/crates/tchap/src/lib.rs @@ -2,9 +2,9 @@ extern crate tracing; use tracing::info; use reqwest; +use serde::{Deserialize, Serialize}; use std::time::Duration; use url::Url; -use serde::{Deserialize, Serialize}; /// Configuration for Tchap-specific functionality #[derive(Debug, Clone, Serialize, Deserialize)] @@ -42,7 +42,7 @@ fn default_identity_server_url() -> Url { ); } } - + // Valeur par défaut si la variable d'environnement n'est pas définie ou invalide Url::parse("http://localhost:8083").unwrap() } @@ -84,15 +84,17 @@ pub enum EmailAllowedResult { pub async fn is_email_allowed(email: &str, server_name: &str) -> EmailAllowedResult { // Get the identity server URL from the environment variable or use the default let identity_server_url = default_identity_server_url(); - + // Construct the URL with the email address let url = format!( "{}_matrix/identity/api/v1/info?medium=email&address={}", - identity_server_url, - email + identity_server_url, email + ); + + info!( + "Checking if email {} is allowed on server {}", + email, server_name ); - - info!("Checking if email {} is allowed on server {}", email, server_name); info!("Making request to identity server: {}", url); // Create a client with a timeout @@ -100,12 +102,9 @@ pub async fn is_email_allowed(email: &str, server_name: &str) -> EmailAllowedRes .timeout(Duration::from_secs(5)) .build() .unwrap_or_default(); - + // Make the HTTP request asynchronously - match client.get(&url) - .send() - .await - { + match client.get(&url).send().await { Ok(response) => { // Parse the JSON response match response.json::().await { @@ -117,35 +116,37 @@ pub async fn is_email_allowed(email: &str, server_name: &str) -> EmailAllowedRes // 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") + let requires_invite = json + .get("requires_invite") .and_then(|v| v.as_bool()) .unwrap_or(false); - - let invited = json.get("invited") + + 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!("Failed to parse JSON response: {}", err); EmailAllowedResult::WrongServer } } - }, + } Err(err) => { // Log the error and return WrongServer as a default error eprintln!("HTTP request failed: {}", err); From 43625f5dd2182ea64ef1d5ec02129ff85d2d2ece Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 16:43:46 +0200 Subject: [PATCH 10/28] clean dep --- Cargo.lock | 152 ++-------------------------------------- crates/tchap/Cargo.toml | 12 ++-- 2 files changed, 10 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c03a4333..6df04ec2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,16 +1158,6 @@ dependencies = [ "url", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.0" @@ -1886,21 +1876,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2391,22 +2366,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.11" @@ -3911,23 +3870,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nom" version = "7.1.3" @@ -4136,50 +4078,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" -[[package]] -name = "openssl-sys" -version = "0.9.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "opentelemetry" version = "0.29.1" @@ -5118,13 +5022,11 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "ipnet", "js-sys", "log", "mime", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", @@ -5136,9 +5038,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-socks", "tower", @@ -5322,7 +5222,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", + "security-framework", ] [[package]] @@ -5349,7 +5249,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" dependencies = [ - "core-foundation 0.10.0", + "core-foundation", "core-foundation-sys", "jni", "log", @@ -5358,7 +5258,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.2.0", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.59.0", @@ -5527,19 +5427,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.2.0" @@ -5547,7 +5434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" dependencies = [ "bitflags", - "core-foundation 0.10.0", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -6313,27 +6200,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "target-lexicon" version = "0.13.2" @@ -6534,16 +6400,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.2" diff --git a/crates/tchap/Cargo.toml b/crates/tchap/Cargo.toml index 0ad91288b..7d1fd299f 100644 --- a/crates/tchap/Cargo.toml +++ b/crates/tchap/Cargo.toml @@ -7,9 +7,9 @@ edition = "2024" [dependencies] -reqwest = { version = "0.12.15", features = ["json"] } -serde_json = "1.0" -tracing = "0.1" -tokio = { version = "1.44.2", features = ["time"] } -url = { version = "2.3", features = ["serde"] } -serde = { version = "1.0", features = ["derive"] } +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 From dcb8e2b095d91a917b0a5ff76daa5327dee44159 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 17:42:03 +0200 Subject: [PATCH 11/28] extract identity client --- crates/tchap/src/identity_client.rs | 43 ++++++++++++++++++++++++++ crates/tchap/src/lib.rs | 47 +++++++++++------------------ 2 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 crates/tchap/src/identity_client.rs diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs new file mode 100644 index 000000000..c6f769c5a --- /dev/null +++ b/crates/tchap/src/identity_client.rs @@ -0,0 +1,43 @@ +//! This module provides utilities for interacting with the Matrix identity server API. + +use reqwest; +use std::time::Duration; +use tracing::info; +use url::Url; + +/// Creates a client for the identity server and constructs the URL for the info endpoint +/// +/// # Parameters +/// +/// * `email`: The email address to check +/// * `server_name`: The name of the server to check against +/// * `identity_server_url`: The base URL of the identity server +/// +/// # Returns +/// +/// A tuple containing the constructed URL and the HTTP client +pub fn create_identity_client( + email: &str, + server_name: &str, + identity_server_url: Url, +) -> (String, reqwest::Client) { + // Construct the URL with the email address + let url = format!( + "{}_matrix/identity/api/v1/info?medium=email&address={}", + identity_server_url, email + ); + + info!( + "Checking if email {} is allowed on server {}", + email, server_name + ); + 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(); + + (url, client) +} diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs index d63e3c8ba..112556982 100644 --- a/crates/tchap/src/lib.rs +++ b/crates/tchap/src/lib.rs @@ -1,11 +1,11 @@ extern crate tracing; use tracing::info; -use reqwest; use serde::{Deserialize, Serialize}; -use std::time::Duration; use url::Url; +mod identity_client; + /// Configuration for Tchap-specific functionality #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TchapConfig { @@ -14,36 +14,36 @@ pub struct TchapConfig { } fn default_identity_server_url() -> Url { - // Essayer de lire la variable d'environnement TCHAP_IDENTITY_SERVER_URL + // Try to read the TCHAP_IDENTITY_SERVER_URL environment variable match std::env::var("TCHAP_IDENTITY_SERVER_URL") { Ok(url_str) => { - // Tenter de parser l'URL depuis la variable d'environnement + // Attempt to parse the URL from the environment variable match Url::parse(&url_str) { Ok(url) => { - // Succès : utiliser l'URL de la variable d'environnement + // Success: use the URL from the environment variable return url; } Err(err) => { - // Erreur de parsing : logger un avertissement et utiliser la valeur par défaut + // Parsing error: log a warning and use the default value tracing::warn!( - "La variable d'environnement TCHAP_IDENTITY_SERVER_URL contient une URL invalide : {}. Utilisation de la valeur par défaut.", + "The TCHAP_IDENTITY_SERVER_URL environment variable contains an invalid URL: {}. Using default value.", err ); } } } Err(std::env::VarError::NotPresent) => { - // Variable non définie : utiliser la valeur par défaut sans avertissement + // Variable not defined: use the default value without warning } Err(std::env::VarError::NotUnicode(_)) => { - // Variable contient des caractères non-Unicode : logger un avertissement + // Variable contains non-Unicode characters: log a warning tracing::warn!( - "La variable d'environnement TCHAP_IDENTITY_SERVER_URL contient des caractères non-Unicode. Utilisation de la valeur par défaut." + "The TCHAP_IDENTITY_SERVER_URL environment variable contains non-Unicode characters. Using default value." ); } } - - // Valeur par défaut si la variable d'environnement n'est pas définie ou invalide + + // Default value if the environment variable is not defined or invalid Url::parse("http://localhost:8083").unwrap() } @@ -84,25 +84,14 @@ pub enum EmailAllowedResult { pub async fn is_email_allowed(email: &str, server_name: &str) -> EmailAllowedResult { // Get the identity server URL from the environment variable or use the default let identity_server_url = default_identity_server_url(); - - // Construct the URL with the email address - let url = format!( - "{}_matrix/identity/api/v1/info?medium=email&address={}", - identity_server_url, email + + // Create the client and get the URL using the identity_client module + let (url, client) = identity_client::create_identity_client( + email, + server_name, + identity_server_url ); - info!( - "Checking if email {} is allowed on server {}", - email, server_name - ); - 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 match client.get(&url).send().await { Ok(response) => { From 401f7843ed6803e3376b6acdc262c8912646f4d6 Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 17:58:38 +0200 Subject: [PATCH 12/28] refactor client --- crates/tchap/src/identity_client.rs | 80 ++++++++++++++---- crates/tchap/src/lib.rs | 122 ++++++---------------------- 2 files changed, 90 insertions(+), 112 deletions(-) diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs index c6f769c5a..8efcc9413 100644 --- a/crates/tchap/src/identity_client.rs +++ b/crates/tchap/src/identity_client.rs @@ -1,36 +1,78 @@ //! This module provides utilities for interacting with the Matrix identity server API. use reqwest; +use serde::{Deserialize, Serialize}; use std::time::Duration; use tracing::info; use url::Url; -/// Creates a client for the identity server and constructs the URL for the info endpoint +/// 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:8083").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 -/// * `server_name`: The name of the server to check against -/// * `identity_server_url`: The base URL of the identity server -/// +/// * `email`: The email address to check/// /// # Returns /// -/// A tuple containing the constructed URL and the HTTP client -pub fn create_identity_client( - email: &str, - server_name: &str, - identity_server_url: Url, -) -> (String, reqwest::Client) { +/// 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/info?medium=email&address={}", identity_server_url, email ); - info!( - "Checking if email {} is allowed on server {}", - email, server_name - ); info!("Making request to identity server: {}", url); // Create a client with a timeout @@ -39,5 +81,11 @@ pub fn create_identity_client( .build() .unwrap_or_default(); - (url, client) + // Make the HTTP request asynchronously + 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 index 112556982..54d38faee 100644 --- a/crates/tchap/src/lib.rs +++ b/crates/tchap/src/lib.rs @@ -1,59 +1,9 @@ extern crate tracing; use tracing::info; -use serde::{Deserialize, Serialize}; -use url::Url; - mod identity_client; -/// 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:8083").unwrap() -} -impl Default for TchapConfig { - fn default() -> Self { - Self { - identity_server_url: default_identity_server_url(), - } - } -} /// Result of checking if an email is allowed on a server #[derive(Debug, Clone, PartialEq, Eq)] @@ -82,59 +32,39 @@ pub enum EmailAllowedResult { /// 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 { - // Get the identity server URL from the environment variable or use the default - let identity_server_url = default_identity_server_url(); - - // Create the client and get the URL using the identity_client module - let (url, client) = identity_client::create_identity_client( - email, - server_name, - identity_server_url - ); + // Query the identity server + match identity_client::query_identity_server(email).await { + Ok(json) => { + let hs = json.get("hs"); - // Make the HTTP request asynchronously - match client.get(&url).send().await { - Ok(response) => { - // Parse the JSON response - match response.json::().await { - Ok(json) => { - let hs = json.get("hs"); - - // Check if "hs" is in the response or if hs different from server_value - if !hs.is_some() || 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 "hs" is in the response or if hs different from server_name + if !hs.is_some() || hs.unwrap() != server_name { + // Email is mapped to a different server or no server at all + return EmailAllowedResult::WrongServer; + } - // 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); + info!("hs: {} ", hs.unwrap()); - let invited = json - .get("invited") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + // 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); - info!("requires_invite: {} invited: {}", requires_invite, invited); + let invited = json + .get("invited") + .and_then(|v| v.as_bool()) + .unwrap_or(false); - if requires_invite && !invited { - // Requires an invite but hasn't been invited - return EmailAllowedResult::InvitationMissing; - } + info!("requires_invite: {} invited: {}", requires_invite, invited); - // All checks passed - EmailAllowedResult::Allowed - } - Err(err) => { - // Log the error and return WrongServer as a default error - eprintln!("Failed to parse JSON response: {}", err); - EmailAllowedResult::WrongServer - } + 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 From b4fa7b3793889a67e8bdfe242b115870983b120e Mon Sep 17 00:00:00 2001 From: olivier Date: Tue, 22 Apr 2025 17:59:11 +0200 Subject: [PATCH 13/28] rust style --- crates/tchap/src/identity_client.rs | 10 ++++------ crates/tchap/src/lib.rs | 2 -- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs index 8efcc9413..f02b91549 100644 --- a/crates/tchap/src/identity_client.rs +++ b/crates/tchap/src/identity_client.rs @@ -42,7 +42,7 @@ fn default_identity_server_url() -> Url { ); } } - + // Default value if the environment variable is not defined or invalid Url::parse("http://localhost:8083").unwrap() } @@ -62,9 +62,7 @@ impl Default for TchapConfig { /// # Returns /// /// A Result containing either the JSON response or an error -pub async fn query_identity_server( - email: &str -) -> Result { +pub async fn query_identity_server(email: &str) -> Result { let identity_server_url = default_identity_server_url(); // Construct the URL with the email address @@ -83,9 +81,9 @@ pub async fn query_identity_server( // Make the HTTP request asynchronously 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 index 54d38faee..567e2de66 100644 --- a/crates/tchap/src/lib.rs +++ b/crates/tchap/src/lib.rs @@ -3,8 +3,6 @@ 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 { From e8a89beb5ae321238acdbbcdbadc299e48809c37 Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 17 Apr 2025 11:15:04 +0200 Subject: [PATCH 14/28] add filters for legacy email and legacy localpart --- Cargo.lock | 5 + crates/handlers/Cargo.toml | 1 + .../handlers/src/upstream_oauth2/template.rs | 7 + crates/tchap/Cargo.toml | 7 + crates/tchap/src/lib.rs | 208 ++++++++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 crates/tchap/Cargo.toml create mode 100644 crates/tchap/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index e4a179e53..0d0a85142 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,10 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a" +[[package]] +name = "tchap" +version = "0.1.0" + [[package]] name = "tempfile" version = "3.15.0" diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 65c7bbb6f..c2c0b8657 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -103,6 +103,7 @@ mas-router.workspace = true mas-storage.workspace = true mas-storage-pg.workspace = true mas-templates.workspace = true +tchap = { path = "../tchap" } oauth2-types.workspace = true zxcvbn = "3.1.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/tchap/Cargo.toml b/crates/tchap/Cargo.toml new file mode 100644 index 000000000..c9b5ca0de --- /dev/null +++ b/crates/tchap/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "tchap" +version = "0.1.0" +description = "Tchap-specific functionality for Matrix Authentication Service" +license = "MIT" + +[dependencies] \ No newline at end of file diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs new file mode 100644 index 000000000..67af5edd9 --- /dev/null +++ b/crates/tchap/src/lib.rs @@ -0,0 +1,208 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +//! Tchap-specific functionality for Matrix Authentication Service + + +/// 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" + ); + } +} From a43fbb613649a0d6fe03940320168da0d1e63956 Mon Sep 17 00:00:00 2001 From: olivier Date: Wed, 23 Apr 2025 11:00:16 +0200 Subject: [PATCH 15/28] fix cargo dep --- Cargo.lock | 1 - crates/handlers/Cargo.toml | 1 - crates/tchap/Cargo.toml | 8 +------- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb88f69c7..6df04ec2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3341,7 +3341,6 @@ dependencies = [ "sha2", "sqlx", "tchap", - "tchap", "thiserror 2.0.12", "time", "tokio", diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index 928aab4a0..b230af3e9 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -103,7 +103,6 @@ mas-router.workspace = true mas-storage.workspace = true mas-storage-pg.workspace = true mas-templates.workspace = true -tchap = { path = "../tchap" } oauth2-types.workspace = true zxcvbn = "3.1.0" diff --git a/crates/tchap/Cargo.toml b/crates/tchap/Cargo.toml index c195ff456..df039f2a1 100644 --- a/crates/tchap/Cargo.toml +++ b/crates/tchap/Cargo.toml @@ -3,13 +3,7 @@ name = "tchap" version = "0.1.0" description = "Tchap-specific functionality for Matrix Authentication Service" license = "MIT" - -[dependencies][package] -name = "tchap" -version = "0.1.0" -description = "Tchap-specific functionality for Matrix Authentication Service" -license = "MIT" -edition = "2024" +edition.workspace = true [dependencies] From 8c880a11a5803e16c6ed8101303ac5a9c9d41b32 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Wed, 23 Apr 2025 15:36:02 +0200 Subject: [PATCH 16/28] Update Cargo.toml --- Cargo.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 784489d60..61f30fa0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,12 @@ mas-templates = { path = "./crates/templates/", version = "=0.15.0-rc.0" } 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 = { path = "./crates/tchap", version = "=0.1.0" } + +# :tchap: +[workspace.dependencies.tchap] +path = "./crates/tchap" +version = "=0.1.0" +# :tchap:end # OpenAPI schema generation and validation [workspace.dependencies.aide] From e3ac1fdb86c04798779d31c1efd77238d76ce664 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Wed, 23 Apr 2025 16:00:48 +0200 Subject: [PATCH 17/28] Update Cargo.toml --- Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 61f30fa0e..e6abe9410 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,9 +57,9 @@ 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" +# [workspace.dependencies.tchap] +# path = "./crates/tchap" +# version = "=0.1.0" # :tchap:end # OpenAPI schema generation and validation From 125d86e7fcdfce397d628a01d47a05d78619c7c2 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Wed, 23 Apr 2025 16:01:14 +0200 Subject: [PATCH 18/28] Update Cargo.toml --- crates/handlers/Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index b230af3e9..5553b5020 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -106,7 +106,10 @@ mas-templates.workspace = true oauth2-types.workspace = true zxcvbn = "3.1.0" -tchap.workspace = true +# tchap.workspace = true +#:tchap: +tchap = { path = "../tchap", version = "=0.1.0" } +#:tchap:end [dev-dependencies] insta.workspace = true From d362f97b3eeed05ee631f91d07b9a757191b01ba Mon Sep 17 00:00:00 2001 From: Olivier D Date: Wed, 23 Apr 2025 16:48:55 +0200 Subject: [PATCH 19/28] Update build.yaml --- .github/workflows/build.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 90a1d8cc8..64e12f895 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,6 +5,7 @@ on: branches: - main_tchap - "release/**" + - "tests/**" tags: - "v*" From 9d6509e6115bdb2c5de5ce54098f6989257f4da7 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Wed, 23 Apr 2025 16:52:07 +0200 Subject: [PATCH 20/28] Update build.yaml --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 64e12f895..2c409cf60 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,7 +5,7 @@ on: branches: - main_tchap - "release/**" - - "tests/**" + - "test/**" tags: - "v*" From 3580c4701644eef0fbd8ba75edf02263a2f44da0 Mon Sep 17 00:00:00 2001 From: Olivier D Date: Thu, 24 Apr 2025 09:46:11 +0200 Subject: [PATCH 21/28] Update Cargo.toml test --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index e6abe9410..3b1e2832c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ syn2mas = { path = "./crates/syn2mas", version = "=0.15.0-rc.0" } # version = "=0.1.0" # :tchap:end + # OpenAPI schema generation and validation [workspace.dependencies.aide] version = "0.14.2" From 8388dcc3e07ba07a3cb763807b779e0b631af96a Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 24 Apr 2025 10:54:29 +0200 Subject: [PATCH 22/28] fix unit test --- crates/handlers/src/upstream_oauth2/link.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index fb53d5f41..8b2ceb86d 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -440,7 +440,7 @@ pub(crate) async fn get( Some(value) => { //:tchap: let server_name = homeserver.homeserver(); - let email_result = tchap::is_email_allowed(&value, &server_name).await; + let email_result = check_email_allowed(&value, &server_name).await; match email_result { EmailAllowedResult::Allowed => { @@ -938,6 +938,16 @@ pub(crate) async fn post( Ok((cookie_jar, post_auth_action.go_next(&url_builder)).into_response()) } +#[cfg(not(test))] +async fn check_email_allowed(email: &str, server_name: &str) -> EmailAllowedResult { + tchap::is_email_allowed(email, server_name).await +} + +#[cfg(test)] +async fn check_email_allowed(_email: &str, _server_name: &str) -> EmailAllowedResult { + EmailAllowedResult::Allowed +} + #[cfg(test)] mod tests { use hyper::{Request, StatusCode, header::CONTENT_TYPE}; From bb846dab81d5abcb1a933f5c488ae9ff70efbf14 Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 24 Apr 2025 10:58:06 +0200 Subject: [PATCH 23/28] add tchap tag --- crates/handlers/src/upstream_oauth2/link.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 8b2ceb86d..39b0cca4f 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -938,15 +938,18 @@ 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 { From 1894c19a42f2edfee9a30e21c25dce1676357aba Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 24 Apr 2025 11:32:18 +0200 Subject: [PATCH 24/28] rust style --- crates/handlers/src/upstream_oauth2/link.rs | 6 +++--- crates/tchap/src/identity_client.rs | 6 ++++-- crates/tchap/src/lib.rs | 9 +++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/handlers/src/upstream_oauth2/link.rs b/crates/handlers/src/upstream_oauth2/link.rs index 39b0cca4f..f3c58960d 100644 --- a/crates/handlers/src/upstream_oauth2/link.rs +++ b/crates/handlers/src/upstream_oauth2/link.rs @@ -37,13 +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: -use tchap::{self, EmailAllowedResult}; -//:tchap: end +//:tchap: end use super::{ UpstreamSessionsCookie, template::{AttributeMappingContext, environment}, diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs index f02b91549..b3ae059be 100644 --- a/crates/tchap/src/identity_client.rs +++ b/crates/tchap/src/identity_client.rs @@ -1,8 +1,10 @@ -//! This module provides utilities for interacting with the Matrix identity server API. +//! This module provides utilities for interacting with the Matrix identity +//! server API. + +use std::time::Duration; use reqwest; use serde::{Deserialize, Serialize}; -use std::time::Duration; use tracing::info; use url::Url; diff --git a/crates/tchap/src/lib.rs b/crates/tchap/src/lib.rs index 567e2de66..8e49cb6a5 100644 --- a/crates/tchap/src/lib.rs +++ b/crates/tchap/src/lib.rs @@ -16,9 +16,9 @@ pub enum EmailAllowedResult { /// 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. +/// 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 /// @@ -27,7 +27,8 @@ pub enum EmailAllowedResult { /// /// # Returns /// -/// An `EmailAllowedResult` indicating whether the email is allowed and if not, why +/// 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 From 81597f1a4d9b7d3ec174f2223bda3a1e4b0da7f2 Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 24 Apr 2025 12:46:16 +0200 Subject: [PATCH 25/28] fix clippy --- crates/tchap/src/identity_client.rs | 3 ++- crates/tchap/src/lib.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs index b3ae059be..41152e991 100644 --- a/crates/tchap/src/identity_client.rs +++ b/crates/tchap/src/identity_client.rs @@ -3,7 +3,6 @@ use std::time::Duration; -use reqwest; use serde::{Deserialize, Serialize}; use tracing::info; use url::Url; @@ -81,7 +80,9 @@ pub async fn query_identity_server(email: &str) -> Result EmailAllowedRes let hs = json.get("hs"); // Check if "hs" is in the response or if hs different from server_name - if !hs.is_some() || hs.unwrap() != 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; } From c6ce9dcab05d4ae824210935af2ed926b73bb36e Mon Sep 17 00:00:00 2001 From: olivier Date: Thu, 24 Apr 2025 14:17:35 +0200 Subject: [PATCH 26/28] add comment --- crates/tchap/src/identity_client.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs index 41152e991..dfba5c985 100644 --- a/crates/tchap/src/identity_client.rs +++ b/crates/tchap/src/identity_client.rs @@ -82,6 +82,7 @@ pub async fn query_identity_server(email: &str) -> Result Date: Thu, 24 Apr 2025 15:00:36 +0200 Subject: [PATCH 27/28] use internal API internal-info --- crates/tchap/src/identity_client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs index dfba5c985..c99219c86 100644 --- a/crates/tchap/src/identity_client.rs +++ b/crates/tchap/src/identity_client.rs @@ -68,7 +68,7 @@ pub async fn query_identity_server(email: &str) -> Result Date: Tue, 13 May 2025 11:11:58 +0200 Subject: [PATCH 28/28] correct default identity server port --- crates/tchap/src/identity_client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tchap/src/identity_client.rs b/crates/tchap/src/identity_client.rs index c99219c86..9cf6f9700 100644 --- a/crates/tchap/src/identity_client.rs +++ b/crates/tchap/src/identity_client.rs @@ -45,7 +45,7 @@ fn default_identity_server_url() -> Url { } // Default value if the environment variable is not defined or invalid - Url::parse("http://localhost:8083").unwrap() + Url::parse("http://localhost:8090").unwrap() } impl Default for TchapConfig {