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..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)?; } @@ -1787,7 +1799,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 72aea43e4..226a06edc 100644 --- a/crates/storage-pg/src/oauth2/device_code_grant.rs +++ b/crates/storage-pg/src/oauth2/device_code_grant.rs @@ -50,6 +50,7 @@ struct OAuth2DeviceGrantLookup { oauth2_session_id: Option, ip_address: Option, user_agent: Option, + locale: Option, } impl TryFrom for DeviceCodeGrant { @@ -71,6 +72,7 @@ impl TryFrom for DeviceCodeGrant { oauth2_session_id, ip_address, user_agent, + locale, }: OAuth2DeviceGrantLookup, ) -> Result { let id = Ulid::from(oauth2_device_code_grant_id); @@ -133,6 +135,7 @@ impl TryFrom for DeviceCodeGrant { expires_at, ip_address, user_agent, + locale, }) } } @@ -207,6 +210,7 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { expires_at, ip_address: params.ip_address, user_agent: params.user_agent, + locale: None, }) } @@ -237,6 +241,7 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { , oauth2_session_id , ip_address as "ip_address: IpAddr" , user_agent + , locale FROM oauth2_device_code_grant @@ -283,6 +288,7 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { , oauth2_session_id , ip_address as "ip_address: IpAddr" , user_agent + , locale FROM oauth2_device_code_grant @@ -329,6 +335,7 @@ impl OAuth2DeviceCodeGrantRepository for PgOAuth2DeviceCodeGrantRepository<'_> { , oauth2_session_id , ip_address as "ip_address: IpAddr" , user_agent + , locale FROM oauth2_device_code_grant @@ -362,10 +369,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!( @@ -373,10 +381,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 40c0aab2d..296f31282 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 f3dadcdd9..a48c19e0a 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(), @@ -1856,6 +1857,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,