From 4301fd9378c46e1f77860086479b03ad400674d6 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 6 Dec 2023 18:57:38 +0100 Subject: [PATCH] Setup the device link form page --- crates/handlers/src/lib.rs | 4 + crates/handlers/src/oauth2/device/link.rs | 102 ++++++++++++++++++++++ crates/handlers/src/oauth2/device/mod.rs | 15 ++++ crates/handlers/src/oauth2/mod.rs | 1 + crates/router/src/endpoints.rs | 17 ++++ crates/templates/src/context.rs | 58 +++++++++++- crates/templates/src/lib.rs | 17 ++-- templates/pages/device_link.html | 53 +++++++++++ 8 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 crates/handlers/src/oauth2/device/link.rs create mode 100644 crates/handlers/src/oauth2/device/mod.rs create mode 100644 templates/pages/device_link.html diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 07f26368..4a62a187 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -404,6 +404,10 @@ where mas_router::UpstreamOAuth2Link::route(), get(self::upstream_oauth2::link::get).post(self::upstream_oauth2::link::post), ) + .route( + mas_router::DeviceCodeLink::route(), + get(self::oauth2::device::link::get).post(self::oauth2::device::link::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/link.rs b/crates/handlers/src/oauth2/device/link.rs new file mode 100644 index 00000000..b30ea5ab --- /dev/null +++ b/crates/handlers/src/oauth2/device/link.rs @@ -0,0 +1,102 @@ +// 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 axum::{ + extract::{Query, State}, + response::IntoResponse, + Form, +}; +use axum_extra::response::Html; +use mas_axum_utils::{ + cookies::CookieJar, + csrf::{CsrfExt, ProtectedForm}, + FancyError, +}; +use mas_storage::{BoxClock, BoxRng}; +use mas_templates::{ + DeviceLinkContext, DeviceLinkFormField, FieldError, FormState, TemplateContext, Templates, +}; +use serde::{Deserialize, Serialize}; + +use crate::PreferredLanguage; + +// We use this struct for both the form and the query parameters. This is useful +// to build a form state from the query parameters. The query parameter is only +// really used when the `verification_uri_complete` feature of the device code +// grant is used. +#[derive(Serialize, Deserialize)] +pub struct Params { + code: String, +} + +#[tracing::instrument(name = "handlers.oauth2.device.link.get", skip_all, err)] +pub(crate) async fn get( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + cookie_jar: CookieJar, + query: Option>, +) -> Result { + let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); + + let mut form_state = FormState::default(); + + // XXX: right now we just get the code from the query to pre-fill the form. We + // may want to make the form readonly instead at some point? tbd + if let Some(Query(params)) = query { + // Validate that it's a full code + if params.code.len() == 6 && params.code.chars().all(|c| c.is_ascii_alphanumeric()) { + form_state = FormState::from_form(¶ms); + } + }; + + 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)?; + + Ok((cookie_jar, Html(content))) +} + +#[tracing::instrument(name = "handlers.oauth2.device.link.post", skip_all, err)] +pub(crate) async fn post( + mut rng: BoxRng, + clock: BoxClock, + PreferredLanguage(locale): PreferredLanguage, + State(templates): State, + cookie_jar: CookieJar, + Form(form): Form>, +) -> 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); + + // 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 ctx = DeviceLinkContext::new() + .with_form_state(form_state) + .with_csrf(csrf_token.form_value()) + .with_language(locale); + + let content = templates.render_device_link(&ctx)?; + + Ok((cookie_jar, Html(content))) +} diff --git a/crates/handlers/src/oauth2/device/mod.rs b/crates/handlers/src/oauth2/device/mod.rs new file mode 100644 index 00000000..b822a3e1 --- /dev/null +++ b/crates/handlers/src/oauth2/device/mod.rs @@ -0,0 +1,15 @@ +// 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. + +pub mod link; diff --git a/crates/handlers/src/oauth2/mod.rs b/crates/handlers/src/oauth2/mod.rs index 62e84ff3..3001a803 100644 --- a/crates/handlers/src/oauth2/mod.rs +++ b/crates/handlers/src/oauth2/mod.rs @@ -32,6 +32,7 @@ use thiserror::Error; pub mod authorization; pub mod consent; +pub mod device; pub mod discovery; pub mod introspection; pub mod keys; diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index bb6f43ba..aea28e58 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -689,6 +689,23 @@ impl Route for UpstreamOAuth2Link { } } +/// `GET|POST /link` +#[derive(Default, Serialize, Deserialize, Debug, Clone)] +pub struct DeviceCodeLink { + code: Option, +} + +impl Route for DeviceCodeLink { + type Query = DeviceCodeLink; + fn route() -> &'static str { + "/link" + } + + fn query(&self) -> Option<&Self::Query> { + Some(self) + } +} + /// `GET /assets` pub struct StaticAsset { path: String, diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 4d861c66..ab15b711 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1,4 +1,4 @@ -// Copyright 2021 The Matrix.org Foundation C.I.C. +// Copyright 2021-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. @@ -104,7 +104,7 @@ impl TemplateContext for () { } /// Context with a specified locale in it -#[derive(Serialize)] +#[derive(Serialize, Debug)] pub struct WithLanguage { lang: String, @@ -143,7 +143,7 @@ impl TemplateContext for WithLanguage { } /// Context with a CSRF token in it -#[derive(Serialize)] +#[derive(Serialize, Debug)] pub struct WithCsrf { csrf_token: String, @@ -1023,6 +1023,58 @@ impl TemplateContext for UpstreamRegister { } } +/// Form fields on the device link page +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DeviceLinkFormField { + /// The device code field + Code, +} + +impl FormField for DeviceLinkFormField { + fn keep(&self) -> bool { + match self { + Self::Code => true, + } + } +} + +/// Context used by the `device_link.html` template +#[derive(Serialize, Default, Debug)] +pub struct DeviceLinkContext { + form_state: FormState, +} + +impl DeviceLinkContext { + /// Constructs a new context with an existing linked user + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the form state + #[must_use] + pub fn with_form_state(mut self, form_state: FormState) -> Self { + self.form_state = form_state; + self + } +} + +impl TemplateContext for DeviceLinkContext { + fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + vec![ + Self::new(), + Self::new().with_form_state( + FormState::default() + .with_error_on_field(DeviceLinkFormField::Code, FieldError::Required), + ), + ] + } +} + /// 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 0389eefa..27124c05 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -42,13 +42,13 @@ mod macros; pub use self::{ context::{ - AppContext, CompatSsoContext, ConsentContext, 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, 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}, }; @@ -365,6 +365,9 @@ register_templates! { /// Render the upstream register screen pub fn render_upstream_oauth2_do_register(WithLanguage>) { "pages/upstream_oauth2/do_register.html" } + + /// Render the device code link page + pub fn render_device_link(WithLanguage>) { "pages/device_link.html" } } impl Templates { diff --git a/templates/pages/device_link.html b/templates/pages/device_link.html new file mode 100644 index 00000000..f204353c --- /dev/null +++ b/templates/pages/device_link.html @@ -0,0 +1,53 @@ +{# +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 %} +
+
+ {{ icon.link() }} +
+ +
+

Link a device

+

Enter the code displayed on your device

+
+
+ +
+ + + {% call(f) field.field(label="Device code", name="code", class="mb-4 self-center", form_state=form_state) %} +
+ + + {% for _ in range(6) %} + + {% endfor %} +
+ {% endcall %} + + {{ button.button(text=_("action.continue")) }} +
+{% endblock content %} +