diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 4d610482f..6032b7e07 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -203,6 +203,7 @@ where Encrypter: FromRef, reqwest::Client: FromRef, SiteConfig: FromRef, + Templates: FromRef, Arc: FromRef, BoxClock: FromRequestParts, BoxRng: FromRequestParts, diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index 08cfcb1d4..3c8c9db20 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -18,6 +18,7 @@ use mas_axum_utils::{ use mas_data_model::{ AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, SiteConfig, TokenType, }; +use mas_i18n::DataLocale; use mas_keystore::{Encrypter, Keystore}; use mas_matrix::HomeserverConnection; use mas_oidc_client::types::scope::ScopeToken; @@ -31,6 +32,7 @@ use mas_storage::{ }, user::BrowserSessionRepository, }; +use mas_templates::{DeviceNameContext, TemplateContext, Templates}; use oauth2_types::{ errors::{ClientError, ClientErrorCode}, pkce::CodeChallengeError, @@ -261,6 +263,8 @@ impl IntoResponse for RouteError { } } +impl_from_error_for_route!(mas_i18n::DataError); +impl_from_error_for_route!(mas_templates::TemplateError); impl_from_error_for_route!(mas_storage::RepositoryError); impl_from_error_for_route!(mas_policy::EvaluationError); impl_from_error_for_route!(super::IdTokenSignatureError); @@ -281,6 +285,7 @@ pub(crate) async fn post( State(homeserver): State>, State(site_config): State, State(encrypter): State, + State(templates): State, policy: Policy, user_agent: Option>, client_authorization: ClientAuthorization, @@ -334,6 +339,7 @@ pub(crate) async fn post( &site_config, repo, &homeserver, + &templates, user_agent, ) .await? @@ -415,6 +421,7 @@ async fn authorization_code_grant( site_config: &SiteConfig, mut repo: BoxRepository, homeserver: &Arc, + templates: &Templates, user_agent: Option, ) -> Result<(AccessTokenResponse, BoxRepository), RouteError> { // Check that the client is allowed to use this grant type @@ -482,6 +489,11 @@ async fn authorization_code_grant( .await? .ok_or(RouteError::NoSuchOAuthSession(session_id))?; + // Generate a device name + let lang: DataLocale = authz_grant.locale.as_deref().unwrap_or("en").parse()?; + let ctx = DeviceNameContext::new(client.clone(), user_agent.clone()).with_language(lang); + let device_name = templates.render_device_name(&ctx)?; + if let Some(user_agent) = user_agent { session = repo .oauth2_session() @@ -567,7 +579,7 @@ async fn authorization_code_grant( for scope in &*session.scope { if let Some(device) = Device::from_scope_token(scope) { homeserver - .create_device(&mxid, device.as_str(), None) + .create_device(&mxid, device.as_str(), Some(&device_name)) .await .map_err(RouteError::ProvisionDeviceFailed)?; } diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 200b5e1ff..44fb06a5e 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -11,7 +11,7 @@ mod translator; pub use icu_calendar; pub use icu_datetime; pub use icu_locid::locale; -pub use icu_provider::DataLocale; +pub use icu_provider::{DataError, DataLocale}; pub use self::{ sprintf::{Argument, ArgumentList, Message}, diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index e1ca20069..345c8bf01 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1564,6 +1564,39 @@ impl TemplateContext for AccountInactiveContext { } } +/// Context used by the `device_name.txt` template +#[derive(Serialize)] +pub struct DeviceNameContext { + client: Client, + raw_user_agent: String, +} + +impl DeviceNameContext { + /// Constructs a new context with a client and user agent + #[must_use] + pub fn new(client: Client, user_agent: Option) -> Self { + Self { + client, + raw_user_agent: user_agent.unwrap_or_default(), + } + } +} + +impl TemplateContext for DeviceNameContext { + fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + Client::samples(now, rng) + .into_iter() + .map(|client| DeviceNameContext { + client, + raw_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned(), + }) + .collect() + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 4c021f87f..c5d0f05e6 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -35,13 +35,13 @@ mod macros; pub use self::{ context::{ AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext, - DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext, - EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, - LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext, - PolicyViolationContext, PostAuthContext, PostAuthContextInner, RecoveryExpiredContext, - RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, - RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, - RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, + DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext, + EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext, + FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, + PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner, + RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, + RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, + RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext, RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, @@ -417,6 +417,9 @@ register_templates! { /// Render the 'account logged out' page pub fn render_account_logged_out(WithLanguage>) { "pages/account/logged_out.html" } + + /// Render the automatic device name for OAuth 2.0 client + pub fn render_device_name(WithLanguage) { "device_name.txt" } } impl Templates { @@ -459,6 +462,7 @@ impl Templates { check::render_upstream_oauth2_link_mismatch(self, now, rng)?; check::render_upstream_oauth2_suggest_link(self, now, rng)?; check::render_upstream_oauth2_do_register(self, now, rng)?; + check::render_device_name(self, now, rng)?; Ok(()) } } diff --git a/templates/device_name.txt b/templates/device_name.txt new file mode 100644 index 000000000..2c5e0b16f --- /dev/null +++ b/templates/device_name.txt @@ -0,0 +1,28 @@ +{# +Copyright 2024, 2025 New Vector Ltd. +Copyright 2021-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. +-#} + +{%- set _ = translator(lang) -%} + +{%- set client_name = client.client_name or client.client_id -%} +{%- set user_agent = raw_user_agent | parse_user_agent() -%} + +{%- set device_name -%} + {%- if user_agent.model -%} + {{- user_agent.model -}} + {%- elif user_agent.name -%} + {%- if user_agent.os -%} + {{- _("mas.device_display_name.name_for_platform", name=user_agent.name, platform=user_agent.os) -}} + {%- else -%} + {{- user_agent.name -}} + {%- endif -%} + {%- else -%} + {{- _("mas.device_display_name.unknown_device") -}} + {%- endif -%} +{%- endset -%} + +{{- _("mas.device_display_name.client_on_device", client_name=client_name, device_name=device_name) -}} diff --git a/translations/en.json b/translations/en.json index 8c4a76e1c..5b2a5ad04 100644 --- a/translations/en.json +++ b/translations/en.json @@ -6,11 +6,11 @@ }, "cancel": "Cancel", "@cancel": { - "context": "pages/consent.html:69:11-29, pages/device_consent.html:126:13-31, pages/policy_violation.html:44:13-31" + "context": "pages/consent.html:69:11-29, pages/device_consent.html:127:13-31, pages/policy_violation.html:44:13-31" }, "continue": "Continue", "@continue": { - "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" + "context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48" }, "create_account": "Create Account", "@create_account": { @@ -22,7 +22,7 @@ }, "sign_out": "Sign out", "@sign_out": { - "context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" + "context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:136:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46" }, "skip": "Skip", "@skip": { @@ -195,37 +195,37 @@ }, "heading": "Allow access to your account?", "@heading": { - "context": "pages/consent.html:25:27-51, pages/device_consent.html:27:29-53" + "context": "pages/consent.html:25:27-51, pages/device_consent.html:28:29-53" }, "make_sure_you_trust": "Make sure that you trust %(client_name)s.", "@make_sure_you_trust": { - "context": "pages/consent.html:38:81-142, pages/device_consent.html:103:83-144" + "context": "pages/consent.html:38:81-142, pages/device_consent.html:104:83-144" }, "this_will_allow": "This will allow %(client_name)s to:", "@this_will_allow": { - "context": "pages/consent.html:28:11-68, pages/device_consent.html:93:13-70" + "context": "pages/consent.html:28:11-68, pages/device_consent.html:94:13-70" }, "you_may_be_sharing": "You may be sharing sensitive information with this site or app.", "@you_may_be_sharing": { - "context": "pages/consent.html:39:7-42, pages/device_consent.html:104:9-44" + "context": "pages/consent.html:39:7-42, pages/device_consent.html:105:9-44" } }, "device_card": { "access_requested": "Access requested", "@access_requested": { - "context": "pages/device_consent.html:81:34-71" + "context": "pages/device_consent.html:82:34-71" }, "device_code": "Code", "@device_code": { - "context": "pages/device_consent.html:85:34-66" + "context": "pages/device_consent.html:86:34-66" }, "generic_device": "Device", "@generic_device": { - "context": "pages/device_consent.html:69:22-57" + "context": "pages/device_consent.html:70:22-57" }, "ip_address": "IP address", "@ip_address": { - "context": "pages/device_consent.html:76:36-67" + "context": "pages/device_consent.html:77:36-67" } }, "device_code_link": { @@ -241,29 +241,45 @@ "device_consent": { "another_device_access": "Another device wants to access your account.", "@another_device_access": { - "context": "pages/device_consent.html:92:13-58" + "context": "pages/device_consent.html:93:13-58" }, "denied": { "description": "You denied access to %(client_name)s. You can close this window.", "@description": { - "context": "pages/device_consent.html:146:27-94" + "context": "pages/device_consent.html:147:27-94" }, "heading": "Access denied", "@heading": { - "context": "pages/device_consent.html:145:29-67" + "context": "pages/device_consent.html:146:29-67" } }, "granted": { "description": "You granted access to %(client_name)s. You can close this window.", "@description": { - "context": "pages/device_consent.html:157:27-95" + "context": "pages/device_consent.html:158:27-95" }, "heading": "Access granted", "@heading": { - "context": "pages/device_consent.html:156:29-68" + "context": "pages/device_consent.html:157:29-68" } } }, + "device_display_name": { + "client_on_device": "%(client_name)s on %(device_name)s", + "@client_on_device": { + "context": "device_name.txt:28:4-99", + "description": "The automatic device name generated for a client, e.g. 'Element on iPhone'" + }, + "name_for_platform": "%(name)s for %(platform)s", + "@name_for_platform": { + "context": "device_name.txt:19:10-102", + "description": "Part of the automatic device name for the platfom, e.g. 'Safari for macOS'" + }, + "unknown_device": "Unknown device", + "@unknown_device": { + "context": "device_name.txt:24:8-51" + } + }, "email_in_use": { "description": "If you have forgotten your account credentials, you can recover your account. You can also start over and use a different email address.", "@description": { @@ -469,7 +485,7 @@ }, "not_you": "Not %(username)s?", "@not_you": { - "context": "pages/consent.html:62:11-67, pages/device_consent.html:132:13-69, pages/sso.html:42:11-67", + "context": "pages/consent.html:62:11-67, pages/device_consent.html:133:13-69, pages/sso.html:42:11-67", "description": "Suggestions for the user to log in as a different user" }, "or_separator": "Or",