Infer an initial name for the device for the device auth grant (#5750)

This commit is contained in:
Quentin Gliech
2026-06-25 18:34:46 +02:00
committed by GitHub
12 changed files with 83 additions and 17 deletions
@@ -193,6 +193,11 @@ pub struct DeviceCodeGrant {
/// The user agent used to request this device code grant.
pub user_agent: Option<String>,
/// 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<String>,
}
impl std::ops::Deref for DeviceCodeGrant {
@@ -215,10 +220,12 @@ impl DeviceCodeGrant {
pub fn fulfill(
self,
browser_session: &BrowserSession,
locale: Option<String>,
fulfilled_at: DateTime<Utc>,
) -> Result<Self, InvalidTransitionError> {
Ok(Self {
state: self.state.fulfill(browser_session, fulfilled_at)?,
locale,
..self
})
}
+1 -1
View File
@@ -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 => {
+14 -2
View File
@@ -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<dyn HomeserverConnection>,
@@ -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();
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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;
@@ -50,6 +50,7 @@ struct OAuth2DeviceGrantLookup {
oauth2_session_id: Option<Uuid>,
ip_address: Option<IpAddr>,
user_agent: Option<String>,
locale: Option<String>,
}
impl TryFrom<OAuth2DeviceGrantLookup> for DeviceCodeGrant {
@@ -71,6 +72,7 @@ impl TryFrom<OAuth2DeviceGrantLookup> for DeviceCodeGrant {
oauth2_session_id,
ip_address,
user_agent,
locale,
}: OAuth2DeviceGrantLookup,
) -> Result<Self, Self::Error> {
let id = Ulid::from(oauth2_device_code_grant_id);
@@ -133,6 +135,7 @@ impl TryFrom<OAuth2DeviceGrantLookup> 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<String>,
) -> Result<DeviceCodeGrant, Self::Error> {
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()
+8 -4
View File
@@ -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());
@@ -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<String>,
) -> Result<DeviceCodeGrant, Self::Error>;
/// 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<String>,
) -> Result<DeviceCodeGrant, Self::Error>;
async fn reject(
+2
View File
@@ -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,