mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-11-14 12:46:49 +00:00
feat: Add support for OAuth2 device auth flow
This commit is contained in:
1 parent
25264308ab
commit
4c9426a13f
13 files changed
+512
-93
No files matched your search
@@ -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
@@ -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
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user