diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 86d18749..e0a86e27 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -412,6 +412,10 @@ where mas_router::DeviceCodeLink::route(), get(self::oauth2::device::link::get).post(self::oauth2::device::link::post), ) + .route( + mas_router::DeviceCodeConsent::route(), + get(self::oauth2::device::consent::get).post(self::oauth2::device::consent::post), + ) .layer(AndThenLayer::new( move |response: axum::response::Response| async move { if response.status().is_server_error() { diff --git a/crates/handlers/src/oauth2/device/authorize.rs b/crates/handlers/src/oauth2/device/authorize.rs index 7564bb98..75a6c6fb 100644 --- a/crates/handlers/src/oauth2/device/authorize.rs +++ b/crates/handlers/src/oauth2/device/authorize.rs @@ -155,8 +155,7 @@ pub(crate) async fn post( TypedHeader(CacheControl::new().with_no_store()), TypedHeader(Pragma::no_cache()), Json(response), - ) - .into_response()) + )) } #[cfg(test)] diff --git a/crates/handlers/src/oauth2/device/consent.rs b/crates/handlers/src/oauth2/device/consent.rs new file mode 100644 index 00000000..6e97c823 --- /dev/null +++ b/crates/handlers/src/oauth2/device/consent.rs @@ -0,0 +1,184 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use anyhow::Context; +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + Form, +}; +use axum_extra::response::Html; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt, ProtectedForm}, + FancyError, SessionInfoExt, +}; +use mas_router::UrlBuilder; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; +use mas_templates::{DeviceConsentContext, TemplateContext, Templates}; +use serde::Deserialize; +use tracing::warn; +use ulid::Ulid; + +use crate::{BoundActivityTracker, PreferredLanguage}; + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +enum Action { + Consent, + Reject, +} + +#[derive(Deserialize, Debug)] +pub(crate) struct ConsentForm { + action: Action, +} + +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + activity_tracker: BoundActivityTracker, + cookie_jar: CookieJar, + Path(grant_id): Path, +) -> Result { + let (session_info, cookie_jar) = cookie_jar.session_info(); + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let maybe_session = session_info.load_session(&mut repo).await?; + + let Some(session) = maybe_session else { + let login = mas_router::Login::and_continue_device_code_grant(grant_id); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); + }; + + activity_tracker + .record_browser_session(&clock, &session) + .await; + + // TODO: better error handling + let grant = repo + .oauth2_device_code_grant() + .lookup(grant_id) + .await? + .context("Device grant not found")?; + + if grant.expires_at < clock.now() { + return Err(FancyError::from(anyhow::anyhow!("Grant is expired"))); + } + + let client = repo + .oauth2_client() + .lookup(grant.client_id) + .await? + .context("Client not found")?; + + let ctx = DeviceConsentContext::new(grant, client) + .with_session(session) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let rendered = templates + .render_device_consent(&ctx) + .context("Failed to render template")?; + + Ok((cookie_jar, Html(rendered)).into_response()) +} + +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + State(url_builder): State, + mut repo: BoxRepository, + activity_tracker: BoundActivityTracker, + cookie_jar: CookieJar, + Path(grant_id): Path, + Form(form): Form>, +) -> Result { + let (session_info, cookie_jar) = cookie_jar.session_info(); + let form = cookie_jar.verify_form(&clock, form)?; + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let maybe_session = session_info.load_session(&mut repo).await?; + + let Some(session) = maybe_session else { + let login = mas_router::Login::and_continue_device_code_grant(grant_id); + return Ok((cookie_jar, url_builder.redirect(&login)).into_response()); + }; + + activity_tracker + .record_browser_session(&clock, &session) + .await; + + // TODO: better error handling + let grant = repo + .oauth2_device_code_grant() + .lookup(grant_id) + .await? + .context("Device grant not found")?; + + if grant.expires_at < clock.now() { + return Err(FancyError::from(anyhow::anyhow!("Grant is expired"))); + } + + let client = repo + .oauth2_client() + .lookup(grant.client_id) + .await? + .context("Client not found")?; + + // TODO: run through the policy + let grant = if grant.is_pending() { + match form.action { + Action::Consent => { + repo.oauth2_device_code_grant() + .fulfill(&clock, grant, &session) + .await? + } + Action::Reject => { + repo.oauth2_device_code_grant() + .reject(&clock, grant, &session) + .await? + } + } + } else { + // XXX: In case we're not pending, let's just return the grant as-is + // since it might just be a form resubmission, and feedback is nice enough + warn!( + oauth2_device_code.id = %grant.id, + browser_session.id = %session.id, + user.id = %session.user.id, + "Grant is not pending", + ); + grant + }; + + repo.save().await?; + + let ctx = DeviceConsentContext::new(grant, client) + .with_session(session) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let rendered = templates + .render_device_consent(&ctx) + .context("Failed to render template")?; + + Ok((cookie_jar, Html(rendered)).into_response()) +} diff --git a/crates/handlers/src/oauth2/device/link.rs b/crates/handlers/src/oauth2/device/link.rs index b30ea5ab..f3b6a90e 100644 --- a/crates/handlers/src/oauth2/device/link.rs +++ b/crates/handlers/src/oauth2/device/link.rs @@ -14,7 +14,7 @@ use axum::{ extract::{Query, State}, - response::IntoResponse, + response::{IntoResponse, Response}, Form, }; use axum_extra::response::Html; @@ -23,7 +23,8 @@ use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, FancyError, }; -use mas_storage::{BoxClock, BoxRng}; +use mas_router::UrlBuilder; +use mas_storage::{BoxClock, BoxRepository, BoxRng}; use mas_templates::{ DeviceLinkContext, DeviceLinkFormField, FieldError, FormState, TemplateContext, Templates, }; @@ -76,27 +77,42 @@ pub(crate) async fn get( pub(crate) async fn post( mut rng: BoxRng, clock: BoxClock, + mut repo: BoxRepository, PreferredLanguage(locale): PreferredLanguage, State(templates): State, + State(url_builder): State, cookie_jar: CookieJar, Form(form): Form>, -) -> Result { +) -> Result { let form = cookie_jar.verify_form(&clock, form)?; let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let form_state = FormState::from_form(&form) - .with_error_on_field(DeviceLinkFormField::Code, FieldError::Required); + let code = form.code.to_uppercase(); + let grant = repo + .oauth2_device_code_grant() + .find_by_user_code(&code) + .await? + // XXX: We should have different error messages for already exchanged and expired + .filter(|grant| grant.is_pending()) + .filter(|grant| grant.expires_at > clock.now()); - // TODO: find the device code grant in the database - // and redirect to /oauth2/device/link/:id - // That then will trigger a login if we don't have a session + let Some(grant) = grant else { + let form_state = FormState::from_form(&form) + .with_error_on_field(DeviceLinkFormField::Code, FieldError::Invalid); - let ctx = DeviceLinkContext::new() - .with_form_state(form_state) - .with_csrf(csrf_token.form_value()) - .with_language(locale); + let ctx = DeviceLinkContext::new() + .with_form_state(form_state) + .with_csrf(csrf_token.form_value()) + .with_language(locale); - let content = templates.render_device_link(&ctx)?; + let content = templates.render_device_link(&ctx)?; - Ok((cookie_jar, Html(content))) + return Ok((cookie_jar, Html(content)).into_response()); + }; + + // Redirect to the consent page + // This will in turn redirect to the login page if the user is not logged in + let destination = url_builder.redirect(&mas_router::DeviceCodeConsent::new(grant.id)); + + Ok((cookie_jar, destination).into_response()) } diff --git a/crates/handlers/src/oauth2/device/mod.rs b/crates/handlers/src/oauth2/device/mod.rs index 7236a11c..ef5c0ee1 100644 --- a/crates/handlers/src/oauth2/device/mod.rs +++ b/crates/handlers/src/oauth2/device/mod.rs @@ -13,4 +13,5 @@ // limitations under the License. pub mod authorize; +pub mod consent; pub mod link; diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index 1aeaf3b6..95454a9d 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -63,6 +63,16 @@ impl OptionalPostAuthAction { PostAuthContextInner::ContinueAuthorizationGrant { grant } } + PostAuthAction::ContinueDeviceCodeGrant { id } => { + let grant = repo + .oauth2_device_code_grant() + .lookup(id) + .await? + .context("Failed to load device code grant")?; + let grant = Box::new(grant); + PostAuthContextInner::ContinueDeviceCodeGrant { grant } + } + PostAuthAction::ContinueCompatSsoLogin { id } => { let login = repo .compat_sso_login() diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index dd8c06c0..172ecc52 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -24,6 +24,9 @@ pub enum PostAuthAction { ContinueAuthorizationGrant { id: Ulid, }, + ContinueDeviceCodeGrant { + id: Ulid, + }, ContinueCompatSsoLogin { id: Ulid, }, @@ -43,6 +46,11 @@ impl PostAuthAction { PostAuthAction::ContinueAuthorizationGrant { id } } + #[must_use] + pub const fn continue_device_code_grant(id: Ulid) -> Self { + PostAuthAction::ContinueDeviceCodeGrant { id } + } + #[must_use] pub const fn continue_compat_sso_login(id: Ulid) -> Self { PostAuthAction::ContinueCompatSsoLogin { id } @@ -63,6 +71,9 @@ impl PostAuthAction { Self::ContinueAuthorizationGrant { id } => { url_builder.redirect(&ContinueAuthorizationGrant(*id)) } + Self::ContinueDeviceCodeGrant { id } => { + url_builder.redirect(&DeviceCodeConsent::new(*id)) + } Self::ContinueCompatSsoLogin { id } => { url_builder.redirect(&CompatLoginSsoComplete::new(*id, None)) } @@ -203,6 +214,13 @@ impl Login { } } + #[must_use] + pub const fn and_continue_device_code_grant(id: Ulid) -> Self { + Self { + post_auth_action: Some(PostAuthAction::continue_device_code_grant(id)), + } + } + #[must_use] pub const fn and_continue_compat_sso_login(id: Ulid) -> Self { Self { @@ -266,6 +284,13 @@ impl Reauth { } } + #[must_use] + pub fn and_continue_device_code_grant(data: Ulid) -> Self { + Self { + post_auth_action: Some(PostAuthAction::continue_device_code_grant(data)), + } + } + /// Get a reference to the reauth's post auth action. #[must_use] pub fn post_auth_action(&self) -> Option<&PostAuthAction> { @@ -713,6 +738,30 @@ impl Route for DeviceCodeLink { } } +/// `GET|POST /link/:device_code_id` +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct DeviceCodeConsent { + id: Ulid, +} + +impl Route for DeviceCodeConsent { + type Query = (); + fn route() -> &'static str { + "/link/:device_code_id" + } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/link/{}", self.id).into() + } +} + +impl DeviceCodeConsent { + #[must_use] + pub fn new(id: Ulid) -> Self { + Self { id } + } +} + /// `POST /oauth2/device` #[derive(Default, Serialize, Deserialize, Debug, Clone)] pub struct OAuth2DeviceAuthorizationEndpoint; diff --git a/crates/router/src/traits.rs b/crates/router/src/traits.rs index 82b51711..f06acb6c 100644 --- a/crates/router/src/traits.rs +++ b/crates/router/src/traits.rs @@ -32,7 +32,12 @@ pub trait Route { let path = self.path(); if let Some(query) = self.query() { let query = serde_urlencoded::to_string(query).unwrap(); - format!("{path}?{query}").into() + + if query.is_empty() { + path + } else { + format!("{path}?{query}").into() + } } else { path } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index ab15b711..14e40ac8 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -18,15 +18,20 @@ mod branding; use std::fmt::Formatter; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use http::{Method, Uri, Version}; use mas_data_model::{ AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, - UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail, UserEmailVerification, + DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail, + UserEmailVerification, }; use mas_i18n::DataLocale; use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder}; -use rand::Rng; +use oauth2_types::scope::OPENID; +use rand::{ + distributions::{Alphanumeric, DistString}, + Rng, +}; use serde::{ser::SerializeStruct, Deserialize, Serialize}; use ulid::Ulid; use url::Url; @@ -346,6 +351,12 @@ pub enum PostAuthContextInner { grant: Box, }, + /// Continue a device code grant + ContinueDeviceCodeGrant { + /// The device code grant that will be continued after authentication + grant: Box, + }, + /// Continue legacy login /// TODO: add the login context in there ContinueCompatSsoLogin { @@ -1075,6 +1086,45 @@ impl TemplateContext for DeviceLinkContext { } } +/// Context used by the `device_consent.html` template +#[derive(Serialize, Debug)] +pub struct DeviceConsentContext { + grant: DeviceCodeGrant, + client: Client, +} + +impl DeviceConsentContext { + /// Constructs a new context with an existing linked user + #[must_use] + pub fn new(grant: DeviceCodeGrant, client: Client) -> Self { + Self { grant, client } + } +} + +impl TemplateContext for DeviceConsentContext { + fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + Client::samples(now, rng) + .into_iter() + .map(|client| { + let grant = DeviceCodeGrant { + id: Ulid::from_datetime_with_source(now.into(), rng), + state: mas_data_model::DeviceCodeGrantState::Pending, + client_id: client.id, + scope: [OPENID].into_iter().collect(), + user_code: Alphanumeric.sample_string(rng, 6).to_uppercase(), + device_code: Alphanumeric.sample_string(rng, 32), + created_at: now - Duration::minutes(5), + expires_at: now + Duration::minutes(25), + }; + Self { grant, client } + }) + .collect() + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 27124c05..3fc1a60a 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -42,13 +42,14 @@ mod macros; pub use self::{ context::{ - AppContext, CompatSsoContext, ConsentContext, DeviceLinkContext, DeviceLinkFormField, - EmailAddContext, EmailVerificationContext, EmailVerificationPageContext, EmptyContext, - ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext, - PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext, - ReauthFormField, RegisterContext, RegisterFormField, SiteBranding, TemplateContext, - UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField, - UpstreamSuggestLink, WithCsrf, WithLanguage, WithOptionalSession, WithSession, + AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext, + DeviceLinkFormField, EmailAddContext, EmailVerificationContext, + EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, + LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext, + PostAuthContextInner, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, + SiteBranding, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, + UpstreamRegisterFormField, UpstreamSuggestLink, WithCsrf, WithLanguage, + WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -368,6 +369,9 @@ register_templates! { /// Render the device code link page pub fn render_device_link(WithLanguage>) { "pages/device_link.html" } + + /// Render the device code consent page + pub fn render_device_consent(WithLanguage>>) { "pages/device_consent.html" } } impl Templates { diff --git a/frontend/src/templates.css b/frontend/src/templates.css index f4f1bfd9..6a9182b5 100644 --- a/frontend/src/templates.css +++ b/frontend/src/templates.css @@ -176,6 +176,15 @@ } } + &.success { + background-color: var(--cpd-color-bg-success-subtle); + + & svg { + color: var(--cpd-color-icon-success-primary); + } + } + + & svg { height: var(--cpd-space-10x); width: var(--cpd-space-10x); diff --git a/templates/pages/device_consent.html b/templates/pages/device_consent.html new file mode 100644 index 00000000..af176704 --- /dev/null +++ b/templates/pages/device_consent.html @@ -0,0 +1,124 @@ +{# +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +#} + +{% extends "base.html" %} + +{% block content %} + {% set client_name = client.client_name or client.client_id %} + + {% if grant.state == "pending" %} +
+ {% if client.logo_uri %} + + {% else %} + + {% endif %} + +
+

