From 4c9426a13f7427d3318861f49aa36dfe7ee5055e Mon Sep 17 00:00:00 2001 From: Ginger Date: Sun, 12 Jul 2026 17:32:13 -0400 Subject: [PATCH] feat: Add support for OAuth2 device auth flow --- changelog.d/+16e3dce7.feature.md | 1 + src/api/client/oauth/device.rs | 13 + src/api/client/oauth/mod.rs | 3 + src/api/client/oauth/server_metadata.rs | 7 +- src/service/oauth/client_metadata.rs | 2 + src/service/oauth/grant.rs | 78 ++++-- src/service/oauth/mod.rs | 256 ++++++++++++++++-- src/web/pages/components/mod.rs | 20 +- src/web/pages/oauth/grant.rs | 144 +++++++--- src/web/pages/resources/grant.css | 14 + .../pages/templates/device_code_grant.html.j2 | 50 ++++ src/web/pages/templates/grant.html.j2 | 11 +- src/web/session/mod.rs | 6 +- 13 files changed, 512 insertions(+), 93 deletions(-) create mode 100644 changelog.d/+16e3dce7.feature.md create mode 100644 src/api/client/oauth/device.rs create mode 100644 src/web/pages/templates/device_code_grant.html.j2 diff --git a/changelog.d/+16e3dce7.feature.md b/changelog.d/+16e3dce7.feature.md new file mode 100644 index 000000000..45474aaab --- /dev/null +++ b/changelog.d/+16e3dce7.feature.md @@ -0,0 +1 @@ +Added support for the OAuth2 device authorization flow. Contributed by @ginger diff --git a/src/api/client/oauth/device.rs b/src/api/client/oauth/device.rs new file mode 100644 index 000000000..ba765a1db --- /dev/null +++ b/src/api/client/oauth/device.rs @@ -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, + Form(request): Form, +) -> impl IntoResponse { + match services.oauth.request_device_code(request).await { + | Ok(response) => Ok(Json(response)), + | Err(err) => Err((StatusCode::BAD_REQUEST, Json(err))), + } +} diff --git a/src/api/client/oauth/mod.rs b/src/api/client/oauth/mod.rs index 0c3e8fa4a..48f58d32b 100644 --- a/src/api/client/oauth/mod.rs +++ b/src/api/client/oauth/mod.rs @@ -10,6 +10,7 @@ use http::StatusCode; use serde_json::json; pub(crate) use server_metadata::*; +mod device; mod register_client; mod server_metadata; mod token; @@ -20,6 +21,7 @@ const JWKS_URI_PATH: &str = "client/keys.json"; const CLIENT_REGISTER_PATH: &str = "client/register"; const TOKEN_REVOKE_PATH: &str = "client/revoke"; const TOKEN_PATH: &str = "grant/token"; +const DEVICE_AUTHORIZATION_PATH: &str = "device"; const ACCOUNT_MANAGEMENT_PATH: &str = concat!(conduwuit_core::ROUTE_PREFIX, "/account/deeplink"); pub(crate) fn router(state: crate::State) -> Router { @@ -53,4 +55,5 @@ fn oauth_router() -> Router { .route(concat!("/", JWKS_URI_PATH), get(async || Json(json!({"keys": []})))) .route(concat!("/", TOKEN_PATH), post(token::token_route)) .route(concat!("/", TOKEN_REVOKE_PATH), post(token::revoke_token_route)) + .route(concat!("/", DEVICE_AUTHORIZATION_PATH), post(device::device_authorization_route)) } diff --git a/src/api/client/oauth/server_metadata.rs b/src/api/client/oauth/server_metadata.rs index e3714d913..331289af2 100644 --- a/src/api/client/oauth/server_metadata.rs +++ b/src/api/client/oauth/server_metadata.rs @@ -12,8 +12,8 @@ use service::Services; use crate::{ Ruma, client::oauth::{ - ACCOUNT_MANAGEMENT_PATH, AUTH_CODE_PATH, CLIENT_REGISTER_PATH, JWKS_URI_PATH, TOKEN_PATH, - TOKEN_REVOKE_PATH, + ACCOUNT_MANAGEMENT_PATH, AUTH_CODE_PATH, CLIENT_REGISTER_PATH, DEVICE_AUTHORIZATION_PATH, + JWKS_URI_PATH, TOKEN_PATH, TOKEN_REVOKE_PATH, }, }; @@ -60,7 +60,8 @@ pub(crate) async fn authorization_server_metadata(services: &Services) -> Value ], "authorization_endpoint": endpoint_base.join(AUTH_CODE_PATH).unwrap(), "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(), "jwks_uri": endpoint_base.join(JWKS_URI_PATH).unwrap(), "prompt_values_supported": prompt_values_supported, diff --git a/src/service/oauth/client_metadata.rs b/src/service/oauth/client_metadata.rs index 99d6435ce..3aa4d5943 100644 --- a/src/service/oauth/client_metadata.rs +++ b/src/service/oauth/client_metadata.rs @@ -132,6 +132,8 @@ pub enum ApplicationType { #[serde(rename_all = "snake_case")] pub enum GrantType { AuthorizationCode, + #[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")] + DeviceCode, RefreshToken, } diff --git a/src/service/oauth/grant.rs b/src/service/oauth/grant.rs index 0a027f637..f53c18600 100644 --- a/src/service/oauth/grant.rs +++ b/src/service/oauth/grant.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use url::Url; use super::client_metadata::ResponseType; +use crate::oauth::client_metadata::GrantType; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuthorizationCodeQuery { @@ -29,6 +30,33 @@ pub struct AuthorizationCodeQuery { pub prompt: Option, } +#[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, + pub expires_in: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DeviceCodeVerifyQuery { + pub user_code: Option, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] #[non_exhaustive] @@ -126,19 +154,29 @@ pub struct OAuthError { impl OAuthError { #[must_use] - pub const fn invalid_request(error_description: &'static str) -> Self { + pub fn new(error: ErrorCode, error_description: String) -> 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), } } + #[must_use] + pub const fn invalid_request(error_description: &'static str) -> Self { + Self::new_static(ErrorCode::InvalidRequest, error_description) + } + #[must_use] pub const fn invalid_grant(error_description: &'static str) -> Self { - Self { - error: ErrorCode::InvalidGrant, - error_description: Cow::Borrowed(error_description), - } + Self::new_static(ErrorCode::InvalidGrant, error_description) } } @@ -158,35 +196,41 @@ pub enum ErrorCode { InvalidScope, InvalidGrant, InvalidClientMetadata, + AuthorizationPending, + ExpiredToken, } -#[derive(Serialize, Deserialize)] -pub struct AuthorizationCodeResponse { - pub state: String, - pub code: String, +#[derive(Deserialize)] +pub struct TokenRequest { + pub client_id: String, + #[serde(flatten)] + pub request: TokenRequestType, } #[derive(Deserialize)] #[serde(tag = "grant_type", rename_all = "snake_case")] -pub enum TokenRequest { +pub enum TokenRequestType { AuthorizationCode { code: String, redirect_uri: Url, - client_id: String, code_verifier: String, }, + #[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")] + DeviceCode { + device_code: String, + }, RefreshToken { - client_id: String, refresh_token: String, }, } -impl TokenRequest { +impl TokenRequestType { #[must_use] - pub fn client_id(&self) -> &str { + pub fn grant_type(&self) -> GrantType { match self { - | Self::AuthorizationCode { client_id, .. } - | Self::RefreshToken { client_id, .. } => client_id, + | Self::AuthorizationCode { .. } => GrantType::AuthorizationCode, + | Self::DeviceCode { .. } => GrantType::DeviceCode, + | Self::RefreshToken { .. } => GrantType::RefreshToken, } } } diff --git a/src/service/oauth/mod.rs b/src/service/oauth/mod.rs index f90edc4b8..ba8958cfa 100644 --- a/src/service/oauth/mod.rs +++ b/src/service/oauth/mod.rs @@ -12,17 +12,19 @@ use conduwuit::{ use database::{Deserialized, Json, Map}; use itertools::Itertools; use lru_cache::LruCache; +use rand::distr::{Distribution, slice::Choose}; use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId}; use serde::{Deserialize, Serialize}; use url::Url; use crate::{ - Dep, + Dep, config, oauth::{ client_metadata::{ApplicationType, ClientMetadata, ResponseType}, grant::{ - AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, ErrorCode, - OAuthError, ResponseMode, Scope, TokenRequest, TokenResponse, TokenType, + AuthorizationCodeQuery, AuthorizationCodeResponse, CodeChallengeMethod, + DeviceCodeRequest, DeviceCodeResponse, ErrorCode, OAuthError, ResponseMode, Scope, + TokenRequest, TokenRequestType, TokenResponse, TokenType, }, }, users::{self, DeviceToken}, @@ -35,7 +37,8 @@ pub struct Service { services: Services, db: Data, tickets: Mutex>>, - pending_code_grants: tokio::sync::Mutex>, + pending_auth_code_grants: tokio::sync::Mutex>, + pending_device_code_grants: tokio::sync::Mutex>, } struct Data { @@ -46,6 +49,7 @@ struct Data { struct Services { users: Dep, + config: Dep, } #[derive(Debug, Deserialize, Serialize)] @@ -62,7 +66,7 @@ struct RefreshTokenInfo { device_id: OwnedDeviceId, } -struct PendingCodeGrant { +struct PendingAuthCodeGrant { authorizing_user: OwnedUserId, requested_scopes: BTreeSet, client_name: Option, @@ -72,12 +76,8 @@ struct PendingCodeGrant { requested_at: SystemTime, } -impl PendingCodeGrant { +impl PendingAuthCodeGrant { 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] pub(crate) fn is_valid_for(&self, client_id: &str) -> bool { @@ -90,6 +90,43 @@ impl PendingCodeGrant { } } +struct PendingDeviceCodeGrant { + state: DeviceCodeGrantState, + requested_scopes: BTreeSet, + client_name: Option, + 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, +} + /// A time-limited grant for a client to perform some sensitive action. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum OAuthTicket { @@ -112,6 +149,7 @@ impl crate::Service for Service { Ok(Arc::new(Self { services: Services { users: args.depend::("users"), + config: args.depend::("config"), }, db: Data { clientid_clientmetadata: args.db["clientid_clientmetadata"].clone(), @@ -119,8 +157,11 @@ impl crate::Service for Service { refreshtoken_refreshtokeninfo: args.db["refreshtoken_refreshtokeninfo"].clone(), }, tickets: Mutex::default(), - pending_code_grants: tokio::sync::Mutex::new(LruCache::new( - Self::MAX_PENDING_CODE_GRANTS, + pending_auth_code_grants: tokio::sync::Mutex::new(LruCache::new( + 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, // to prevent unbounded memory use if someone decides to repeatedly reload the // grant page. - const MAX_PENDING_CODE_GRANTS: usize = 100; + const MAX_PENDING_GRANTS: usize = 100; 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_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 { metadata.validate().map_err(|error| OAuthError { error: ErrorCode::InvalidClientMetadata, @@ -231,14 +282,14 @@ impl Service { | ResponseMode::Query => '?', }; - let code = PendingCodeGrant::generate_code(); + let code = Self::generate_token(); info!( client_id = &query.client_id, client_name = &client_metadata.client_name, ?requested_scopes, ?authorizing_user, - "Issuing oauth authorization code" + "Issuing OAuth authorization code" ); let redirect_uri = format!( @@ -252,7 +303,7 @@ impl Service { .unwrap(), ); - let pending_grant = PendingCodeGrant { + let pending_grant = PendingAuthCodeGrant { authorizing_user, requested_scopes, client_name: client_metadata.client_name, @@ -262,7 +313,7 @@ impl Service { requested_at: SystemTime::now(), }; - self.pending_code_grants + self.pending_auth_code_grants .lock() .await .insert(code, pending_grant); @@ -270,15 +321,129 @@ impl Service { Ok(redirect_uri) } + pub async fn request_device_code( + &self, + query: DeviceCodeRequest, + ) -> Result { + let Some(client_metadata) = self.get_client_metadata(&query.client_id).await else { + return Err(OAuthError::invalid_grant("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 { + 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 { + let TokenRequest { client_id, request } = request; + + let Some(client_metadata) = self.get_client_metadata(&client_id).await else { + return Err(OAuthError::invalid_request("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 { - | TokenRequest::AuthorizationCode { - code, - redirect_uri, - client_id, - code_verifier, - } => { - let mut pending_grants = self.pending_code_grants.lock().await; + | TokenRequestType::AuthorizationCode { code, redirect_uri, code_verifier } => { + let mut pending_grants = self.pending_auth_code_grants.lock().await; let Some(pending_grant) = pending_grants .remove(&code) @@ -305,7 +470,39 @@ impl Service { ) .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, } } @@ -364,11 +561,10 @@ impl Service { .await .is_ok() { - return Err(OAuthError { - error: ErrorCode::InvalidScope, - error_description: "A device with the supplied ID already exists for this user" - .into(), - }); + return Err(OAuthError::new_static( + ErrorCode::InvalidScope, + "A device with the supplied ID already exists for this user", + )); } self.services diff --git a/src/web/pages/components/mod.rs b/src/web/pages/components/mod.rs index a95cf4502..6ca61fd7b 100644 --- a/src/web/pages/components/mod.rs +++ b/src/web/pages/components/mod.rs @@ -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)] diff --git a/src/web/pages/oauth/grant.rs b/src/web/pages/oauth/grant.rs index aa2825e1d..5ebc4b85b 100644 --- a/src/web/pages/oauth/grant.rs +++ b/src/web/pages/oauth/grant.rs @@ -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 { - 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, - tos_uri: Option, - scopes: ClientScopes + client_metadata: ClientMetadata, + scopes: ClientScopes, + device_code: Option } } @@ -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, + Extension(context): Extension, + user: User, + Expect(Query(query)): Expect>, + PostForm(form): PostForm, +) -> 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 + })), + } +} diff --git a/src/web/pages/resources/grant.css b/src/web/pages/resources/grant.css index 6848756d1..157998753 100644 --- a/src/web/pages/resources/grant.css +++ b/src/web/pages/resources/grant.css @@ -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; + } +} diff --git a/src/web/pages/templates/device_code_grant.html.j2 b/src/web/pages/templates/device_code_grant.html.j2 new file mode 100644 index 000000000..b177daef7 --- /dev/null +++ b/src/web/pages/templates/device_code_grant.html.j2 @@ -0,0 +1,50 @@ +{% extends "_layout.html.j2" %} + +{%- block head -%} + +{%- endblock -%} + +{%- block title -%} +Authorize client +{%- endblock -%} + +{%- block content -%} +
+

Authorize device

+ {{ user_card }} + {% match body %} + {% when DeviceCodeGrantBody::Lookup { user_code_error } %} +

+ Enter the code displayed on your device, or in the app you're signing into. +

+

+ Never enter a code given to you by someone else. + Your homeserver administrator will never ask you to enter a code on this page. +

+ +
+

+

+ + +
+ {% if user_code_error %} + Invalid code + {% endif %} +

+ + +
+ {% when DeviceCodeGrantBody::Success %} +

Success! 🎉 You can close this tab and return to your device.

+ {% endmatch %} +
+{% endblock %} diff --git a/src/web/pages/templates/grant.html.j2 b/src/web/pages/templates/grant.html.j2 index ccb9826f7..711d384e2 100644 --- a/src/web/pages/templates/grant.html.j2 +++ b/src/web/pages/templates/grant.html.j2 @@ -9,6 +9,7 @@ Authorize client {%- endblock -%} {%- block content -%} +{% let client_name = client_metadata.client_name.as_deref().unwrap_or("Unknown application") %}

Authorize {{ client_name }}

@@ -18,17 +19,17 @@ Authorize client - {{ client_avatar }} + {{ Avatar::for_client(client_metadata) }}
Signed in as {{ user_id }}. Switch accounts

- {{ client_name }} ({{ client_uri.domain().unwrap() }}) would like + {{ client_name }} ({{ client_metadata.client_uri.domain().unwrap() }}) would like your permission to: {{ scopes }}

- {% match (&policy_uri, &tos_uri) %} + {% match (&client_metadata.policy_uri, &client_metadata.tos_uri) %} {% when (Some(policy_uri), Some(tos_uri)) %}

{{ client_name }}'s policies @@ -49,6 +50,10 @@ Authorize client {% endmatch %}

+ + {% if let Some(device_code) = device_code %} + + {% endif %}
diff --git a/src/web/session/mod.rs b/src/web/session/mod.rs index 526915243..b49d866e8 100644 --- a/src/web/session/mod.rs +++ b/src/web/session/mod.rs @@ -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(), };