Compare commits

...
Author SHA1 Message Date
Ginger 64189263ba fix: Use correct error code for invalid client ID 2026-07-13 10:01:50 -04:00
Ginger 14b8333183 feat: Add support for OAuth2 device auth flow 2026-07-12 17:32:13 -04:00
13 changed files with 513 additions and 93 deletions
+1
View File
@@ -0,0 +1 @@
Added support for the OAuth2 device authorization flow. Contributed by @ginger
+13
View File
@@ -0,0 +1,13 @@
use axum::{Form, Json, extract::State, response::IntoResponse};
use http::StatusCode;
use service::oauth::grant::DeviceCodeRequest;
pub(crate) async fn device_authorization_route(
State(services): State<crate::State>,
Form(request): Form<DeviceCodeRequest>,
) -> impl IntoResponse {
match services.oauth.request_device_code(request).await {
| Ok(response) => Ok(Json(response)),
| Err(err) => Err((StatusCode::BAD_REQUEST, Json(err))),
}
}
+3
View File
@@ -10,6 +10,7 @@
use serde_json::json; use serde_json::json;
pub(crate) use server_metadata::*; pub(crate) use server_metadata::*;
mod device;
mod register_client; mod register_client;
mod server_metadata; mod server_metadata;
mod token; mod token;
@@ -20,6 +21,7 @@
const CLIENT_REGISTER_PATH: &str = "client/register"; const CLIENT_REGISTER_PATH: &str = "client/register";
const TOKEN_REVOKE_PATH: &str = "client/revoke"; const TOKEN_REVOKE_PATH: &str = "client/revoke";
const TOKEN_PATH: &str = "grant/token"; const TOKEN_PATH: &str = "grant/token";
const DEVICE_AUTHORIZATION_PATH: &str = "device";
const ACCOUNT_MANAGEMENT_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/account/deeplink"); const ACCOUNT_MANAGEMENT_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/account/deeplink");
pub(crate) fn router(state: crate::State) -> Router<crate::State> { pub(crate) fn router(state: crate::State) -> Router<crate::State> {
@@ -53,4 +55,5 @@ fn oauth_router() -> Router<crate::State> {
.route(concat!("/", JWKS_URI_PATH), get(async || Json(json!({"keys": []})))) .route(concat!("/", JWKS_URI_PATH), get(async || Json(json!({"keys": []}))))
.route(concat!("/", TOKEN_PATH), post(token::token_route)) .route(concat!("/", TOKEN_PATH), post(token::token_route))
.route(concat!("/", TOKEN_REVOKE_PATH), post(token::revoke_token_route)) .route(concat!("/", TOKEN_REVOKE_PATH), post(token::revoke_token_route))
.route(concat!("/", DEVICE_AUTHORIZATION_PATH), post(device::device_authorization_route))
} }
+4 -3
View File
@@ -12,8 +12,8 @@
use crate::{ use crate::{
Ruma, Ruma,
client::oauth::{ client::oauth::{
ACCOUNT_MANAGEMENT_PATH, AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH, ACCOUNT_MANAGEMENT_PATH, AUTH_CODE_PATH, CLIENT_REGISTER_PATH, DEVICE_AUTHORIZATION_PATH,
TOKEN_REVOKE_PATH, JWKS_URI_PATH, TOKEN_PATH, TOKEN_REVOKE_PATH,
}, },
}; };
@@ -49,7 +49,8 @@ pub(crate) async fn authorization_server_metadata(services: &Services) -> Value
], ],
"authorization_endpoint": endpoint_base.join(AUTH_CODE_PATH).unwrap(), "authorization_endpoint": endpoint_base.join(AUTH_CODE_PATH).unwrap(),
"code_challenge_methods_supported": ["S256"], "code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token"], "device_authorization_endpoint": endpoint_base.join(DEVICE_AUTHORIZATION_PATH).unwrap(),
"grant_types_supported": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
"issuer": services.config.get_client_domain(), "issuer": services.config.get_client_domain(),
"jwks_uri": endpoint_base.join(JWKS_URI_PATH).unwrap(), "jwks_uri": endpoint_base.join(JWKS_URI_PATH).unwrap(),
"prompt_values_supported": ["create"], "prompt_values_supported": ["create"],
+2
View File
@@ -132,6 +132,8 @@ pub enum ApplicationType {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum GrantType { pub enum GrantType {
AuthorizationCode, AuthorizationCode,
#[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")]
DeviceCode,
RefreshToken, RefreshToken,
} }
+62 -17
View File
@@ -13,6 +13,7 @@
use url::Url; use url::Url;
use super::client_metadata::ResponseType; use super::client_metadata::ResponseType;
use crate::oauth::client_metadata::GrantType;
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthorizationCodeQuery { pub struct AuthorizationCodeQuery {
@@ -29,6 +30,33 @@ pub struct AuthorizationCodeQuery {
pub prompt: Option<Prompt>, pub prompt: Option<Prompt>,
} }
#[derive(Deserialize, Serialize)]
pub struct AuthorizationCodeResponse {
pub state: String,
pub code: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeviceCodeRequest {
pub client_id: String,
pub scope: RawScopes,
}
#[derive(Deserialize, Serialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: Url,
#[serde(skip_serializing_if = "Option::is_none")]
pub verification_uri_complete: Option<Url>,
pub expires_in: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DeviceCodeVerifyQuery {
pub user_code: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[non_exhaustive] #[non_exhaustive]
@@ -126,19 +154,29 @@ pub struct OAuthError {
impl OAuthError { impl OAuthError {
#[must_use] #[must_use]
pub const fn invalid_request(error_description: &'static str) -> Self { pub fn new(error: ErrorCode, error_description: String) -> Self {
Self { Self {
error: ErrorCode::InvalidRequest, error,
error_description: Cow::Owned(error_description),
}
}
#[must_use]
pub const fn new_static(error: ErrorCode, error_description: &'static str) -> Self {
Self {
error,
error_description: Cow::Borrowed(error_description), error_description: Cow::Borrowed(error_description),
} }
} }
#[must_use] #[must_use]
pub const fn invalid_grant(error_description: &'static str) -> Self { pub const fn invalid_request(error_description: &'static str) -> Self {
Self { Self::new_static(ErrorCode::InvalidRequest, error_description)
error: ErrorCode::InvalidGrant,
error_description: Cow::Borrowed(error_description),
} }
#[must_use]
pub const fn invalid_grant(error_description: &'static str) -> Self {
Self::new_static(ErrorCode::InvalidGrant, error_description)
} }
} }
@@ -157,36 +195,43 @@ pub enum ErrorCode {
AccessDenied, AccessDenied,
InvalidScope, InvalidScope,
InvalidGrant, InvalidGrant,
InvalidClient,
InvalidClientMetadata, InvalidClientMetadata,
AuthorizationPending,
ExpiredToken,
} }
#[derive(Serialize, Deserialize)] #[derive(Deserialize)]
pub struct AuthorizationCodeResponse { pub struct TokenRequest {
pub state: String, pub client_id: String,
pub code: String, #[serde(flatten)]
pub request: TokenRequestType,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(tag = "grant_type", rename_all = "snake_case")] #[serde(tag = "grant_type", rename_all = "snake_case")]
pub enum TokenRequest { pub enum TokenRequestType {
AuthorizationCode { AuthorizationCode {
code: String, code: String,
redirect_uri: Url, redirect_uri: Url,
client_id: String,
code_verifier: String, code_verifier: String,
}, },
#[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")]
DeviceCode {
device_code: String,
},
RefreshToken { RefreshToken {
client_id: String,
refresh_token: String, refresh_token: String,
}, },
} }
impl TokenRequest { impl TokenRequestType {
#[must_use] #[must_use]
pub fn client_id(&self) -> &str { pub fn grant_type(&self) -> GrantType {
match self { match self {
| Self::AuthorizationCode { client_id, .. } | Self::AuthorizationCode { .. } => GrantType::AuthorizationCode,
| Self::RefreshToken { client_id, .. } => client_id, | Self::DeviceCode { .. } => GrantType::DeviceCode,
| Self::RefreshToken { .. } => GrantType::RefreshToken,
} }
} }
} }
+226 -30
View File
@@ -12,17 +12,19 @@
use database::{Deserialized, Json, Map}; use database::{Deserialized, Json, Map};
use itertools::Itertools; use itertools::Itertools;
use lru_cache::LruCache; use lru_cache::LruCache;
use rand::distr::{Distribution, slice::Choose};
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId}; use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use crate::{ use crate::{
Dep, Dep, config,
oauth::{ oauth::{
client_metadata::{ApplicationType, ClientMetadata, ResponseType}, client_metadata::{ApplicationType, ClientMetadata, ResponseType},
grant::{ grant::{
AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, ErrorCode, AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod,
OAuthError, ResponseMode, Scope, TokenRequest, TokenResponse, TokenType, DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, ResponseMode, Scope,
TokenRequest, TokenRequestType, TokenResponse, TokenType,
}, },
}, },
users, users,
@@ -35,7 +37,8 @@ pub struct Service {
services: Services, services: Services,
db: Data, db: Data,
tickets: Mutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>, tickets: Mutex<HashMap<String, HashMap<OAuthTicket, SystemTime>>>,
pending_code_grants: tokio::sync::Mutex<LruCache<String, PendingCodeGrant>>, pending_auth_code_grants: tokio::sync::Mutex<LruCache<String, PendingAuthCodeGrant>>,
pending_device_code_grants: tokio::sync::Mutex<LruCache<String, PendingDeviceCodeGrant>>,
} }
struct Data { struct Data {
@@ -46,6 +49,7 @@ struct Data {
struct Services { struct Services {
users: Dep<users::Service>, users: Dep<users::Service>,
config: Dep<config::Service>,
} }
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
@@ -62,7 +66,7 @@ struct RefreshTokenInfo {
device_id: OwnedDeviceId, device_id: OwnedDeviceId,
} }
struct PendingCodeGrant { struct PendingAuthCodeGrant {
authorizing_user: OwnedUserId, authorizing_user: OwnedUserId,
requested_scopes: BTreeSet<Scope>, requested_scopes: BTreeSet<Scope>,
client_name: Option<String>, client_name: Option<String>,
@@ -72,12 +76,8 @@ struct PendingCodeGrant {
requested_at: SystemTime, requested_at: SystemTime,
} }
impl PendingCodeGrant { impl PendingAuthCodeGrant {
const MAX_AGE: Duration = Duration::from_mins(1); const MAX_AGE: Duration = Duration::from_mins(1);
const RANDOM_CODE_LENGTH: usize = 32;
#[must_use]
pub(crate) fn generate_code() -> String { utils::random_string(Self::RANDOM_CODE_LENGTH) }
#[must_use] #[must_use]
pub(crate) fn is_valid_for(&self, client_id: &str) -> bool { pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
@@ -90,6 +90,43 @@ pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
} }
} }
struct PendingDeviceCodeGrant {
state: DeviceCodeGrantState,
requested_scopes: BTreeSet<Scope>,
client_name: Option<String>,
client_id: String,
requested_at: SystemTime,
}
enum DeviceCodeGrantState {
Unverified {
user_code: String,
},
Verified {
authorizing_user: OwnedUserId,
},
}
impl PendingDeviceCodeGrant {
const MAX_AGE: Duration = Duration::from_mins(1);
#[must_use]
pub(crate) fn is_valid_for(&self, client_id: &str) -> bool {
let now = SystemTime::now();
self.client_id == client_id
&& now
.duration_since(self.requested_at)
.is_ok_and(|age| age < Self::MAX_AGE)
}
}
pub struct DeviceCodeGrantInfo {
pub device_code: String,
pub client_metadata: ClientMetadata,
pub requested_scopes: BTreeSet<Scope>,
}
/// A time-limited grant for a client to perform some sensitive action. /// A time-limited grant for a client to perform some sensitive action.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum OAuthTicket { pub enum OAuthTicket {
@@ -112,6 +149,7 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
Ok(Arc::new(Self { Ok(Arc::new(Self {
services: Services { services: Services {
users: args.depend::<users::Service>("users"), users: args.depend::<users::Service>("users"),
config: args.depend::<config::Service>("config"),
}, },
db: Data { db: Data {
clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(), clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(),
@@ -119,8 +157,11 @@ fn build(args: crate::Args<'_>) -> Result<Arc<Self>> {
refreshtoken_refreshtokeninfo: args.db["refreshtoken_refreshtokeninfo"].clone(), refreshtoken_refreshtokeninfo: args.db["refreshtoken_refreshtokeninfo"].clone(),
}, },
tickets: Mutex::default(), tickets: Mutex::default(),
pending_code_grants: tokio::sync::Mutex::new(LruCache::new( pending_auth_code_grants: tokio::sync::Mutex::new(LruCache::new(
Self::MAX_PENDING_CODE_GRANTS, Self::MAX_PENDING_GRANTS,
)),
pending_device_code_grants: tokio::sync::Mutex::new(LruCache::new(
Self::MAX_PENDING_GRANTS,
)), )),
})) }))
} }
@@ -133,11 +174,21 @@ impl Service {
// Maximum number of pending code grants which will be held in memory at once, // Maximum number of pending code grants which will be held in memory at once,
// to prevent unbounded memory use if someone decides to repeatedly reload the // to prevent unbounded memory use if someone decides to repeatedly reload the
// grant page. // grant page.
const MAX_PENDING_CODE_GRANTS: usize = 100; const MAX_PENDING_GRANTS: usize = 100;
const RANDOM_TOKEN_LENGTH: usize = 32; const RANDOM_TOKEN_LENGTH: usize = 32;
const USER_CODE_CHARACTERS: &[char] = &['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
const USER_CODE_LENGTH: usize = 6;
fn generate_token() -> String { utils::random_string(Self::RANDOM_TOKEN_LENGTH) } fn generate_token() -> String { utils::random_string(Self::RANDOM_TOKEN_LENGTH) }
fn generate_user_code() -> String {
Choose::new(Self::USER_CODE_CHARACTERS)
.unwrap()
.sample_iter(&mut rand::rng())
.take(Self::USER_CODE_LENGTH)
.collect()
}
pub async fn register_client(&self, metadata: &ClientMetadata) -> Result<String, OAuthError> { pub async fn register_client(&self, metadata: &ClientMetadata) -> Result<String, OAuthError> {
metadata.validate().map_err(|error| OAuthError { metadata.validate().map_err(|error| OAuthError {
error: ErrorCode::InvalidClientMetadata, error: ErrorCode::InvalidClientMetadata,
@@ -231,14 +282,14 @@ pub async fn request_authorization_code(
| ResponseMode::Query => '?', | ResponseMode::Query => '?',
}; };
let code = PendingCodeGrant::generate_code(); let code = Self::generate_token();
info!( info!(
client_id = &query.client_id, client_id = &query.client_id,
client_name = &client_metadata.client_name, client_name = &client_metadata.client_name,
?requested_scopes, ?requested_scopes,
?authorizing_user, ?authorizing_user,
"Issuing oauth authorization code" "Issuing OAuth authorization code"
); );
let redirect_uri = format!( let redirect_uri = format!(
@@ -252,7 +303,7 @@ pub async fn request_authorization_code(
.unwrap(), .unwrap(),
); );
let pending_grant = PendingCodeGrant { let pending_grant = PendingAuthCodeGrant {
authorizing_user, authorizing_user,
requested_scopes, requested_scopes,
client_name: client_metadata.client_name, client_name: client_metadata.client_name,
@@ -262,7 +313,7 @@ pub async fn request_authorization_code(
requested_at: SystemTime::now(), requested_at: SystemTime::now(),
}; };
self.pending_code_grants self.pending_auth_code_grants
.lock() .lock()
.await .await
.insert(code, pending_grant); .insert(code, pending_grant);
@@ -270,15 +321,129 @@ pub async fn request_authorization_code(
Ok(redirect_uri) Ok(redirect_uri)
} }
pub async fn request_device_code(
&self,
query: DeviceCodeRequest,
) -> Result<DeviceCodeResponse, OAuthError> {
let Some(client_metadata) = self.get_client_metadata(&query.client_id).await else {
return Err(OAuthError::new_static(ErrorCode::InvalidClient, "Invalid client ID"));
};
let requested_scopes = query
.scope
.to_scopes()
.map_err(|err| OAuthError::new(ErrorCode::InvalidGrant, err))?;
let device_code = Self::generate_token();
let user_code = Self::generate_user_code();
let verification_uri = self
.services
.config
.get_client_domain()
.join(&format!("{}/oauth2/grant/device_code", conduwuit::ROUTE_PREFIX))
.unwrap();
let mut verification_uri_complete = verification_uri.clone();
verification_uri_complete
.query_pairs_mut()
.append_pair("user_code", &user_code);
info!(
client_id = &query.client_id,
client_name = &client_metadata.client_name,
?requested_scopes,
"Issuing OAuth device code"
);
let pending_grant = PendingDeviceCodeGrant {
state: DeviceCodeGrantState::Unverified { user_code: user_code.clone() },
requested_scopes,
client_name: client_metadata.client_name,
client_id: query.client_id,
requested_at: SystemTime::now(),
};
self.pending_device_code_grants
.lock()
.await
.insert(device_code.clone(), pending_grant);
Ok(DeviceCodeResponse {
device_code,
user_code,
verification_uri,
verification_uri_complete: Some(verification_uri_complete),
expires_in: PendingDeviceCodeGrant::MAX_AGE.as_secs(),
})
}
pub async fn grant_info_for_user_code(
&self,
supplied_user_code: &str,
) -> Option<DeviceCodeGrantInfo> {
let pending_grants = self.pending_device_code_grants.lock().await;
let (device_code, grant) = pending_grants
.iter()
.find(|(_, grant)| {
matches!(&grant.state, DeviceCodeGrantState::Unverified { user_code } if user_code == supplied_user_code)
})?;
let client_metadata = self
.get_client_metadata(&grant.client_id)
.await
.expect("client should exist");
Some(DeviceCodeGrantInfo {
device_code: device_code.clone(),
client_metadata,
requested_scopes: grant.requested_scopes.clone(),
})
}
pub async fn validate_device_code(
&self,
authorizing_user: OwnedUserId,
device_code: &str,
) -> Result<(), String> {
let mut pending_grants = self.pending_device_code_grants.lock().await;
let Some(pending_grant) = pending_grants.get_mut(device_code) else {
return Err("Invalid device code".to_owned());
};
match &mut pending_grant.state {
| state @ DeviceCodeGrantState::Unverified { .. } => {
*state = DeviceCodeGrantState::Verified { authorizing_user };
Ok(())
},
| DeviceCodeGrantState::Verified {
authorizing_user: previous_authorizing_user,
} =>
if *previous_authorizing_user == authorizing_user {
Ok(())
} else {
Err("Device code is already verified".to_owned())
},
}
}
pub async fn issue_token(&self, request: TokenRequest) -> Result<TokenResponse, OAuthError> { pub async fn issue_token(&self, request: TokenRequest) -> Result<TokenResponse, OAuthError> {
let TokenRequest { client_id, request } = request;
let Some(client_metadata) = self.get_client_metadata(&client_id).await else {
return Err(OAuthError::new_static(ErrorCode::InvalidClient, "Invalid client ID"));
};
if !client_metadata.grant_types.contains(&request.grant_type()) {
return Err(OAuthError::invalid_grant("Client cannot request this grant type"));
}
match request { match request {
| TokenRequest::AuthorizationCode { | TokenRequestType::AuthorizationCode { code, redirect_uri, code_verifier } => {
code, let mut pending_grants = self.pending_auth_code_grants.lock().await;
redirect_uri,
client_id,
code_verifier,
} => {
let mut pending_grants = self.pending_code_grants.lock().await;
let Some(pending_grant) = pending_grants let Some(pending_grant) = pending_grants
.remove(&code) .remove(&code)
@@ -305,7 +470,39 @@ pub async fn issue_token(&self, request: TokenRequest) -> Result<TokenResponse,
) )
.await .await
}, },
| TokenRequest::RefreshToken { client_id, refresh_token } => | TokenRequestType::DeviceCode { device_code } => {
let mut pending_grants = self.pending_device_code_grants.lock().await;
let Some(pending_grant) = pending_grants
.remove(&device_code)
.filter(|grant| grant.is_valid_for(&client_id))
else {
return Err(OAuthError::new_static(
ErrorCode::ExpiredToken,
"Invalid device code",
));
};
match &pending_grant.state {
| DeviceCodeGrantState::Unverified { .. } => {
pending_grants.insert(device_code, pending_grant);
Err(OAuthError::new_static(
ErrorCode::AuthorizationPending,
"Authorization is pending",
))
},
| DeviceCodeGrantState::Verified { authorizing_user } =>
self.create_session(
authorizing_user.to_owned(),
pending_grant.requested_scopes,
pending_grant.client_name,
client_id,
)
.await,
}
},
| TokenRequestType::RefreshToken { refresh_token } =>
self.refresh_session(client_id, refresh_token).await, self.refresh_session(client_id, refresh_token).await,
} }
} }
@@ -364,11 +561,10 @@ async fn create_session(
.await .await
.is_ok() .is_ok()
{ {
return Err(OAuthError { return Err(OAuthError::new_static(
error: ErrorCode::InvalidScope, ErrorCode::InvalidScope,
error_description: "A device with the supplied ID already exists for this user" "A device with the supplied ID already exists for this user",
.into(), ));
});
} }
self.services self.services
+17 -3
View File
@@ -65,17 +65,17 @@ pub(super) async fn for_local_user(services: &Services, user_id: &UserId) -> Sel
} }
pub(super) fn for_device( pub(super) fn for_device(
oauth_metadata: Option<&ClientMetadata>, client_metadata: Option<&ClientMetadata>,
display_name: Option<&str>, display_name: Option<&str>,
) -> Self { ) -> Self {
let avatar_src = oauth_metadata let avatar_src = client_metadata
.and_then(|metadata| metadata.logo_uri.as_ref()) .and_then(|metadata| metadata.logo_uri.as_ref())
.map(|uri| uri.as_str().to_owned()); .map(|uri| uri.as_str().to_owned());
let avatar_type = if let Some(avatar_src) = avatar_src { let avatar_type = if let Some(avatar_src) = avatar_src {
AvatarType::Image(avatar_src) AvatarType::Image(avatar_src)
} else if let Some(initial) = display_name.and_then(|name| name.chars().next()) { } 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) AvatarType::Initial(initial)
} else { } else {
AvatarType::Initial('❖') AvatarType::Initial('❖')
@@ -86,6 +86,20 @@ pub(super) fn for_device(
Self { avatar_type } 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)] #[derive(Debug, Template)]
+108 -36
View File
@@ -4,9 +4,12 @@
response::Redirect, response::Redirect,
routing::on, routing::on,
}; };
use conduwuit_service::oauth::grant::{AuthorizationCodeQuery, Prompt}; use conduwuit_service::oauth::{
client_metadata::ClientMetadata,
grant::{AuthorizationCodeQuery, DeviceCodeVerifyQuery, Prompt},
};
use ruma::OwnedUserId; use ruma::OwnedUserId;
use url::Url; use serde::Deserialize;
use crate::{ use crate::{
ROUTE_PREFIX, WebError, ROUTE_PREFIX, WebError,
@@ -14,7 +17,7 @@
pages::{ pages::{
GET_POST, Result, TemplateContext, GET_POST, Result, TemplateContext,
account::register::{RegisterQuery, RequestedRegistrationFlow}, account::register::{RegisterQuery, RequestedRegistrationFlow},
components::{Avatar, AvatarType, ClientScopes}, components::{Avatar, ClientScopes, UserCard},
}, },
response, response,
session::{LoginIntent, LoginQuery, LoginTarget, User}, session::{LoginIntent, LoginQuery, LoginTarget, User},
@@ -22,7 +25,9 @@
}; };
pub(crate) fn build() -> Router<crate::State> { 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! { template! {
@@ -30,12 +35,9 @@ struct Grant use "grant.html.j2" {
logout_query: String, logout_query: String,
user_id: OwnedUserId, user_id: OwnedUserId,
user_avatar: Avatar, user_avatar: Avatar,
client_uri: Url, client_metadata: ClientMetadata,
client_name: String, scopes: ClientScopes,
client_avatar: Avatar, device_code: Option<String>
policy_uri: Option<Url>,
tos_uri: Option<Url>,
scopes: ClientScopes
} }
} }
@@ -102,27 +104,6 @@ async fn route_authorization_code(
let scopes = query.scope.to_scopes().map_err(WebError::BadRequest)?; 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; let user_avatar = Avatar::for_local_user(&services, &user_id).await;
response!(Grant::new( response!(Grant::new(
@@ -135,11 +116,102 @@ async fn route_authorization_code(
.unwrap(), .unwrap(),
user_id, user_id,
user_avatar, user_avatar,
client.client_uri.clone(), client,
client_name,
client_avatar,
client.policy_uri.clone(),
client.tos_uri.clone(),
ClientScopes { scopes }, 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; font-style: italic;
text-align: center; 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 -%} {%- endblock -%}
{%- block content -%} {%- block content -%}
{% let client_name = client_metadata.client_name.as_deref().unwrap_or("Unknown application") %}
<div class="panel narrow"> <div class="panel narrow">
<h1>Authorize {{ client_name }}</h1> <h1>Authorize {{ client_name }}</h1>
<div class="avatars"> <div class="avatars">
@@ -18,17 +19,17 @@ Authorize client
<div class="separator" aria-hidden="true"> <div class="separator" aria-hidden="true">
</div> </div>
{{ client_avatar }} {{ Avatar::for_client(client_metadata) }}
</div> </div>
<div class="identity"> <div class="identity">
Signed in as <code>{{ user_id }}</code>. <a href="{{ crate::ROUTE_PREFIX }}/account/logout?{{ logout_query }}">Switch accounts</a> Signed in as <code>{{ user_id }}</code>. <a href="{{ crate::ROUTE_PREFIX }}/account/logout?{{ logout_query }}">Switch accounts</a>
</div> </div>
<p> <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: your permission to:
{{ scopes }} {{ scopes }}
</p> </p>
{% match (&policy_uri, &tos_uri) %} {% match (&client_metadata.policy_uri, &client_metadata.tos_uri) %}
{% when (Some(policy_uri), Some(tos_uri)) %} {% when (Some(policy_uri), Some(tos_uri)) %}
<p> <p>
{{ client_name }}'s <a href="{{ policy_uri }}">policies</a> {{ client_name }}'s <a href="{{ policy_uri }}">policies</a>
@@ -49,6 +50,10 @@ Authorize client
{% endmatch %} {% endmatch %}
<form method="post"> <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> <button type="submit">Continue</button>
</form> </form>
</div> </div>
+5 -1
View File
@@ -9,7 +9,7 @@
http::request::Parts, http::request::Parts,
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use conduwuit_service::oauth::grant::AuthorizationCodeQuery; use conduwuit_service::oauth::grant::{AuthorizationCodeQuery, DeviceCodeVerifyQuery};
use ruma::{OwnedUserId, UserId}; use ruma::{OwnedUserId, UserId};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tower_sessions::Session; use tower_sessions::Session;
@@ -38,6 +38,7 @@ pub(crate) enum LoginTarget {
ChangeEmail, ChangeEmail,
CrossSigningReset, CrossSigningReset,
Deactivate, Deactivate,
DeviceCode(DeviceCodeVerifyQuery),
DeviceInfo(DevicePath), DeviceInfo(DevicePath),
RemoveDevice(DevicePath), RemoveDevice(DevicePath),
} }
@@ -59,6 +60,9 @@ pub(crate) fn target_path(&self) -> String {
| Self::ChangeEmail => "account/email/change/".into(), | Self::ChangeEmail => "account/email/change/".into(),
| Self::CrossSigningReset => "account/cross_signing_reset".into(), | Self::CrossSigningReset => "account/cross_signing_reset".into(),
| Self::Deactivate => "account/deactivate".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::DeviceInfo(path) => format!("account/device/{}/", path.device).into(),
| Self::RemoveDevice(path) => format!("account/device/{}/remove", path.device).into(), | Self::RemoveDevice(path) => format!("account/device/{}/remove", path.device).into(),
}; };