Allow access to your account?

+

+ {% if client.client_uri %} + {{ client_name }} + {% else %} + {{ client_name }} + {% endif %} + wants to access your account. This will allow {{ client_name }} to:

+
+
+ + + +
+ Make sure that you trust {{ client_name }}. + You may be sharing sensitive information with this site or app. + {% if client.policy_uri or client.tos_uri %} + Find out how {{ client_name }} will handle your data by reviewing its + {% if client.policy_uri %} + privacy policy{% if not client.tos_uri %}.{% endif %} + {% endif %} + {% if client.policy_uri and client.tos_uri%} + and + {% endif %} + {% if client.tos_uri %} + terms of service. + {% endif %} + {% endif %} +
+ +
+ This request was made on another device, which should display the following code: {{ grant.user_code }}. +
+ +
+
+ + + +
+ +
+

+ {{ _("mas.not_you", username=current_session.user.username) }} +

+ + {{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }} +
+
+ {% elif grant.state == "rejected" %} +
+
+ {{ icon.block() }} +
+ +
+

Access denied

+

+ You denied access to + {% if client.client_uri %} + {{ client_name }} + {%- else %} + {{ client_name -}} + {% endif -%}. You can now close this window. +

+
+ {% else %} +
+
+ {{ icon.check() }} +
+ +
+

Access granted

+

+ You granted access to + {% if client.client_uri %} + {{ client_name }} + {%- else %} + {{ client_name -}} + {% endif -%}. You can now close this window. +

+
+ {% endif %} +{% endblock content %} +