Implement the device consent logic

This commit is contained in:
Quentin Gliech
2023-12-08 14:23:34 +01:00
parent fc78e7bf7e
commit f866310d7e
12 changed files with 482 additions and 27 deletions

View File

@@ -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() {

View File

@@ -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)]

View File

@@ -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<Templates>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository,
activity_tracker: BoundActivityTracker,
cookie_jar: CookieJar,
Path(grant_id): Path<Ulid>,
) -> Result<Response, FancyError> {
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<Templates>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository,
activity_tracker: BoundActivityTracker,
cookie_jar: CookieJar,
Path(grant_id): Path<Ulid>,
Form(form): Form<ProtectedForm<ConsentForm>>,
) -> Result<Response, FancyError> {
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())
}

View File

@@ -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<Templates>,
State(url_builder): State<UrlBuilder>,
cookie_jar: CookieJar,
Form(form): Form<ProtectedForm<Params>>,
) -> Result<impl IntoResponse, FancyError> {
) -> Result<Response, FancyError> {
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())
}

View File

@@ -13,4 +13,5 @@
// limitations under the License.
pub mod authorize;
pub mod consent;
pub mod link;

View File

@@ -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()

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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<AuthorizationGrant>,
},
/// Continue a device code grant
ContinueDeviceCodeGrant {
/// The device code grant that will be continued after authentication
grant: Box<DeviceCodeGrant>,
},
/// 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<Utc>, rng: &mut impl Rng) -> Vec<Self>
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<T> {

View File

@@ -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<WithCsrf<DeviceLinkContext>>) { "pages/device_link.html" }
/// Render the device code consent page
pub fn render_device_consent(WithLanguage<WithCsrf<WithSession<DeviceConsentContext>>>) { "pages/device_consent.html" }
}
impl Templates {

View File

@@ -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);

View File

@@ -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" %}
<header class="page-heading">
{% if client.logo_uri %}
<img class="consent-client-icon image" referrerpolicy="no-referrer" src="{{ client.logo_uri }}" />
{% else %}
<div class="consent-client-icon generic">
{{ icon.web_browser() }}
</div>
{% endif %}
<div class="header">
<h1 class="title">Allow access to your account?</h1>
<p class="text">
{% if client.client_uri %}
<a target="_blank" href="{{ client.client_uri }}">{{ client_name }}</a>
{% else %}
{{ client_name }}
{% endif %}
wants to access your account. This will allow <span class="whitespace-nowrap">{{ client_name }}</span> to:</p>
</div>
</header>
<section class="consent-scope-list">
{{ scope.list(scopes=grant.scope) }}
</section>
<section class="text-center cpd-text-secondary cpd-text-body-md-regular">
<span class="font-semibold cpd-text-primary">Make sure that you trust <span class="whitespace-nowrap">{{ client_name }}</span>.</span>
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 %}
<a target="_blank" href="{{ client.policy_uri }}" class="cpd-link" data-kind="primary">privacy policy</a>{% if not client.tos_uri %}.{% endif %}
{% endif %}
{% if client.policy_uri and client.tos_uri%}
and
{% endif %}
{% if client.tos_uri %}
<a target="_blank" href="{{ client.tos_uri }}" class="cpd-link" data-kind="primary">terms of service</a>.
{% endif %}
{% endif %}
</section>
<section class="cpd-text-primary cpd-text-body-md-regular">
This request was made on another device, which should display the following code: {{ grant.user_code }}.
</section>
<section class="flex flex-col gap-6">
<form method="POST" class="cpd-form-root">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
<button type="submit" name="action" value="consent" class="cpd-button" data-kind="primary" data-size="lg">
{{ _("action.continue") }}
</button>
<button type="submit" name="action" value="reject" class="cpd-button" data-kind="destructive" data-size="lg">
{{ _("action.cancel") }}
</button>
</form>
<div class="flex gap-1 justify-center items-center">
<p class="cpd-text-secondary cpd-text-body-md-regular">
{{ _("mas.not_you", username=current_session.user.username) }}
</p>
{{ logout.button(text=_("action.sign_out"), csrf_token=csrf_token, post_logout_action=action, as_link=true) }}
</div>
</section>
{% elif grant.state == "rejected" %}
<header class="page-heading">
<div class="icon invalid">
{{ icon.block() }}
</div>
<div class="header">
<h1 class="title">Access denied</h1>
<p class="text">
You denied access to
{% if client.client_uri %}
<a target="_blank" href="{{ client.client_uri }}">{{ client_name }}</a>
{%- else %}
{{ client_name -}}
{% endif -%}. You can now close this window.
</div>
</header>
{% else %}
<header class="page-heading">
<div class="icon success">
{{ icon.check() }}
</div>
<div class="header">
<h1 class="title">Access granted</h1>
<p class="text">
You granted access to
{% if client.client_uri %}
<a target="_blank" href="{{ client.client_uri }}">{{ client_name }}</a>
{%- else %}
{{ client_name -}}
{% endif -%}. You can now close this window.
</div>
</header>
{% endif %}
{% endblock content %}