feat: Add support for OAuth2 device auth flow

This commit is contained in:
Ginger authored and Ellis Git committed 2026-07-21 13:42:50 +00:00
1 parent 25264308ab
commit 4c9426a13f
13 files changed
+512 -93

No files matched your search

+17 -3
View File
@@ -65,17 +65,17 @@ impl Avatar {
}
pub(super) fn for_device(
oauth_metadata: Option<&ClientMetadata>,
client_metadata: Option<&ClientMetadata>,
display_name: Option<&str>,
) -> Self {
let avatar_src = oauth_metadata
let avatar_src = client_metadata
.and_then(|metadata| metadata.logo_uri.as_ref())
.map(|uri| uri.as_str().to_owned());
let avatar_type = if let Some(avatar_src) = avatar_src {
AvatarType::Image(avatar_src)
} else if let Some(initial) = display_name.and_then(|name| name.chars().next()) {
if oauth_metadata.is_some() {
if client_metadata.is_some() {
AvatarType::Initial(initial)
} else {
AvatarType::Initial('❖')
@@ -86,6 +86,20 @@ impl Avatar {
Self { avatar_type }
}
pub(super) fn for_client(client_metadata: &ClientMetadata) -> Self {
let avatar_type = if let Some(logo) = &client_metadata.logo_uri {
AvatarType::Image(logo.to_string())
} else if let Some(name) = &client_metadata.client_name
&& let Some(char) = name.chars().next()
{
AvatarType::Initial(char)
} else {
AvatarType::Initial('?')
};
Self { avatar_type }
}
}
#[derive(Debug, Template)]
+108 -36
View File
@@ -4,9 +4,12 @@ use axum::{
response::Redirect,
routing::on,
};
use conduwuit_service::oauth::grant::{AuthorizationCodeQuery, Prompt};
use conduwuit_service::oauth::{
client_metadata::ClientMetadata,
grant::{AuthorizationCodeQuery, DeviceCodeVerifyQuery, Prompt},
};
use ruma::OwnedUserId;
use url::Url;
use serde::Deserialize;
use crate::{
ROUTE_PREFIX, WebError,
@@ -14,7 +17,7 @@ use crate::{
pages::{
GET_POST, Result, TemplateContext,
account::register::{RegisterQuery, RequestedRegistrationFlow},
components::{Avatar, AvatarType, ClientScopes},
components::{Avatar, ClientScopes, UserCard},
},
response,
session::{LoginIntent, LoginQuery, LoginTarget, User},
@@ -22,7 +25,9 @@ use crate::{
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/authorization_code", on(GET_POST, route_authorization_code))
Router::new()
.route("/authorization_code", on(GET_POST, route_authorization_code))
.route("/device_code", on(GET_POST, route_device_code))
}
template! {
@@ -30,12 +35,9 @@ template! {
logout_query: String,
user_id: OwnedUserId,
user_avatar: Avatar,
client_uri: Url,
client_name: String,
client_avatar: Avatar,
policy_uri: Option<Url>,
tos_uri: Option<Url>,
scopes: ClientScopes
client_metadata: ClientMetadata,
scopes: ClientScopes,
device_code: Option<String>
}
}
@@ -102,27 +104,6 @@ async fn route_authorization_code(
let scopes = query.scope.to_scopes().map_err(WebError::BadRequest)?;
let client_name = if let Some(name) = &client.client_name {
name
} else {
"Unknown application"
}
.to_owned();
let client_avatar = {
let avatar_type = if let Some(logo) = &client.logo_uri {
AvatarType::Image(logo.to_string())
} else if let Some(name) = &client.client_name
&& let Some(char) = name.chars().next()
{
AvatarType::Initial(char)
} else {
AvatarType::Initial('?')
};
Avatar { avatar_type }
};
let user_avatar = Avatar::for_local_user(&services, &user_id).await;
response!(Grant::new(
@@ -135,11 +116,102 @@ async fn route_authorization_code(
.unwrap(),
user_id,
user_avatar,
client.client_uri.clone(),
client_name,
client_avatar,
client.policy_uri.clone(),
client.tos_uri.clone(),
client,
ClientScopes { scopes },
None,
))
}
#[derive(Deserialize)]
#[serde(tag = "stage", rename_all = "snake_case")]
enum DeviceCodeForm {
Lookup {
user_code: String,
},
Confirm {
device_code: String,
},
}
template! {
struct DeviceCodeGrant use "device_code_grant.html.j2" {
user_card: UserCard,
body: DeviceCodeGrantBody
}
}
#[derive(Debug)]
enum DeviceCodeGrantBody {
Lookup {
user_code_error: bool,
},
Success,
}
async fn route_device_code(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
user: User<true>,
Expect(Query(query)): Expect<Query<DeviceCodeVerifyQuery>>,
PostForm(form): PostForm<DeviceCodeForm>,
) -> Result {
let user_id = if let Some(user) = user.into_session() {
user.user_id
} else {
let next = LoginTarget::DeviceCode(query.clone());
let uri = format!(
"{}/account/login?{}",
ROUTE_PREFIX,
serde_urlencoded::to_string(LoginQuery { next: Some(next), ..Default::default() })
.unwrap()
);
return response!(Redirect::to(&uri));
};
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
match (form, query.user_code.clone()) {
| (None, Some(user_code)) | (Some(DeviceCodeForm::Lookup { user_code }), _) => {
let Some(grant_info) = services.oauth.grant_info_for_user_code(&user_code).await
else {
return response!(DeviceCodeGrant::new(
context,
user_card,
DeviceCodeGrantBody::Lookup { user_code_error: true }
));
};
let user_avatar = Avatar::for_local_user(&services, &user_id).await;
response!(Grant::new(
context,
serde_urlencoded::to_string(LoginQuery {
next: Some(LoginTarget::DeviceCode(query)),
intent: Some(LoginIntent::SwitchAccounts),
..Default::default()
})
.unwrap(),
user_id,
user_avatar,
grant_info.client_metadata,
ClientScopes { scopes: grant_info.requested_scopes },
Some(grant_info.device_code),
))
},
| (Some(DeviceCodeForm::Confirm { device_code }), _) => {
services
.oauth
.validate_device_code(user_id, &device_code)
.await
.map_err(WebError::BadRequest)?;
response!(DeviceCodeGrant::new(context, user_card, DeviceCodeGrantBody::Success))
},
| (None, None) =>
response!(DeviceCodeGrant::new(context, user_card, DeviceCodeGrantBody::Lookup {
user_code_error: false
})),
}
}
+14
View File
@@ -20,3 +20,17 @@
font-style: italic;
text-align: center;
}
.user-code {
display: flex;
flex-direction: column;
align-items: center;
input {
text-align: center;
font-weight: bold;
font-size: 200%;
padding: 0.2em;
width: 6em !important;
}
}
@@ -0,0 +1,50 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/grant.css">
{%- endblock -%}
{%- block title -%}
Authorize client
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Authorize device</h1>
{{ user_card }}
{% match body %}
{% when DeviceCodeGrantBody::Lookup { user_code_error } %}
<p>
Enter the code displayed on your device, or in the app you're signing into.
</p>
<p>
<em class="negative">Never enter a code given to you by someone else.</em>
Your homeserver administrator will never ask you to enter a code on this page.
</p>
<form method="post">
<p>
<div class="user-code">
<label for="user_code">Validation code</label>
<input
type="text"
id="user_code"
name="user_code"
maxlength="6"
spellcheck="false"
pattern="\d{6}"
title="Enter the six-digit validation code"
>
</div>
{% if user_code_error %}
<small class="error">Invalid code</small>
{% endif %}
</p>
<input type="hidden" name="stage" value="lookup">
<button type="submit">Continue</button>
</form>
{% when DeviceCodeGrantBody::Success %}
<p>Success! 🎉 You can close this tab and return to your device.</p>
{% endmatch %}
</div>
{% endblock %}
+8 -3
View File
@@ -9,6 +9,7 @@ Authorize client
{%- endblock -%}
{%- block content -%}
{% let client_name = client_metadata.client_name.as_deref().unwrap_or("Unknown application") %}
<div class="panel narrow">
<h1>Authorize {{ client_name }}</h1>
<div class="avatars">
@@ -18,17 +19,17 @@ Authorize client
<div class="separator" aria-hidden="true">
⇄
</div>
{{ client_avatar }}
{{ Avatar::for_client(client_metadata) }}
</div>
<div class="identity">
Signed in as <code>{{ user_id }}</code>. <a href="{{ crate::ROUTE_PREFIX }}/account/logout?{{ logout_query }}">Switch accounts</a>
</div>
<p>
<b>{{ client_name }}</b> (<a href="{{ client_uri }}">{{ client_uri.domain().unwrap() }}</a>) would like
<b>{{ client_name }}</b> (<a href="{{ client_metadata.client_uri }}">{{ client_metadata.client_uri.domain().unwrap() }}</a>) would like
your permission to:
{{ scopes }}
</p>
{% match (&policy_uri, &tos_uri) %}
{% match (&client_metadata.policy_uri, &client_metadata.tos_uri) %}
{% when (Some(policy_uri), Some(tos_uri)) %}
<p>
{{ client_name }}'s <a href="{{ policy_uri }}">policies</a>
@@ -49,6 +50,10 @@ Authorize client
{% endmatch %}
<form method="post">
<input type="hidden" name="stage" value="confirm">
{% if let Some(device_code) = device_code %}
<input type="hidden" name="device_code" value="{{ device_code }}">
{% endif %}
<button type="submit">Continue</button>
</form>
</div>
+5 -1
View File
@@ -9,7 +9,7 @@ use axum::{
http::request::Parts,
response::{IntoResponse, Redirect, Response},
};
use conduwuit_service::oauth::grant::AuthorizationCodeQuery;
use conduwuit_service::oauth::grant::{AuthorizationCodeQuery, DeviceCodeVerifyQuery};
use ruma::{OwnedUserId, UserId};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
@@ -38,6 +38,7 @@ pub(crate) enum LoginTarget {
ChangeEmail,
CrossSigningReset,
Deactivate,
DeviceCode(DeviceCodeVerifyQuery),
DeviceInfo(DevicePath),
RemoveDevice(DevicePath),
}
@@ -59,6 +60,9 @@ impl LoginTarget {
| Self::ChangeEmail => "account/email/change/".into(),
| Self::CrossSigningReset => "account/cross_signing_reset".into(),
| Self::Deactivate => "account/deactivate".into(),
| Self::DeviceCode(code) =>
format!("oauth2/grant/device_code?{}", serde_urlencoded::to_string(code).unwrap())
.into(),
| Self::DeviceInfo(path) => format!("account/device/{}/", path.device).into(),
| Self::RemoveDevice(path) => format!("account/device/{}/remove", path.device).into(),
};