From 401b1e299b6d5d30bd0b1261021026c741e30bcb Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 23 Jun 2026 09:44:23 +0200 Subject: [PATCH 1/2] Persist the browser locale on the device code grant Capture the preferred language from the browser view which accepts the device code grant and store it on the grant, so it can later be used to render a human-readable device name. --- crates/data-model/src/oauth2/device_code_grant.rs | 7 +++++++ crates/handlers/src/oauth2/device/consent.rs | 2 +- crates/handlers/src/oauth2/token.rs | 2 +- ...88937df017289d5cabe62ad00b115a2a408f4936b.json} | 10 ++++++++-- ...c4d83f8fd8f85cce92c46b40afbcf1e48e7c80b2a.json} | 5 +++-- ...dfefe658aa71cade3ee70d17ee09a334599edc561.json} | 10 ++++++++-- ...0ae9ba64031c0e635e5b40684a26565d50b3905f2.json} | 10 ++++++++-- ...60623100001_oauth2_device_code_grant_locale.sql | 8 ++++++++ crates/storage-pg/src/oauth2/device_code_grant.rs | 14 ++++++++++++-- crates/storage-pg/src/oauth2/mod.rs | 12 ++++++++---- crates/storage/src/oauth2/device_code_grant.rs | 4 ++++ crates/templates/src/context.rs | 2 ++ 12 files changed, 70 insertions(+), 16 deletions(-) rename crates/storage-pg/.sqlx/{query-75a62d170e4c959a14c5698f1da983113e7d1bc565d01e85c158856abb17ddc6.json => query-89674770c8d7929382e266a88937df017289d5cabe62ad00b115a2a408f4936b.json} (87%) rename crates/storage-pg/.sqlx/{query-d26e42d9fd2b2ee3cf9702c1666d83e7cffa26b320ae1442c7f3e22376c4a4ee.json => query-9aa97bc9530456bb3044f84c4d83f8fd8f85cce92c46b40afbcf1e48e7c80b2a.json} (60%) rename crates/storage-pg/.sqlx/{query-bb141d28c0c82244f31d542038c314d05ceb3a7b8f35397c0faef3b36d2d14a7.json => query-ca8db2854f873a17f0ead6adfefe658aa71cade3ee70d17ee09a334599edc561.json} (87%) rename crates/storage-pg/.sqlx/{query-89041298e272d15c21e2b7127bd16c5a4f48e2be87dc26e9d0e3a932c9c49dfb.json => query-e0dd18421e3b19079f291ba0ae9ba64031c0e635e5b40684a26565d50b3905f2.json} (87%) create mode 100644 crates/storage-pg/migrations/20260623100001_oauth2_device_code_grant_locale.sql diff --git a/crates/data-model/src/oauth2/device_code_grant.rs b/crates/data-model/src/oauth2/device_code_grant.rs index aaf7df594..d35e42470 100644 --- a/crates/data-model/src/oauth2/device_code_grant.rs +++ b/crates/data-model/src/oauth2/device_code_grant.rs @@ -193,6 +193,11 @@ pub struct DeviceCodeGrant { /// The user agent used to request this device code grant. pub user_agent: Option, + + /// The locale detected from the browser which fulfilled this device code + /// grant. Used to render a human-readable device name. [`None`] until the + /// grant is fulfilled. + pub locale: Option, } impl std::ops::Deref for DeviceCodeGrant { @@ -215,10 +220,12 @@ impl DeviceCodeGrant { pub fn fulfill( self, browser_session: &BrowserSession, + locale: Option, fulfilled_at: DateTime, ) -> Result { Ok(Self { state: self.state.fulfill(browser_session, fulfilled_at)?, + locale, ..self }) } diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs index 4799d503f..78f61f1c6 100644 --- a/crates/handlers/src/oauth2/device/consent.rs +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -305,7 +305,7 @@ pub(crate) async fn post( } repo.oauth2_device_code_grant() - .fulfill(&clock, grant, &session) + .fulfill(&clock, grant, &session, Some(locale.to_string())) .await? } Action::Reject => { diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index e93cad8ba..ebc46b660 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -1787,7 +1787,7 @@ mod tests { // And fulfill it let grant = repo .oauth2_device_code_grant() - .fulfill(&state.clock, grant, &browser_session) + .fulfill(&state.clock, grant, &browser_session, Some("en".to_owned())) .await .unwrap(); diff --git a/crates/storage-pg/.sqlx/query-75a62d170e4c959a14c5698f1da983113e7d1bc565d01e85c158856abb17ddc6.json b/crates/storage-pg/.sqlx/query-89674770c8d7929382e266a88937df017289d5cabe62ad00b115a2a408f4936b.json similarity index 87% rename from crates/storage-pg/.sqlx/query-75a62d170e4c959a14c5698f1da983113e7d1bc565d01e85c158856abb17ddc6.json rename to crates/storage-pg/.sqlx/query-89674770c8d7929382e266a88937df017289d5cabe62ad00b115a2a408f4936b.json index 1a1320b7a..40ffa9182 100644 --- a/crates/storage-pg/.sqlx/query-75a62d170e4c959a14c5698f1da983113e7d1bc565d01e85c158856abb17ddc6.json +++ b/crates/storage-pg/.sqlx/query-89674770c8d7929382e266a88937df017289d5cabe62ad00b115a2a408f4936b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_device_code_grant_id\n , oauth2_client_id\n , scope\n , device_code\n , user_code\n , created_at\n , expires_at\n , fulfilled_at\n , rejected_at\n , exchanged_at\n , user_session_id\n , oauth2_session_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n FROM\n oauth2_device_code_grant\n\n WHERE device_code = $1\n ", + "query": "\n SELECT oauth2_device_code_grant_id\n , oauth2_client_id\n , scope\n , device_code\n , user_code\n , created_at\n , expires_at\n , fulfilled_at\n , rejected_at\n , exchanged_at\n , user_session_id\n , oauth2_session_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , locale\n FROM\n oauth2_device_code_grant\n\n WHERE user_code = $1\n ", "describe": { "columns": [ { @@ -72,6 +72,11 @@ "ordinal": 13, "name": "user_agent", "type_info": "Text" + }, + { + "ordinal": 14, + "name": "locale", + "type_info": "Text" } ], "parameters": { @@ -93,8 +98,9 @@ true, true, true, + true, true ] }, - "hash": "75a62d170e4c959a14c5698f1da983113e7d1bc565d01e85c158856abb17ddc6" + "hash": "89674770c8d7929382e266a88937df017289d5cabe62ad00b115a2a408f4936b" } diff --git a/crates/storage-pg/.sqlx/query-d26e42d9fd2b2ee3cf9702c1666d83e7cffa26b320ae1442c7f3e22376c4a4ee.json b/crates/storage-pg/.sqlx/query-9aa97bc9530456bb3044f84c4d83f8fd8f85cce92c46b40afbcf1e48e7c80b2a.json similarity index 60% rename from crates/storage-pg/.sqlx/query-d26e42d9fd2b2ee3cf9702c1666d83e7cffa26b320ae1442c7f3e22376c4a4ee.json rename to crates/storage-pg/.sqlx/query-9aa97bc9530456bb3044f84c4d83f8fd8f85cce92c46b40afbcf1e48e7c80b2a.json index c607483c4..f7fc43e33 100644 --- a/crates/storage-pg/.sqlx/query-d26e42d9fd2b2ee3cf9702c1666d83e7cffa26b320ae1442c7f3e22376c4a4ee.json +++ b/crates/storage-pg/.sqlx/query-9aa97bc9530456bb3044f84c4d83f8fd8f85cce92c46b40afbcf1e48e7c80b2a.json @@ -1,16 +1,17 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE oauth2_device_code_grant\n SET fulfilled_at = $1\n , user_session_id = $2\n WHERE oauth2_device_code_grant_id = $3\n ", + "query": "\n UPDATE oauth2_device_code_grant\n SET fulfilled_at = $1\n , user_session_id = $2\n , locale = $3\n WHERE oauth2_device_code_grant_id = $4\n ", "describe": { "columns": [], "parameters": { "Left": [ "Timestamptz", "Uuid", + "Text", "Uuid" ] }, "nullable": [] }, - "hash": "d26e42d9fd2b2ee3cf9702c1666d83e7cffa26b320ae1442c7f3e22376c4a4ee" + "hash": "9aa97bc9530456bb3044f84c4d83f8fd8f85cce92c46b40afbcf1e48e7c80b2a" } diff --git a/crates/storage-pg/.sqlx/query-bb141d28c0c82244f31d542038c314d05ceb3a7b8f35397c0faef3b36d2d14a7.json b/crates/storage-pg/.sqlx/query-ca8db2854f873a17f0ead6adfefe658aa71cade3ee70d17ee09a334599edc561.json similarity index 87% rename from crates/storage-pg/.sqlx/query-bb141d28c0c82244f31d542038c314d05ceb3a7b8f35397c0faef3b36d2d14a7.json rename to crates/storage-pg/.sqlx/query-ca8db2854f873a17f0ead6adfefe658aa71cade3ee70d17ee09a334599edc561.json index fc966587e..d4a01a203 100644 --- a/crates/storage-pg/.sqlx/query-bb141d28c0c82244f31d542038c314d05ceb3a7b8f35397c0faef3b36d2d14a7.json +++ b/crates/storage-pg/.sqlx/query-ca8db2854f873a17f0ead6adfefe658aa71cade3ee70d17ee09a334599edc561.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_device_code_grant_id\n , oauth2_client_id\n , scope\n , device_code\n , user_code\n , created_at\n , expires_at\n , fulfilled_at\n , rejected_at\n , exchanged_at\n , user_session_id\n , oauth2_session_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n FROM\n oauth2_device_code_grant\n\n WHERE user_code = $1\n ", + "query": "\n SELECT oauth2_device_code_grant_id\n , oauth2_client_id\n , scope\n , device_code\n , user_code\n , created_at\n , expires_at\n , fulfilled_at\n , rejected_at\n , exchanged_at\n , user_session_id\n , oauth2_session_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , locale\n FROM\n oauth2_device_code_grant\n\n WHERE device_code = $1\n ", "describe": { "columns": [ { @@ -72,6 +72,11 @@ "ordinal": 13, "name": "user_agent", "type_info": "Text" + }, + { + "ordinal": 14, + "name": "locale", + "type_info": "Text" } ], "parameters": { @@ -93,8 +98,9 @@ true, true, true, + true, true ] }, - "hash": "bb141d28c0c82244f31d542038c314d05ceb3a7b8f35397c0faef3b36d2d14a7" + "hash": "ca8db2854f873a17f0ead6adfefe658aa71cade3ee70d17ee09a334599edc561" } diff --git a/crates/storage-pg/.sqlx/query-89041298e272d15c21e2b7127bd16c5a4f48e2be87dc26e9d0e3a932c9c49dfb.json b/crates/storage-pg/.sqlx/query-e0dd18421e3b19079f291ba0ae9ba64031c0e635e5b40684a26565d50b3905f2.json similarity index 87% rename from crates/storage-pg/.sqlx/query-89041298e272d15c21e2b7127bd16c5a4f48e2be87dc26e9d0e3a932c9c49dfb.json rename to crates/storage-pg/.sqlx/query-e0dd18421e3b19079f291ba0ae9ba64031c0e635e5b40684a26565d50b3905f2.json index 4b11059e9..c785d1083 100644 --- a/crates/storage-pg/.sqlx/query-89041298e272d15c21e2b7127bd16c5a4f48e2be87dc26e9d0e3a932c9c49dfb.json +++ b/crates/storage-pg/.sqlx/query-e0dd18421e3b19079f291ba0ae9ba64031c0e635e5b40684a26565d50b3905f2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT oauth2_device_code_grant_id\n , oauth2_client_id\n , scope\n , device_code\n , user_code\n , created_at\n , expires_at\n , fulfilled_at\n , rejected_at\n , exchanged_at\n , user_session_id\n , oauth2_session_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n FROM\n oauth2_device_code_grant\n\n WHERE oauth2_device_code_grant_id = $1\n ", + "query": "\n SELECT oauth2_device_code_grant_id\n , oauth2_client_id\n , scope\n , device_code\n , user_code\n , created_at\n , expires_at\n , fulfilled_at\n , rejected_at\n , exchanged_at\n , user_session_id\n , oauth2_session_id\n , ip_address as \"ip_address: IpAddr\"\n , user_agent\n , locale\n FROM\n oauth2_device_code_grant\n\n WHERE oauth2_device_code_grant_id = $1\n ", "describe": { "columns": [ { @@ -72,6 +72,11 @@ "ordinal": 13, "name": "user_agent", "type_info": "Text" + }, + { + "ordinal": 14, + "name": "locale", + "type_info": "Text" } ], "parameters": { @@ -93,8 +98,9 @@ true, true, true, + true, true ] }, - "hash": "89041298e272d15c21e2b7127bd16c5a4f48e2be87dc26e9d0e3a932c9c49dfb" + "hash": "e0dd18421e3b19079f291ba0ae9ba64031c0e635e5b40684a26565d50b3905f2" } diff --git a/crates/storage-pg/migrations/20260623100001_oauth2_device_code_grant_locale.sql b/crates/storage-pg/migrations/20260623100001_oauth2_device_code_grant_locale.sql new file mode 100644 index 000000000..344c9ab92 --- /dev/null +++ b/crates/storage-pg/migrations/20260623100001_oauth2_device_code_grant_locale.sql @@ -0,0 +1,8 @@ +-- Copyright 2026 Element Creations Ltd. +-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +-- Please see LICENSE files in the repository root for full details. + +-- Stores the locale detected from the browser which fulfilled the device code +-- grant, so that the token endpoint can render a human-readable device name. +ALTER TABLE "oauth2_device_code_grant" + ADD COLUMN "locale" TEXT; diff --git a/crates/storage-pg/src/oauth2/device_code_grant.rs b/crates/storage-pg/src/oauth2/device_code_grant.rs index 9d2767bdb..2ac61d68a 100644 --- a/crates/storage-pg/src/oauth2/device_code_grant.rs +++ b/crates/storage-pg/src/oauth2/device_code_grant.rs @@ -48,6 +48,7 @@ struct OAuth2DeviceGrantLookup { oauth2_session_id: Option, ip_address: Option, user_agent: Option, + locale: Option, } impl TryFrom for DeviceCodeGrant { @@ -69,6 +70,7 @@ impl TryFrom for DeviceCodeGrant { oauth2_session_id, ip_address, user_agent, + locale, }: OAuth2DeviceGrantLookup, ) -> Result { let id = Ulid::from(oauth2_device_code_grant_id); @@ -131,6 +133,7 @@ impl TryFrom for DeviceCodeGrant { expires_at, ip_address, user_agent, + locale, }) } } @@ -205,6 +208,7 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { expires_at, ip_address: params.ip_address, user_agent: params.user_agent, + locale: None, }) } @@ -235,6 +239,7 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { , oauth2_session_id , ip_address as "ip_address: IpAddr" , user_agent + , locale FROM oauth2_device_code_grant @@ -281,6 +286,7 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { , oauth2_session_id , ip_address as "ip_address: IpAddr" , user_agent + , locale FROM oauth2_device_code_grant @@ -327,6 +333,7 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { , oauth2_session_id , ip_address as "ip_address: IpAddr" , user_agent + , locale FROM oauth2_device_code_grant @@ -360,10 +367,11 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { clock: &dyn Clock, device_code_grant: DeviceCodeGrant, browser_session: &BrowserSession, + locale: Option, ) -> Result { let fulfilled_at = clock.now(); let device_code_grant = device_code_grant - .fulfill(browser_session, fulfilled_at) + .fulfill(browser_session, locale, fulfilled_at) .map_err(DatabaseError::to_invalid_operation)?; let res = sqlx::query!( @@ -371,10 +379,12 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { UPDATE oauth2_device_code_grant SET fulfilled_at = $1 , user_session_id = $2 - WHERE oauth2_device_code_grant_id = $3 + , locale = $3 + WHERE oauth2_device_code_grant_id = $4 "#, fulfilled_at, Uuid::from(browser_session.id), + device_code_grant.locale.as_deref(), Uuid::from(device_code_grant.id), ) .traced() diff --git a/crates/storage-pg/src/oauth2/mod.rs b/crates/storage-pg/src/oauth2/mod.rs index 46b8acac2..8efa12dc8 100644 --- a/crates/storage-pg/src/oauth2/mod.rs +++ b/crates/storage-pg/src/oauth2/mod.rs @@ -1055,14 +1055,15 @@ mod tests { .unwrap(); assert_eq!(lookup.as_ref(), Some(&grant)); - // Let's mark it as fulfilled + // Let's mark it as fulfilled, with a locale captured from the browser let grant = repo .oauth2_device_code_grant() - .fulfill(&clock, grant, &browser_session) + .fulfill(&clock, grant, &browser_session, Some("en".to_owned())) .await .unwrap(); assert!(!grant.is_pending()); assert!(grant.is_fulfilled()); + assert_eq!(grant.locale.as_deref(), Some("en")); // Check that we can't mark it as rejected now let res = repo @@ -1079,10 +1080,13 @@ mod tests { .unwrap() .unwrap(); + // The locale was persisted + assert_eq!(grant.locale.as_deref(), Some("en")); + // We can't mark it as fulfilled again let res = repo .oauth2_device_code_grant() - .fulfill(&clock, grant, &browser_session) + .fulfill(&clock, grant, &browser_session, None) .await; assert!(res.is_err()); @@ -1166,7 +1170,7 @@ mod tests { // We can't mark it as fulfilled let res = repo .oauth2_device_code_grant() - .fulfill(&clock, grant, &browser_session) + .fulfill(&clock, grant, &browser_session, None) .await; assert!(res.is_err()); diff --git a/crates/storage/src/oauth2/device_code_grant.rs b/crates/storage/src/oauth2/device_code_grant.rs index 7b8915cb6..220a7fb1e 100644 --- a/crates/storage/src/oauth2/device_code_grant.rs +++ b/crates/storage/src/oauth2/device_code_grant.rs @@ -123,6 +123,8 @@ pub trait OAuth2DeviceCodeGrantRepository: Send + Sync { /// * `device_code_grant`: The device code grant to fulfill /// * `browser_session`: The browser session which was used to fulfill the /// device code grant + /// * `locale`: The locale detected from the browser which fulfilled the + /// grant, used later to render a human-readable device name /// /// # Errors /// @@ -135,6 +137,7 @@ pub trait OAuth2DeviceCodeGrantRepository: Send + Sync { clock: &dyn Clock, device_code_grant: DeviceCodeGrant, browser_session: &BrowserSession, + locale: Option, ) -> Result; /// Mark the device code grant as rejected with the given browser session @@ -235,6 +238,7 @@ repository_impl!(OAuth2DeviceCodeGrantRepository: clock: &dyn Clock, device_code_grant: DeviceCodeGrant, browser_session: &BrowserSession, + locale: Option, ) -> Result; async fn reject( diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 0e3deed93..b6dd71164 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -835,6 +835,7 @@ impl TemplateContext for PolicyViolationContext { expires_at: now + Duration::try_minutes(25).unwrap(), ip_address: None, user_agent: None, + locale: None, }, client, Vec::new(), @@ -1859,6 +1860,7 @@ impl TemplateContext for DeviceConsentContext { expires_at: now + Duration::try_minutes(25).unwrap(), ip_address: Some(IpAddr::V4(Ipv4Addr::LOCALHOST)), user_agent: Some("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()), + locale: None, }; Self { grant, From 1283932c35f655feaa9e6006b5f638356eac0a94 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Tue, 23 Jun 2026 09:49:21 +0200 Subject: [PATCH 2/2] Infer a device name for the device authorization grant Mirror the authorization code grant: render a human-readable device name from the client and the polling device's user-agent, using the locale captured on the grant, and provision it on the homeserver instead of sending no display name. --- crates/handlers/src/oauth2/token.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/handlers/src/oauth2/token.rs b/crates/handlers/src/oauth2/token.rs index ebc46b660..37c4c7497 100644 --- a/crates/handlers/src/oauth2/token.rs +++ b/crates/handlers/src/oauth2/token.rs @@ -382,6 +382,7 @@ pub(crate) async fn post( &client, &key_store, &url_builder, + &templates, &site_config, repo, &homeserver, @@ -870,6 +871,7 @@ async fn device_code_grant( client: &Client, key_store: &Keystore, url_builder: &UrlBuilder, + templates: &Templates, site_config: &SiteConfig, mut repo: BoxRepository, homeserver: &Arc, @@ -924,6 +926,12 @@ async fn device_code_grant( .await? .ok_or(RouteError::NoSuchBrowserSession(browser_session_id))?; + // Generate a device name, using the locale captured from the browser which + // fulfilled the grant + let lang: DataLocale = 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)?; + // Start the session let mut session = repo .oauth2_session() @@ -1001,7 +1009,11 @@ async fn device_code_grant( // We're using an upsert so if the device already exists for some reason // (like when a concurrent device sync happening) it won't have any effect. homeserver - .upsert_device(&browser_session.user.username, device.as_str(), None) + .upsert_device( + &browser_session.user.username, + device.as_str(), + Some(&device_name), + ) .await .map_err(RouteError::ProvisionDeviceFailed)?; }