diff --git a/crates/axum-utils/src/url_builder.rs b/crates/axum-utils/src/url_builder.rs index f31fa2e9..dbb09243 100644 --- a/crates/axum-utils/src/url_builder.rs +++ b/crates/axum-utils/src/url_builder.rs @@ -46,7 +46,7 @@ impl UrlBuilder { /// OAuth 2.0 authorization endpoint #[must_use] pub fn oauth_authorization_endpoint(&self) -> Url { - self.base.join("oauth2/authorize").expect("build URL") + self.base.join("authorize").expect("build URL") } /// OAuth 2.0 token endpoint diff --git a/crates/data-model/src/oauth2/authorization_grant.rs b/crates/data-model/src/oauth2/authorization_grant.rs index 7cffc4e3..efa0108e 100644 --- a/crates/data-model/src/oauth2/authorization_grant.rs +++ b/crates/data-model/src/oauth2/authorization_grant.rs @@ -119,6 +119,14 @@ impl AuthorizationGrantStage { _ => Err(InvalidTransitionError), } } + + /// Returns `true` if the authorization grant stage is [`Pending`]. + /// + /// [`Pending`]: AuthorizationGrantStage::Pending + #[must_use] + pub fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } } impl From> for AuthorizationGrantStage<()> { @@ -166,6 +174,7 @@ pub struct AuthorizationGrant { pub response_type_token: bool, pub response_type_id_token: bool, pub created_at: DateTime, + pub requires_consent: bool, } impl From> for AuthorizationGrant<()> { @@ -185,6 +194,7 @@ impl From> for AuthorizationGrant response_type_token: g.response_type_token, response_type_id_token: g.response_type_id_token, created_at: g.created_at, + requires_consent: g.requires_consent, } } } diff --git a/crates/data-model/src/users.rs b/crates/data-model/src/users.rs index a184f352..b411f43e 100644 --- a/crates/data-model/src/users.rs +++ b/crates/data-model/src/users.rs @@ -89,6 +89,16 @@ impl From> for BrowserSession<()> { } } +impl BrowserSession { + pub fn was_authenticated_after(&self, after: DateTime) -> bool { + if let Some(auth) = &self.last_authentication { + auth.created_at > after + } else { + false + } + } +} + impl BrowserSession where T::BrowserSessionData: Default, diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 6c6262cd..ab57034e 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -119,13 +119,13 @@ where "/account/emails", get(self::views::account::emails::get).post(self::views::account::emails::post), ) - .route("/oauth2/authorize", get(self::oauth2::authorization::get)) + .route("/authorize", get(self::oauth2::authorization::get)) .route( - "/oauth2/authorize/step", - get(self::oauth2::authorization::step_get), + "/authorize/:grant_id", + get(self::oauth2::authorization::complete::get), ) .route( - "/consent", + "/consent/:grant_id", get(self::oauth2::consent::get).post(self::oauth2::consent::post), ) .merge(api_router) diff --git a/crates/handlers/src/oauth2/authorization.rs b/crates/handlers/src/oauth2/authorization.rs deleted file mode 100644 index 70810d8d..00000000 --- a/crates/handlers/src/oauth2/authorization.rs +++ /dev/null @@ -1,517 +0,0 @@ -// Copyright 2021, 2022 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::{anyhow, Context}; -use axum::{ - extract::{Extension, Form, Query}, - response::{IntoResponse, Redirect, Response}, -}; -use axum_extra::extract::PrivateCookieJar; -use chrono::Duration; -use hyper::{ - http::uri::{Parts, PathAndQuery, Uri}, - StatusCode, -}; -use mas_axum_utils::SessionInfoExt; -use mas_config::Encrypter; -use mas_data_model::{ - Authentication, AuthorizationCode, AuthorizationGrant, AuthorizationGrantStage, BrowserSession, - Pkce, StorageBackend, TokenType, -}; -use mas_iana::oauth::OAuthAuthorizationEndpointResponseType; -use mas_storage::{ - oauth2::{ - access_token::add_access_token, - authorization_grant::{ - derive_session, fulfill_grant, get_grant_by_id, new_authorization_grant, - }, - client::{lookup_client_by_client_id, ClientFetchError}, - consent::fetch_client_consent, - refresh_token::add_refresh_token, - }, - PostgresqlBackend, -}; -use mas_templates::Templates; -use oauth2_types::{ - errors::{ - INVALID_REQUEST, LOGIN_REQUIRED, REGISTRATION_NOT_SUPPORTED, REQUEST_NOT_SUPPORTED, - REQUEST_URI_NOT_SUPPORTED, UNAUTHORIZED_CLIENT, - }, - pkce, - prelude::*, - requests::{ - AccessTokenResponse, AuthorizationRequest, AuthorizationResponse, GrantType, Prompt, - ResponseMode, - }, - scope::ScopeToken, -}; -use rand::{distributions::Alphanumeric, thread_rng, Rng}; -use serde::{Deserialize, Serialize}; -use sqlx::{PgConnection, PgPool, Postgres, Transaction}; -use thiserror::Error; - -use self::callback::CallbackDestination; -use super::consent::ConsentRequest; -use crate::views::{LoginRequest, PostAuthAction, ReauthRequest, RegisterRequest}; - -mod callback; - -#[derive(Debug, Error)] -pub enum RouteError { - #[error(transparent)] - Internal(Box), - - #[error(transparent)] - Anyhow(anyhow::Error), - - #[error("could not find client")] - ClientNotFound, - - #[error("invalid redirect uri")] - InvalidRedirectUri(#[from] self::callback::InvalidRedirectUriError), - - #[error("invalid redirect uri")] - UnknownRedirectUri(#[from] mas_data_model::InvalidRedirectUriError), -} - -impl IntoResponse for RouteError { - fn into_response(self) -> axum::response::Response { - // TODO: better error pages - match self { - RouteError::Internal(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() - } - RouteError::Anyhow(e) => { - (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() - } - RouteError::ClientNotFound => { - (StatusCode::BAD_REQUEST, "could not find client").into_response() - } - RouteError::InvalidRedirectUri(e) => ( - StatusCode::BAD_REQUEST, - format!("Invalid redirect URI ({})", e), - ) - .into_response(), - RouteError::UnknownRedirectUri(e) => ( - StatusCode::BAD_REQUEST, - format!("Invalid redirect URI ({})", e), - ) - .into_response(), - } - } -} - -impl From for RouteError { - fn from(e: sqlx::Error) -> Self { - Self::Internal(Box::new(e)) - } -} - -impl From for RouteError { - fn from(e: self::callback::CallbackDestinationError) -> Self { - Self::Internal(Box::new(e)) - } -} - -impl From for RouteError { - fn from(e: ClientFetchError) -> Self { - if e.not_found() { - Self::ClientNotFound - } else { - Self::Internal(Box::new(e)) - } - } -} - -impl From for RouteError { - fn from(e: anyhow::Error) -> Self { - Self::Anyhow(e) - } -} - -#[derive(Deserialize)] -pub(crate) struct Params { - #[serde(flatten)] - auth: AuthorizationRequest, - - #[serde(flatten)] - pkce: Option, -} - -/// Given a list of response types and an optional user-defined response mode, -/// figure out what response mode must be used, and emit an error if the -/// suggested response mode isn't allowed for the given response types. -fn resolve_response_mode( - response_type: OAuthAuthorizationEndpointResponseType, - suggested_response_mode: Option, -) -> anyhow::Result { - use ResponseMode as M; - - // If the response type includes either "token" or "id_token", the default - // response mode is "fragment" and the response mode "query" must not be - // used - if response_type.has_token() || response_type.has_id_token() { - match suggested_response_mode { - None => Ok(M::Fragment), - Some(M::Query) => Err(anyhow!("invalid response mode")), - Some(mode) => Ok(mode), - } - } else { - // In other cases, all response modes are allowed, defaulting to "query" - Ok(suggested_response_mode.unwrap_or(M::Query)) - } -} - -#[allow(clippy::too_many_lines)] -#[tracing::instrument(skip_all, err)] -pub(crate) async fn get( - Extension(templates): Extension, - Extension(pool): Extension, - cookie_jar: PrivateCookieJar, - Form(params): Form, -) -> Result { - let mut txn = pool.begin().await?; - - // First, fetch the current session if there is one - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info - .load_session(&mut txn) - .await - .context("failed to load browser session")?; - - // Then, find out what client it is - let client = lookup_client_by_client_id(&mut txn, ¶ms.auth.client_id).await?; - - let redirect_uri = client - .resolve_redirect_uri(¶ms.auth.redirect_uri)? - .clone(); - let response_type = params.auth.response_type; - let response_mode = resolve_response_mode(response_type, params.auth.response_mode)?; - - let callback_destination = CallbackDestination::try_new( - response_mode, - redirect_uri.clone(), - params.auth.state.clone(), - )?; - - // One day, we will have try blocks - let res: Result = (async move { - // Check if the request/request_uri/registration params are used. If so, reply - // with the right error since we don't support them. - if params.auth.request.is_some() { - return Ok(callback_destination - .go(&templates, REQUEST_NOT_SUPPORTED) - .await?); - } - - if params.auth.request_uri.is_some() { - return Ok(callback_destination - .go(&templates, REQUEST_URI_NOT_SUPPORTED) - .await?); - } - - if params.auth.registration.is_some() { - return Ok(callback_destination - .go(&templates, REGISTRATION_NOT_SUPPORTED) - .await?); - } - - // Check if it is allowed to use this grant type - if !client.grant_types.contains(&GrantType::AuthorizationCode) { - return Ok(callback_destination - .go(&templates, UNAUTHORIZED_CLIENT) - .await?); - } - - let code: Option = if response_type.has_code() { - // 32 random alphanumeric characters, about 190bit of entropy - let code: String = thread_rng() - .sample_iter(&Alphanumeric) - .take(32) - .map(char::from) - .collect(); - - let pkce = params.pkce.map(|p| Pkce { - challenge: p.code_challenge, - challenge_method: p.code_challenge_method, - }); - - Some(AuthorizationCode { code, pkce }) - } else { - // If the request had PKCE params but no code asked, it should get back with an - // error - if params.pkce.is_some() { - return Ok(callback_destination.go(&templates, INVALID_REQUEST).await?); - } - - None - }; - - // Generate the device ID - // TODO: this should probably be done somewhere else? - let device_id: String = thread_rng() - .sample_iter(&Alphanumeric) - .take(10) - .map(char::from) - .collect(); - let device_scope: ScopeToken = format!("urn:matrix:device:{}", device_id) - .parse() - .context("could not parse generated device scope")?; - - let scope = { - let mut s = params.auth.scope.clone(); - s.insert(device_scope); - s - }; - - let grant = new_authorization_grant( - &mut txn, - client, - redirect_uri.clone(), - scope, - code, - params.auth.state.clone(), - params.auth.nonce, - params.auth.max_age, - None, - response_mode, - response_type.has_token(), - response_type.has_id_token(), - ) - .await?; - - let next = ContinueAuthorizationGrant::from_authorization_grant(&grant); - - match (maybe_session, params.auth.prompt) { - (None, Some(Prompt::None)) => { - // If there is no session and prompt=none was asked, go back to the client - txn.commit().await?; - Ok(callback_destination.go(&templates, LOGIN_REQUIRED).await?) - } - (Some(_), Some(Prompt::Consent)) => { - // We're already logged in but consent was asked - txn.commit().await?; - - let next: ConsentRequest = next.into(); - let next = next.build_uri()?; - - Ok(Redirect::to(&next.to_string()).into_response()) - } - (Some(_), Some(Prompt::Login | Prompt::SelectAccount)) => { - // We're already logged in but login|select_account was asked, reauth - // TODO: better pages here - txn.commit().await?; - - let next: PostAuthAction = next.into(); - let next: ReauthRequest = next.into(); - let next = next.build_uri()?; - - Ok(Redirect::to(&next.to_string()).into_response()) - } - (Some(user_session), _) => { - // Other cases where we already have a session - step(next, user_session, txn, &templates).await - } - (None, Some(Prompt::Create)) => { - // Client asked for a registration, show the registration prompt - txn.commit().await?; - - let next: PostAuthAction = next.into(); - let next: RegisterRequest = next.into(); - let next = next.build_uri()?; - - Ok(Redirect::to(&next.to_string()).into_response()) - } - (None, _) => { - // Other cases where we don't have a session, ask for a login - txn.commit().await?; - - let next: PostAuthAction = next.into(); - let next: LoginRequest = next.into(); - let next = next.build_uri()?; - - Ok(Redirect::to(&next.to_string()).into_response()) - } - } - }) - .await; - - let response = match res { - Ok(r) => r, - Err(err) => { - tracing::error!(%err); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - }; - - Ok((cookie_jar, response).into_response()) -} - -#[derive(Serialize, Deserialize, Clone)] -pub(crate) struct ContinueAuthorizationGrant { - data: String, -} - -impl ContinueAuthorizationGrant { - pub fn from_authorization_grant(grant: &AuthorizationGrant) -> Self - where - S::AuthorizationGrantData: std::fmt::Display, - { - Self { - data: grant.data.to_string(), - } - } - - pub fn build_uri(&self) -> anyhow::Result { - let qs = serde_urlencoded::to_string(self)?; - let path_and_query = PathAndQuery::try_from(format!("/oauth2/authorize/step?{}", qs))?; - let uri = Uri::from_parts({ - let mut parts = Parts::default(); - parts.path_and_query = Some(path_and_query); - parts - })?; - Ok(uri) - } - - pub async fn fetch_authorization_grant( - &self, - conn: &mut PgConnection, - ) -> anyhow::Result> { - let data = self.data.parse()?; - get_grant_by_id(conn, data).await - } -} - -pub(crate) async fn step_get( - Extension(templates): Extension, - Extension(pool): Extension, - Query(next): Query, - cookie_jar: PrivateCookieJar, -) -> Result { - let mut txn = pool.begin().await?; - - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info - .load_session(&mut txn) - .await - // TODO - .context("could not load db session")?; - - let session = if let Some(session) = maybe_session { - session - } else { - // If there is no session, redirect to the login screen, redirecting here after - // logout - let next: PostAuthAction = next.into(); - let login: LoginRequest = next.into(); - let login = login.build_uri()?; - return Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()); - }; - - step(next, session, txn, &templates).await -} - -async fn step( - next: ContinueAuthorizationGrant, - browser_session: BrowserSession, - mut txn: Transaction<'_, Postgres>, - templates: &Templates, -) -> Result { - // TODO: we should check if the grant here was started by the browser doing that - // request using a signed cookie - let grant = next.fetch_authorization_grant(&mut txn).await?; - - let callback_destination = CallbackDestination::try_from(&grant)?; - - if !matches!(grant.stage, AuthorizationGrantStage::Pending) { - return Err(anyhow!("authorization grant not pending").into()); - } - - let current_consent = - fetch_client_consent(&mut txn, &browser_session.user, &grant.client).await?; - - let lacks_consent = grant - .scope - .difference(¤t_consent) - .any(|scope| !scope.starts_with("urn:matrix:device:")); - - let reply = match (lacks_consent, &browser_session.last_authentication) { - (false, Some(Authentication { created_at, .. })) if created_at > &grant.max_auth_time() => { - let session = derive_session(&mut txn, &grant, browser_session).await?; - - let grant = fulfill_grant(&mut txn, grant, session.clone()).await?; - - // Yep! Let's complete the auth now - let mut params = AuthorizationResponse::default(); - - // Did they request an auth code? - if let Some(code) = grant.code { - params.code = Some(code.code); - } - - // Did they request an access token? - if grant.response_type_token { - let ttl = Duration::minutes(5); - let (access_token_str, refresh_token_str) = { - let mut rng = thread_rng(); - ( - TokenType::AccessToken.generate(&mut rng), - TokenType::RefreshToken.generate(&mut rng), - ) - }; - - let access_token = - add_access_token(&mut txn, &session, &access_token_str, ttl).await?; - - let _refresh_token = - add_refresh_token(&mut txn, &session, access_token, &refresh_token_str).await?; - - params.response = Some( - AccessTokenResponse::new(access_token_str) - .with_expires_in(ttl) - .with_refresh_token(refresh_token_str), - ); - } - - // Did they request an ID token? - if grant.response_type_id_token { - return Err(RouteError::Anyhow(anyhow!( - "id tokens are not implemented yet" - ))); - } - - let params = serde_json::to_value(¶ms).unwrap(); - - callback_destination.go(templates, params).await? - } - (true, Some(Authentication { created_at, .. })) if created_at > &grant.max_auth_time() => { - let next: ConsentRequest = next.into(); - let next = next.build_uri()?; - - Redirect::to(&next.to_string()).into_response() - } - _ => { - let next: PostAuthAction = next.into(); - let next: ReauthRequest = next.into(); - let next = next.build_uri()?; - - Redirect::to(&next.to_string()).into_response() - } - }; - - txn.commit().await?; - Ok(reply) -} diff --git a/crates/handlers/src/oauth2/authorization/callback.rs b/crates/handlers/src/oauth2/authorization/callback.rs index 3af2e094..f028567e 100644 --- a/crates/handlers/src/oauth2/authorization/callback.rs +++ b/crates/handlers/src/oauth2/authorization/callback.rs @@ -24,6 +24,7 @@ use serde::Serialize; use thiserror::Error; use url::Url; +#[derive(Debug, Clone)] enum CallbackDestinationMode { Query { existing_params: HashMap, @@ -32,6 +33,7 @@ enum CallbackDestinationMode { FormPost, } +#[derive(Debug, Clone)] pub struct CallbackDestination { mode: CallbackDestinationMode, safe_redirect_uri: Url, diff --git a/crates/handlers/src/oauth2/authorization/complete.rs b/crates/handlers/src/oauth2/authorization/complete.rs new file mode 100644 index 00000000..ed5b63eb --- /dev/null +++ b/crates/handlers/src/oauth2/authorization/complete.rs @@ -0,0 +1,257 @@ +// Copyright 2022 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::anyhow; +use axum::{ + extract::Path, + response::{IntoResponse, Response}, + Extension, +}; +use axum_extra::extract::PrivateCookieJar; +use chrono::Duration; +use hyper::StatusCode; +use mas_axum_utils::SessionInfoExt; +use mas_config::Encrypter; +use mas_data_model::{AuthorizationGrant, BrowserSession, TokenType}; +use mas_storage::{ + oauth2::{ + access_token::add_access_token, + authorization_grant::{derive_session, fulfill_grant, get_grant_by_id}, + consent::fetch_client_consent, + refresh_token::add_refresh_token, + }, + user::ActiveSessionLookupError, + PostgresqlBackend, +}; +use mas_templates::Templates; +use oauth2_types::requests::{AccessTokenResponse, AuthorizationResponse}; +use rand::thread_rng; +use sqlx::{PgPool, Postgres, Transaction}; +use thiserror::Error; + +use super::callback::{CallbackDestination, CallbackDestinationError, InvalidRedirectUriError}; +use crate::{ + oauth2::consent::ConsentRequest, + views::{LoginRequest, PostAuthAction, ReauthRequest}, +}; + +#[derive(Debug, Error)] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error(transparent)] + Anyhow(anyhow::Error), + + #[error("authorization grant is not in a pending state")] + NotPending, +} + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + // TODO: better error pages + match self { + RouteError::NotPending => ( + StatusCode::BAD_REQUEST, + "authorization grant not in a pending state", + ) + .into_response(), + RouteError::Anyhow(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + RouteError::Internal(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + } + } +} + +impl From for RouteError { + fn from(e: anyhow::Error) -> Self { + Self::Anyhow(e) + } +} + +impl From for RouteError { + fn from(e: sqlx::Error) -> Self { + Self::Internal(Box::new(e)) + } +} + +impl From for RouteError { + fn from(e: ActiveSessionLookupError) -> Self { + Self::Internal(Box::new(e)) + } +} + +impl From for RouteError { + fn from(e: InvalidRedirectUriError) -> Self { + Self::Internal(Box::new(e)) + } +} + +impl From for RouteError { + fn from(e: CallbackDestinationError) -> Self { + Self::Internal(Box::new(e)) + } +} + +pub(crate) async fn get( + Extension(templates): Extension, + Extension(pool): Extension, + cookie_jar: PrivateCookieJar, + Path(grant_id): Path, +) -> Result { + let mut txn = pool.begin().await?; + + let (session_info, cookie_jar) = cookie_jar.session_info(); + + let maybe_session = session_info.load_session(&mut txn).await?; + + let grant = get_grant_by_id(&mut txn, grant_id).await?; + + let callback_destination = CallbackDestination::try_from(&grant)?; + let continue_grant = PostAuthAction::continue_grant(&grant); + let consent_request = ConsentRequest::for_grant(&grant); + + let session = if let Some(session) = maybe_session { + session + } else { + // If there is no session, redirect to the login screen, redirecting here after + // logout + return Ok((cookie_jar, LoginRequest::from(continue_grant).go()).into_response()); + }; + + match complete(grant, session, txn).await { + Ok(params) => { + let res = callback_destination.go(&templates, params).await?; + Ok((cookie_jar, res).into_response()) + } + Err(GrantCompletionError::RequiresReauth) => { + Ok((cookie_jar, ReauthRequest::from(continue_grant).go()).into_response()) + } + Err(GrantCompletionError::RequiresConsent) => { + Ok((cookie_jar, consent_request.go()).into_response()) + } + Err(GrantCompletionError::NotPending) => Err(RouteError::NotPending), + Err(GrantCompletionError::Internal(e)) => Err(RouteError::Internal(e)), + Err(GrantCompletionError::Anyhow(e)) => Err(RouteError::Anyhow(e)), + } +} + +#[derive(Debug, Error)] +pub enum GrantCompletionError { + #[error(transparent)] + Internal(Box), + + #[error(transparent)] + Anyhow(#[from] anyhow::Error), + + #[error("authorization grant is not in a pending state")] + NotPending, + + #[error("user needs to reauthenticate")] + RequiresReauth, + + #[error("client lacks consent")] + RequiresConsent, +} + +impl From for GrantCompletionError { + fn from(e: sqlx::Error) -> Self { + Self::Internal(Box::new(e)) + } +} + +impl From for GrantCompletionError { + fn from(e: InvalidRedirectUriError) -> Self { + Self::Internal(Box::new(e)) + } +} + +pub(crate) async fn complete( + grant: AuthorizationGrant, + browser_session: BrowserSession, + mut txn: Transaction<'_, Postgres>, +) -> Result>, GrantCompletionError> { + // Verify that the grant is in a pending stage + if !grant.stage.is_pending() { + return Err(GrantCompletionError::NotPending); + } + + // Check if the authentication is fresh enough + if !browser_session.was_authenticated_after(grant.max_auth_time()) { + txn.commit().await?; + return Err(GrantCompletionError::RequiresReauth); + } + + let current_consent = + fetch_client_consent(&mut txn, &browser_session.user, &grant.client).await?; + + let lacks_consent = grant + .scope + .difference(¤t_consent) + .any(|scope| !scope.starts_with("urn:matrix:device:")); + + // Check if the client lacks consent *or* if consent was explicitely asked + if lacks_consent || grant.requires_consent { + txn.commit().await?; + return Err(GrantCompletionError::RequiresConsent); + } + + // All good, let's start the session + let session = derive_session(&mut txn, &grant, browser_session).await?; + + let grant = fulfill_grant(&mut txn, grant, session.clone()).await?; + + // Yep! Let's complete the auth now + let mut params = AuthorizationResponse::default(); + + // Did they request an auth code? + if let Some(code) = grant.code { + params.code = Some(code.code); + } + + // Did they request an access token? + // TODO: maybe we don't want to support the implicit flows + if grant.response_type_token { + let ttl = Duration::minutes(5); + let (access_token_str, refresh_token_str) = { + let mut rng = thread_rng(); + ( + TokenType::AccessToken.generate(&mut rng), + TokenType::RefreshToken.generate(&mut rng), + ) + }; + + let access_token = add_access_token(&mut txn, &session, &access_token_str, ttl).await?; + + let _refresh_token = + add_refresh_token(&mut txn, &session, access_token, &refresh_token_str).await?; + + params.response = Some( + AccessTokenResponse::new(access_token_str) + .with_expires_in(ttl) + .with_refresh_token(refresh_token_str), + ); + } + + // Did they request an ID token? + if grant.response_type_id_token { + return Err(anyhow!("id tokens are not implemented yet").into()); + } + + txn.commit().await?; + Ok(params) +} diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs new file mode 100644 index 00000000..ad55ff7b --- /dev/null +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -0,0 +1,381 @@ +// Copyright 2021, 2022 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::{anyhow, Context}; +use axum::{ + extract::{Extension, Form}, + response::{IntoResponse, Response}, +}; +use axum_extra::extract::PrivateCookieJar; +use hyper::StatusCode; +use mas_axum_utils::SessionInfoExt; +use mas_config::Encrypter; +use mas_data_model::{AuthorizationCode, Pkce}; +use mas_iana::oauth::OAuthAuthorizationEndpointResponseType; +use mas_storage::oauth2::{ + authorization_grant::new_authorization_grant, + client::{lookup_client_by_client_id, ClientFetchError}, +}; +use mas_templates::Templates; +use oauth2_types::{ + errors::{ + CONSENT_REQUIRED, INTERACTION_REQUIRED, INVALID_REQUEST, LOGIN_REQUIRED, + REGISTRATION_NOT_SUPPORTED, REQUEST_NOT_SUPPORTED, REQUEST_URI_NOT_SUPPORTED, SERVER_ERROR, + UNAUTHORIZED_CLIENT, + }, + pkce, + prelude::*, + requests::{AuthorizationRequest, GrantType, Prompt, ResponseMode}, + scope::ScopeToken, +}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use serde::Deserialize; +use sqlx::PgPool; +use thiserror::Error; + +use self::{callback::CallbackDestination, complete::GrantCompletionError}; +use super::consent::ConsentRequest; +use crate::views::{LoginRequest, PostAuthAction, ReauthRequest, RegisterRequest}; + +mod callback; +pub mod complete; + +#[derive(Debug, Error)] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error(transparent)] + Anyhow(anyhow::Error), + + #[error("could not find client")] + ClientNotFound, + + #[error("invalid redirect uri")] + InvalidRedirectUri(#[from] self::callback::InvalidRedirectUriError), + + #[error("invalid redirect uri")] + UnknownRedirectUri(#[from] mas_data_model::InvalidRedirectUriError), +} + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + // TODO: better error pages + match self { + RouteError::Internal(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + RouteError::Anyhow(e) => { + (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() + } + RouteError::ClientNotFound => { + (StatusCode::BAD_REQUEST, "could not find client").into_response() + } + RouteError::InvalidRedirectUri(e) => ( + StatusCode::BAD_REQUEST, + format!("Invalid redirect URI ({})", e), + ) + .into_response(), + RouteError::UnknownRedirectUri(e) => ( + StatusCode::BAD_REQUEST, + format!("Invalid redirect URI ({})", e), + ) + .into_response(), + } + } +} + +impl From for RouteError { + fn from(e: sqlx::Error) -> Self { + Self::Internal(Box::new(e)) + } +} + +impl From for RouteError { + fn from(e: self::callback::CallbackDestinationError) -> Self { + Self::Internal(Box::new(e)) + } +} + +impl From for RouteError { + fn from(e: ClientFetchError) -> Self { + if e.not_found() { + Self::ClientNotFound + } else { + Self::Internal(Box::new(e)) + } + } +} + +impl From for RouteError { + fn from(e: anyhow::Error) -> Self { + Self::Anyhow(e) + } +} + +#[derive(Deserialize)] +pub(crate) struct Params { + #[serde(flatten)] + auth: AuthorizationRequest, + + #[serde(flatten)] + pkce: Option, +} + +/// Given a list of response types and an optional user-defined response mode, +/// figure out what response mode must be used, and emit an error if the +/// suggested response mode isn't allowed for the given response types. +fn resolve_response_mode( + response_type: OAuthAuthorizationEndpointResponseType, + suggested_response_mode: Option, +) -> anyhow::Result { + use ResponseMode as M; + + // If the response type includes either "token" or "id_token", the default + // response mode is "fragment" and the response mode "query" must not be + // used + if response_type.has_token() || response_type.has_id_token() { + match suggested_response_mode { + None => Ok(M::Fragment), + Some(M::Query) => Err(anyhow!("invalid response mode")), + Some(mode) => Ok(mode), + } + } else { + // In other cases, all response modes are allowed, defaulting to "query" + Ok(suggested_response_mode.unwrap_or(M::Query)) + } +} + +#[allow(clippy::too_many_lines)] +pub(crate) async fn get( + Extension(templates): Extension, + Extension(pool): Extension, + cookie_jar: PrivateCookieJar, + Form(params): Form, +) -> Result { + let mut txn = pool.begin().await?; + + // First, figure out what client it is + let client = lookup_client_by_client_id(&mut txn, ¶ms.auth.client_id).await?; + + // And resolve the redirect_uri and response_mode + let redirect_uri = client + .resolve_redirect_uri(¶ms.auth.redirect_uri)? + .clone(); + let response_type = params.auth.response_type; + let response_mode = resolve_response_mode(response_type, params.auth.response_mode)?; + + // Now we have a proper callback destination to go to on error + let callback_destination = CallbackDestination::try_new( + response_mode, + redirect_uri.clone(), + params.auth.state.clone(), + )?; + + // Get the session info from the cookie + let (session_info, cookie_jar) = cookie_jar.session_info(); + + // One day, we will have try blocks + let res: Result = ({ + let templates = templates.clone(); + let callback_destination = callback_destination.clone(); + async move { + let maybe_session = session_info + .load_session(&mut txn) + .await + .context("failed to load browser session")?; + + // Check if the request/request_uri/registration params are used. If so, reply + // with the right error since we don't support them. + if params.auth.request.is_some() { + return Ok(callback_destination + .go(&templates, REQUEST_NOT_SUPPORTED) + .await?); + } + + if params.auth.request_uri.is_some() { + return Ok(callback_destination + .go(&templates, REQUEST_URI_NOT_SUPPORTED) + .await?); + } + + if params.auth.registration.is_some() { + return Ok(callback_destination + .go(&templates, REGISTRATION_NOT_SUPPORTED) + .await?); + } + + // Check if it is allowed to use this grant type + if !client.grant_types.contains(&GrantType::AuthorizationCode) { + return Ok(callback_destination + .go(&templates, UNAUTHORIZED_CLIENT) + .await?); + } + + // Fail early if prompt=none and there is no active session + if params.auth.prompt == Some(Prompt::None) && maybe_session.is_none() { + return Ok(callback_destination.go(&templates, LOGIN_REQUIRED).await?); + } + + let code: Option = if response_type.has_code() { + // 32 random alphanumeric characters, about 190bit of entropy + let code: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect(); + + let pkce = params.pkce.map(|p| Pkce { + challenge: p.code_challenge, + challenge_method: p.code_challenge_method, + }); + + Some(AuthorizationCode { code, pkce }) + } else { + // If the request had PKCE params but no code asked, it should get back with an + // error + if params.pkce.is_some() { + return Ok(callback_destination.go(&templates, INVALID_REQUEST).await?); + } + + None + }; + + // Generate the device ID + // TODO: this should probably be done somewhere else? + let device_id: String = thread_rng() + .sample_iter(&Alphanumeric) + .take(10) + .map(char::from) + .collect(); + let device_scope: ScopeToken = format!("urn:matrix:device:{}", device_id) + .parse() + .context("could not parse generated device scope")?; + + let scope = { + let mut s = params.auth.scope.clone(); + s.insert(device_scope); + s + }; + + let requires_consent = params.auth.prompt == Some(Prompt::Consent); + + let grant = new_authorization_grant( + &mut txn, + client, + redirect_uri.clone(), + scope, + code, + params.auth.state.clone(), + params.auth.nonce, + params.auth.max_age, + None, + response_mode, + response_type.has_token(), + response_type.has_id_token(), + requires_consent, + ) + .await?; + let continue_grant = PostAuthAction::continue_grant(&grant); + let consent_request = ConsentRequest::for_grant(&grant); + + let res = match (maybe_session, params.auth.prompt) { + // Cases where there is no active session, redirect to the relevant page + (None, Some(Prompt::None)) => { + // This case should already be handled earlier + unreachable!(); + } + (None, Some(Prompt::Create)) => { + // Client asked for a registration, show the registration prompt + txn.commit().await?; + + RegisterRequest::from(continue_grant).go().into_response() + } + (None, _) => { + // Other cases where we don't have a session, ask for a login + txn.commit().await?; + + LoginRequest::from(continue_grant).go().into_response() + } + + // Special case when we already have a sesion but prompt=login|select_account + (Some(_), Some(Prompt::Login | Prompt::SelectAccount)) => { + // TODO: better pages here + txn.commit().await?; + + ReauthRequest::from(continue_grant).go().into_response() + } + + // Else, we immediately try to complete the authorization grant + (Some(user_session), Some(Prompt::None)) => { + // With prompt=none, we should get back to the client immediately + match self::complete::complete(grant, user_session, txn).await { + Ok(params) => callback_destination.go(&templates, params).await?, + Err(GrantCompletionError::RequiresConsent) => { + callback_destination + .go(&templates, CONSENT_REQUIRED) + .await? + } + Err(GrantCompletionError::RequiresReauth) => { + callback_destination + .go(&templates, INTERACTION_REQUIRED) + .await? + } + Err(GrantCompletionError::Anyhow(a)) => return Err(RouteError::Anyhow(a)), + Err(GrantCompletionError::Internal(e)) => { + return Err(RouteError::Internal(e)) + } + Err(GrantCompletionError::NotPending) => { + // This should never happen + return Err(anyhow!("authorization grant is not pending").into()); + } + } + } + (Some(user_session), _) => { + // Else, we show the relevant reauth/consent page if necessary + match self::complete::complete(grant, user_session, txn).await { + Ok(params) => callback_destination.go(&templates, params).await?, + Err(GrantCompletionError::RequiresConsent) => { + consent_request.go().into_response() + } + Err(GrantCompletionError::RequiresReauth) => { + ReauthRequest::from(continue_grant).go().into_response() + } + Err(GrantCompletionError::Anyhow(a)) => return Err(RouteError::Anyhow(a)), + Err(GrantCompletionError::Internal(e)) => { + return Err(RouteError::Internal(e)) + } + Err(GrantCompletionError::NotPending) => { + // This should never happen + return Err(anyhow!("authorization grant is not pending").into()); + } + } + } + }; + + Ok(res) + } + }) + .await; + + let response = match res { + Ok(r) => r, + Err(err) => { + tracing::error!(%err); + callback_destination.go(&templates, SERVER_ERROR).await? + } + }; + + Ok((cookie_jar, response).into_response()) +} diff --git a/crates/handlers/src/oauth2/consent.rs b/crates/handlers/src/oauth2/consent.rs index 5732b493..c2371b57 100644 --- a/crates/handlers/src/oauth2/consent.rs +++ b/crates/handlers/src/oauth2/consent.rs @@ -14,24 +14,28 @@ use anyhow::Context; use axum::{ - extract::{Extension, Form, Query}, - http::uri::{Parts, PathAndQuery}, + extract::{Extension, Form, Path}, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::PrivateCookieJar; -use hyper::{StatusCode, Uri}; +use hyper::StatusCode; use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, SessionInfoExt, }; use mas_config::Encrypter; -use mas_data_model::AuthorizationGrantStage; -use mas_storage::oauth2::consent::insert_client_consent; +use mas_data_model::{AuthorizationGrant, AuthorizationGrantStage}; +use mas_storage::{ + oauth2::{ + authorization_grant::{get_grant_by_id, give_consent_to_grant}, + consent::insert_client_consent, + }, + PostgresqlBackend, +}; use mas_templates::{ConsentContext, TemplateContext, Templates}; use sqlx::PgPool; use thiserror::Error; -use super::ContinueAuthorizationGrant; use crate::views::{LoginRequest, PostAuthAction}; #[derive(Debug, Error)] @@ -47,25 +51,19 @@ impl IntoResponse for RouteError { } pub(crate) struct ConsentRequest { - grant: ContinueAuthorizationGrant, -} - -impl From for ConsentRequest { - fn from(grant: ContinueAuthorizationGrant) -> Self { - Self { grant } - } + grant_id: i64, } impl ConsentRequest { - pub fn build_uri(&self) -> anyhow::Result { - let qs = serde_urlencoded::to_string(&self.grant)?; - let path_and_query = PathAndQuery::try_from(format!("/consent?{}", qs))?; - let uri = Uri::from_parts({ - let mut parts = Parts::default(); - parts.path_and_query = Some(path_and_query); - parts - })?; - Ok(uri) + pub fn for_grant(grant: &AuthorizationGrant) -> Self { + Self { + grant_id: grant.data, + } + } + + pub fn go(&self) -> Redirect { + let uri = format!("/consent/{}", self.grant_id); + Redirect::to(&uri) } } @@ -73,7 +71,7 @@ pub(crate) async fn get( Extension(templates): Extension, Extension(pool): Extension, cookie_jar: PrivateCookieJar, - Query(next): Query, + Path(grant_id): Path, ) -> Result { let mut conn = pool .acquire() @@ -87,7 +85,7 @@ pub(crate) async fn get( .await .context("could not load session")?; - let grant = next.fetch_authorization_grant(&mut conn).await?; + let grant = get_grant_by_id(&mut conn, grant_id).await?; if !matches!(grant.stage, AuthorizationGrantStage::Pending) { return Err(anyhow::anyhow!("authorization grant not pending").into()); @@ -107,16 +105,15 @@ pub(crate) async fn get( Ok((cookie_jar, Html(content)).into_response()) } else { - let login = LoginRequest::from(PostAuthAction::from(next)); - let login = login.build_uri()?; - Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()) + let login = LoginRequest::from(PostAuthAction::continue_grant(&grant)); + Ok((cookie_jar, login.go()).into_response()) } } pub(crate) async fn post( Extension(pool): Extension, cookie_jar: PrivateCookieJar, - Query(next): Query, + Path(grant_id): Path, Form(form): Form>, ) -> Result { let mut txn = pool @@ -135,15 +132,16 @@ pub(crate) async fn post( .await .context("could not load session")?; + let grant = get_grant_by_id(&mut txn, grant_id).await?; + let next = PostAuthAction::continue_grant(&grant); + let session = if let Some(session) = maybe_session { session } else { - let login = LoginRequest::from(PostAuthAction::from(next)); - let login = login.build_uri()?; - return Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()); + let login = LoginRequest::from(next); + return Ok((cookie_jar, login.go()).into_response()); }; - let grant = next.fetch_authorization_grant(&mut txn).await?; // Do not consent for the "urn:matrix:device:*" scope let scope_without_device = grant .scope @@ -159,8 +157,11 @@ pub(crate) async fn post( ) .await?; + let _grant = give_consent_to_grant(&mut txn, grant) + .await + .context("failed to give consent to grant")?; + txn.commit().await.context("could not commit txn")?; - let uri = next.build_uri()?; - Ok((cookie_jar, Redirect::to(&uri.to_string())).into_response()) + Ok((cookie_jar, next.redirect()).into_response()) } diff --git a/crates/handlers/src/oauth2/mod.rs b/crates/handlers/src/oauth2/mod.rs index c749956d..c4fb43e4 100644 --- a/crates/handlers/src/oauth2/mod.rs +++ b/crates/handlers/src/oauth2/mod.rs @@ -21,5 +21,3 @@ pub mod registration; pub mod token; pub mod userinfo; pub mod webfinger; - -pub(crate) use authorization::ContinueAuthorizationGrant; diff --git a/crates/handlers/src/views/account/emails.rs b/crates/handlers/src/views/account/emails.rs index cab977db..1ddd5041 100644 --- a/crates/handlers/src/views/account/emails.rs +++ b/crates/handlers/src/views/account/emails.rs @@ -14,7 +14,7 @@ use axum::{ extract::{Extension, Form}, - response::{Html, IntoResponse, Redirect, Response}, + response::{Html, IntoResponse, Response}, }; use axum_extra::extract::PrivateCookieJar; use lettre::{message::Mailbox, Address}; @@ -70,8 +70,7 @@ pub(crate) async fn get( render(templates, session, cookie_jar, &mut conn).await } else { let login = LoginRequest::default(); - let login = login.build_uri().map_err(fancy_error(templates.clone()))?; - Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()) + Ok((cookie_jar, login.go()).into_response()) } } @@ -151,8 +150,7 @@ pub(crate) async fn post( session } else { let login = LoginRequest::default(); - let login = login.build_uri().map_err(fancy_error(templates.clone()))?; - return Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()); + return Ok((cookie_jar, login.go()).into_response()); }; let form = cookie_jar diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/account/mod.rs index 233883c4..92105172 100644 --- a/crates/handlers/src/views/account/mod.rs +++ b/crates/handlers/src/views/account/mod.rs @@ -17,7 +17,7 @@ pub mod password; use axum::{ extract::Extension, - response::{Html, IntoResponse, Redirect, Response}, + response::{Html, IntoResponse, Response}, }; use axum_extra::extract::PrivateCookieJar; use mas_axum_utils::{csrf::CsrfExt, fancy_error, FancyError, SessionInfoExt}; @@ -50,8 +50,7 @@ pub(crate) async fn get( session } else { let login = LoginRequest::default(); - let login = login.build_uri().map_err(fancy_error(templates.clone()))?; - return Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()); + return Ok((cookie_jar, login.go()).into_response()); }; let active_sessions = count_active_sessions(&mut conn, &session.user) diff --git a/crates/handlers/src/views/account/password.rs b/crates/handlers/src/views/account/password.rs index 63019736..185c7906 100644 --- a/crates/handlers/src/views/account/password.rs +++ b/crates/handlers/src/views/account/password.rs @@ -15,7 +15,7 @@ use argon2::Argon2; use axum::{ extract::{Extension, Form}, - response::{Html, IntoResponse, Redirect, Response}, + response::{Html, IntoResponse, Response}, }; use axum_extra::extract::PrivateCookieJar; use mas_axum_utils::{ @@ -62,8 +62,7 @@ pub(crate) async fn get( render(templates, session, cookie_jar).await } else { let login = LoginRequest::default(); - let login = login.build_uri().map_err(fancy_error(templates.clone()))?; - Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()) + Ok((cookie_jar, login.go()).into_response()) } } @@ -109,8 +108,7 @@ pub(crate) async fn post( session } else { let login = LoginRequest::default(); - let login = login.build_uri().map_err(fancy_error(templates.clone()))?; - return Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()); + return Ok((cookie_jar, login.go()).into_response()); }; authenticate_session(&mut txn, &mut session, form.current_password) diff --git a/crates/handlers/src/views/login.rs b/crates/handlers/src/views/login.rs index 210d9d56..c47dbc43 100644 --- a/crates/handlers/src/views/login.rs +++ b/crates/handlers/src/views/login.rs @@ -12,12 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::borrow::Cow; + use axum::{ extract::{Extension, Form, Query}, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::PrivateCookieJar; -use hyper::http::uri::{Parts, PathAndQuery, Uri}; use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, fancy_error, FancyError, SessionInfoExt, @@ -31,7 +32,7 @@ use sqlx::PgPool; use super::{shared::PostAuthAction, RegisterRequest}; -#[derive(Deserialize, Default)] +#[derive(Deserialize, Default, Debug)] pub(crate) struct LoginRequest { #[serde(flatten)] post_auth_action: Option, @@ -50,29 +51,25 @@ impl From> for LoginRequest { } impl LoginRequest { - pub fn build_uri(&self) -> anyhow::Result { - let path_and_query = if let Some(next) = &self.post_auth_action { - let qs = serde_urlencoded::to_string(next)?; - PathAndQuery::try_from(format!("/login?{}", qs))? + pub fn as_link(&self) -> Cow<'static, str> { + if let Some(next) = &self.post_auth_action { + let qs = serde_urlencoded::to_string(next).unwrap(); + Cow::Owned(format!("/login?{}", qs)) } else { - PathAndQuery::from_static("/login") - }; - let uri = Uri::from_parts({ - let mut parts = Parts::default(); - parts.path_and_query = Some(path_and_query); - parts - })?; - Ok(uri) + Cow::Borrowed("/login") + } } - fn redirect(self) -> Result { - let uri = if let Some(action) = self.post_auth_action { - action.build_uri()? - } else { - Uri::from_static("/") - }; + pub fn go(&self) -> Redirect { + Redirect::to(&self.as_link()) + } - Ok(Redirect::to(&uri.to_string())) + fn redirect(self) -> Redirect { + if let Some(action) = self.post_auth_action { + action.redirect() + } else { + Redirect::to("/") + } } } @@ -82,6 +79,7 @@ pub(crate) struct LoginForm { password: String, } +#[tracing::instrument(skip(templates, pool, cookie_jar))] pub(crate) async fn get( Extension(templates): Extension, Extension(pool): Extension, @@ -102,18 +100,13 @@ pub(crate) async fn get( .map_err(fancy_error(templates.clone()))?; if maybe_session.is_some() { - let response = query - .redirect() - .map_err(fancy_error(templates.clone()))? - .into_response(); + let response = query.redirect().into_response(); Ok(response) } else { let ctx = LoginContext::default(); let ctx = match query.post_auth_action { Some(next) => { - let register_link = RegisterRequest::from(next.clone()) - .build_uri() - .map_err(fancy_error(templates.clone()))?; + let register_link = RegisterRequest::from(next.clone()).as_link(); let next = next .load_context(&mut conn) .await @@ -157,7 +150,7 @@ pub(crate) async fn post( match login(&mut conn, &form.username, form.password).await { Ok(session_info) => { let cookie_jar = cookie_jar.set_session(&session_info); - let reply = query.redirect().map_err(fancy_error(templates.clone()))?; + let reply = query.redirect(); Ok((cookie_jar, reply).into_response()) } Err(e) => { diff --git a/crates/handlers/src/views/reauth.rs b/crates/handlers/src/views/reauth.rs index 95d037b0..1a5cd0c8 100644 --- a/crates/handlers/src/views/reauth.rs +++ b/crates/handlers/src/views/reauth.rs @@ -12,15 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::borrow::Cow; + use axum::{ extract::{Extension, Form, Query}, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::PrivateCookieJar; -use hyper::{ - http::uri::{Parts, PathAndQuery}, - Uri, -}; use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, fancy_error, FancyError, SessionInfoExt, @@ -48,26 +46,24 @@ impl From for ReauthRequest { } impl ReauthRequest { - pub fn build_uri(&self) -> anyhow::Result { - let path_and_query = if let Some(next) = &self.post_auth_action { - let qs = serde_urlencoded::to_string(next)?; - PathAndQuery::try_from(format!("/reauth?{}", qs))? + pub fn as_link(&self) -> Cow<'static, str> { + if let Some(next) = &self.post_auth_action { + let qs = serde_urlencoded::to_string(next).unwrap(); + Cow::Owned(format!("/reauth?{}", qs)) } else { - PathAndQuery::from_static("/reauth") - }; - let uri = Uri::from_parts({ - let mut parts = Parts::default(); - parts.path_and_query = Some(path_and_query); - parts - })?; - Ok(uri) + Cow::Borrowed("/reauth") + } } - fn redirect(self) -> Result { + pub fn go(&self) -> Redirect { + Redirect::to(&self.as_link()) + } + + fn redirect(self) -> Redirect { if let Some(action) = self.post_auth_action { - Ok(Redirect::to(&action.build_uri()?.to_string())) + action.redirect() } else { - Ok(Redirect::to("/")) + Redirect::to("/") } } } @@ -102,8 +98,7 @@ pub(crate) async fn get( // If there is no session, redirect to the login screen, keeping the // PostAuthAction let login: LoginRequest = query.post_auth_action.into(); - let login = login.build_uri().map_err(fancy_error(templates.clone()))?; - return Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()); + return Ok((cookie_jar, login.go()).into_response()); }; let ctx = ReauthContext::default(); @@ -153,8 +148,7 @@ pub(crate) async fn post( // If there is no session, redirect to the login screen, keeping the // PostAuthAction let login: LoginRequest = query.post_auth_action.into(); - let login = login.build_uri().map_err(fancy_error(templates.clone()))?; - return Ok((cookie_jar, Redirect::to(&login.to_string())).into_response()); + return Ok((cookie_jar, login.go()).into_response()); }; // TODO: recover from errors here @@ -164,6 +158,6 @@ pub(crate) async fn post( let cookie_jar = cookie_jar.set_session(&session); txn.commit().await.map_err(fancy_error(templates.clone()))?; - let redirection = query.redirect().map_err(fancy_error(templates.clone()))?; + let redirection = query.redirect(); Ok((cookie_jar, redirection).into_response()) } diff --git a/crates/handlers/src/views/register.rs b/crates/handlers/src/views/register.rs index 51f734bd..d0609acb 100644 --- a/crates/handlers/src/views/register.rs +++ b/crates/handlers/src/views/register.rs @@ -14,13 +14,14 @@ #![allow(clippy::trait_duplication_in_bounds)] +use std::borrow::Cow; + use argon2::Argon2; use axum::{ extract::{Extension, Form, Query}, response::{Html, IntoResponse, Redirect, Response}, }; use axum_extra::extract::PrivateCookieJar; -use hyper::http::uri::{Parts, PathAndQuery, Uri}; use mas_axum_utils::{ csrf::{CsrfExt, ProtectedForm}, fancy_error, FancyError, SessionInfoExt, @@ -48,27 +49,24 @@ impl From for RegisterRequest { } impl RegisterRequest { - #[allow(dead_code)] - pub fn build_uri(&self) -> anyhow::Result { - let path_and_query = if let Some(next) = &self.post_auth_action { - let qs = serde_urlencoded::to_string(next)?; - PathAndQuery::try_from(format!("/register?{}", qs))? + pub fn as_link(&self) -> Cow<'static, str> { + if let Some(next) = &self.post_auth_action { + let qs = serde_urlencoded::to_string(next).unwrap(); + Cow::Owned(format!("/register?{}", qs)) } else { - PathAndQuery::from_static("/register") - }; - let uri = Uri::from_parts({ - let mut parts = Parts::default(); - parts.path_and_query = Some(path_and_query); - parts - })?; - Ok(uri) + Cow::Borrowed("/register") + } } - fn redirect(self) -> Result { + pub fn go(&self) -> Redirect { + Redirect::to(&self.as_link()) + } + + fn redirect(self) -> Redirect { if let Some(action) = self.post_auth_action { - Ok(Redirect::to(&action.build_uri()?.to_string())) + action.redirect() } else { - Ok(Redirect::to("/")) + Redirect::to("/") } } } @@ -100,10 +98,7 @@ pub(crate) async fn get( .map_err(fancy_error(templates.clone()))?; if maybe_session.is_some() { - let response = query - .redirect() - .map_err(fancy_error(templates.clone()))? - .into_response(); + let response = query.redirect().into_response(); Ok(response) } else { let ctx = RegisterContext::default(); @@ -117,9 +112,7 @@ pub(crate) async fn get( } None => ctx, }; - let login_link = LoginRequest::from(query.post_auth_action) - .build_uri() - .map_err(fancy_error(templates.clone()))?; + let login_link = LoginRequest::from(query.post_auth_action).as_link(); let ctx = ctx.with_login_link(login_link.to_string()); let ctx = ctx.with_csrf(csrf_token.form_value()); @@ -162,6 +155,6 @@ pub(crate) async fn post( txn.commit().await.map_err(fancy_error(templates.clone()))?; let cookie_jar = cookie_jar.set_session(&session); - let reply = query.redirect().map_err(fancy_error(templates.clone()))?; + let reply = query.redirect(); Ok((cookie_jar, reply).into_response()) } diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index 2f9b8b92..907ae6ae 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -12,23 +12,33 @@ // See the License for the specific language governing permissions and // limitations under the License. -use hyper::Uri; +use axum::response::Redirect; +use mas_data_model::AuthorizationGrant; +use mas_storage::{oauth2::authorization_grant::get_grant_by_id, PostgresqlBackend}; use mas_templates::PostAuthContext; use serde::{Deserialize, Serialize}; use sqlx::PgConnection; -use super::super::oauth2::ContinueAuthorizationGrant; - -#[derive(Deserialize, Serialize, Clone)] +#[derive(Deserialize, Serialize, Clone, Debug)] #[serde(rename_all = "snake_case", tag = "next")] pub(crate) enum PostAuthAction { - ContinueAuthorizationGrant(ContinueAuthorizationGrant), + ContinueAuthorizationGrant { + #[serde(deserialize_with = "serde_with::rust::display_fromstr::deserialize")] + data: i64, + }, } impl PostAuthAction { - pub fn build_uri(&self) -> anyhow::Result { + pub fn continue_grant(grant: &AuthorizationGrant) -> Self { + Self::ContinueAuthorizationGrant { data: grant.data } + } + + pub fn redirect(&self) -> Redirect { match self { - PostAuthAction::ContinueAuthorizationGrant(c) => c.build_uri(), + PostAuthAction::ContinueAuthorizationGrant { data } => { + let url = format!("/authorize/{}", data); + Redirect::to(&url) + } } } @@ -37,8 +47,8 @@ impl PostAuthAction { conn: &mut PgConnection, ) -> anyhow::Result { match self { - Self::ContinueAuthorizationGrant(c) => { - let grant = c.fetch_authorization_grant(conn).await?; + Self::ContinueAuthorizationGrant { data } => { + let grant = get_grant_by_id(conn, *data).await?; let grant = grant.into(); Ok(PostAuthContext::ContinueAuthorizationGrant { grant }) } @@ -46,8 +56,18 @@ impl PostAuthAction { } } -impl From for PostAuthAction { - fn from(g: ContinueAuthorizationGrant) -> Self { - Self::ContinueAuthorizationGrant(g) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_post_auth_action() { + let action: PostAuthAction = + serde_urlencoded::from_str("next=continue_authorization_grant&data=123").unwrap(); + + assert!(matches!( + action, + PostAuthAction::ContinueAuthorizationGrant { data: 123 } + )); } } diff --git a/crates/storage/migrations/20220506134736_oauth2_authz_grant_require_consent.down.sql b/crates/storage/migrations/20220506134736_oauth2_authz_grant_require_consent.down.sql new file mode 100644 index 00000000..c2ee11cb --- /dev/null +++ b/crates/storage/migrations/20220506134736_oauth2_authz_grant_require_consent.down.sql @@ -0,0 +1,16 @@ +-- Copyright 2022 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. + +ALTER TABLE oauth2_authorization_grants + DROP COLUMN requires_consent; diff --git a/crates/storage/migrations/20220506134736_oauth2_authz_grant_require_consent.up.sql b/crates/storage/migrations/20220506134736_oauth2_authz_grant_require_consent.up.sql new file mode 100644 index 00000000..77196c49 --- /dev/null +++ b/crates/storage/migrations/20220506134736_oauth2_authz_grant_require_consent.up.sql @@ -0,0 +1,16 @@ +-- Copyright 2022 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. + +ALTER TABLE oauth2_authorization_grants + ADD COLUMN requires_consent BOOLEAN NOT NULL DEFAULT 'f'; diff --git a/crates/storage/sqlx-data.json b/crates/storage/sqlx-data.json index d66a534d..9080c9cd 100644 --- a/crates/storage/sqlx-data.json +++ b/crates/storage/sqlx-data.json @@ -1,5 +1,217 @@ { "db": "PostgreSQL", + "08896e50738af687ac53dc5ac5ae0b19bcac7503230ba90e11de799978d7a026": { + "describe": { + "columns": [ + { + "name": "grant_id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "grant_created_at", + "ordinal": 1, + "type_info": "Timestamptz" + }, + { + "name": "grant_cancelled_at", + "ordinal": 2, + "type_info": "Timestamptz" + }, + { + "name": "grant_fulfilled_at", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "grant_exchanged_at", + "ordinal": 4, + "type_info": "Timestamptz" + }, + { + "name": "grant_scope", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "grant_state", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "grant_redirect_uri", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "grant_response_mode", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "grant_nonce", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "grant_max_age", + "ordinal": 10, + "type_info": "Int4" + }, + { + "name": "grant_acr_values", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "oauth2_client_id", + "ordinal": 12, + "type_info": "Int8" + }, + { + "name": "grant_code", + "ordinal": 13, + "type_info": "Text" + }, + { + "name": "grant_response_type_code", + "ordinal": 14, + "type_info": "Bool" + }, + { + "name": "grant_response_type_token", + "ordinal": 15, + "type_info": "Bool" + }, + { + "name": "grant_response_type_id_token", + "ordinal": 16, + "type_info": "Bool" + }, + { + "name": "grant_code_challenge", + "ordinal": 17, + "type_info": "Text" + }, + { + "name": "grant_code_challenge_method", + "ordinal": 18, + "type_info": "Text" + }, + { + "name": "grant_requires_consent", + "ordinal": 19, + "type_info": "Bool" + }, + { + "name": "session_id?", + "ordinal": 20, + "type_info": "Int8" + }, + { + "name": "user_session_id?", + "ordinal": 21, + "type_info": "Int8" + }, + { + "name": "user_session_created_at?", + "ordinal": 22, + "type_info": "Timestamptz" + }, + { + "name": "user_id?", + "ordinal": 23, + "type_info": "Int8" + }, + { + "name": "user_username?", + "ordinal": 24, + "type_info": "Text" + }, + { + "name": "user_session_last_authentication_id?", + "ordinal": 25, + "type_info": "Int8" + }, + { + "name": "user_session_last_authentication_created_at?", + "ordinal": 26, + "type_info": "Timestamptz" + }, + { + "name": "user_email_id?", + "ordinal": 27, + "type_info": "Int8" + }, + { + "name": "user_email?", + "ordinal": 28, + "type_info": "Text" + }, + { + "name": "user_email_created_at?", + "ordinal": 29, + "type_info": "Timestamptz" + }, + { + "name": "user_email_confirmed_at?", + "ordinal": 30, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false, + true, + true, + true, + false, + true, + false, + false, + true, + true, + true, + false, + true, + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT\n og.id AS grant_id,\n og.created_at AS grant_created_at,\n og.cancelled_at AS grant_cancelled_at,\n og.fulfilled_at AS grant_fulfilled_at,\n og.exchanged_at AS grant_exchanged_at,\n og.scope AS grant_scope,\n og.state AS grant_state,\n og.redirect_uri AS grant_redirect_uri,\n og.response_mode AS grant_response_mode,\n og.nonce AS grant_nonce,\n og.max_age AS grant_max_age,\n og.acr_values AS grant_acr_values,\n og.oauth2_client_id AS oauth2_client_id,\n og.code AS grant_code,\n og.response_type_code AS grant_response_type_code,\n og.response_type_token AS grant_response_type_token,\n og.response_type_id_token AS grant_response_type_id_token,\n og.code_challenge AS grant_code_challenge,\n og.code_challenge_method AS grant_code_challenge_method,\n og.requires_consent AS grant_requires_consent,\n os.id AS \"session_id?\",\n us.id AS \"user_session_id?\",\n us.created_at AS \"user_session_created_at?\",\n u.id AS \"user_id?\",\n u.username AS \"user_username?\",\n usa.id AS \"user_session_last_authentication_id?\",\n usa.created_at AS \"user_session_last_authentication_created_at?\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n FROM\n oauth2_authorization_grants og\n LEFT JOIN oauth2_sessions os\n ON os.id = og.oauth2_session_id\n LEFT JOIN user_sessions us\n ON us.id = os.user_session_id\n LEFT JOIN users u\n ON u.id = us.user_id\n LEFT JOIN user_session_authentications usa\n ON usa.session_id = us.id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE og.id = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n " + }, + "096060f2be446fd77ee29308c673f9ba9210fb110444f4fccfeb976424ef4376": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE oauth2_authorization_grants AS og\n SET\n requires_consent = 'f'\n WHERE\n og.id = $1\n " + }, "0c056fcc1a85d00db88034bcc582376cf220e1933d2932e520c44ed9931f5c9d": { "describe": { "columns": [ @@ -28,6 +240,46 @@ }, "query": "\n INSERT INTO oauth2_refresh_tokens\n (oauth2_session_id, oauth2_access_token_id, token)\n VALUES\n ($1, $2, $3)\n RETURNING\n id, created_at\n " }, + "0ce16ae459b815e4fbef78784fafea08b30443741b6817dd1d722f4960dc19f8": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "created_at", + "ordinal": 1, + "type_info": "Timestamptz" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Text", + "Int4", + "Text", + "Text", + "Text", + "Text", + "Bool", + "Bool", + "Bool", + "Text", + "Bool" + ] + } + }, + "query": "\n INSERT INTO oauth2_authorization_grants\n (oauth2_client_id, redirect_uri, scope, state, nonce, max_age,\n acr_values, response_mode, code_challenge, code_challenge_method,\n response_type_code, response_type_token, response_type_id_token,\n code, requires_consent)\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n RETURNING id, created_at\n " + }, "11f29a7b467bef1cf483d91eede7849707e01847542e4fc3c1be702560bf36bf": { "describe": { "columns": [ @@ -205,200 +457,6 @@ }, "query": "\n UPDATE users\n SET primary_email_id = user_emails.id \n FROM user_emails\n WHERE user_emails.id = $1\n AND users.id = user_emails.user_id\n " }, - "4f0e5c9a6d345a1f1e154d61cd7bb4d67f5d20499b411a44e6d8c39b5ef75ca6": { - "describe": { - "columns": [ - { - "name": "grant_id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "grant_created_at", - "ordinal": 1, - "type_info": "Timestamptz" - }, - { - "name": "grant_cancelled_at", - "ordinal": 2, - "type_info": "Timestamptz" - }, - { - "name": "grant_fulfilled_at", - "ordinal": 3, - "type_info": "Timestamptz" - }, - { - "name": "grant_exchanged_at", - "ordinal": 4, - "type_info": "Timestamptz" - }, - { - "name": "grant_scope", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "grant_state", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "grant_redirect_uri", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "grant_response_mode", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "grant_nonce", - "ordinal": 9, - "type_info": "Text" - }, - { - "name": "grant_max_age", - "ordinal": 10, - "type_info": "Int4" - }, - { - "name": "grant_acr_values", - "ordinal": 11, - "type_info": "Text" - }, - { - "name": "oauth2_client_id", - "ordinal": 12, - "type_info": "Int8" - }, - { - "name": "grant_code", - "ordinal": 13, - "type_info": "Text" - }, - { - "name": "grant_response_type_code", - "ordinal": 14, - "type_info": "Bool" - }, - { - "name": "grant_response_type_token", - "ordinal": 15, - "type_info": "Bool" - }, - { - "name": "grant_response_type_id_token", - "ordinal": 16, - "type_info": "Bool" - }, - { - "name": "grant_code_challenge", - "ordinal": 17, - "type_info": "Text" - }, - { - "name": "grant_code_challenge_method", - "ordinal": 18, - "type_info": "Text" - }, - { - "name": "session_id?", - "ordinal": 19, - "type_info": "Int8" - }, - { - "name": "user_session_id?", - "ordinal": 20, - "type_info": "Int8" - }, - { - "name": "user_session_created_at?", - "ordinal": 21, - "type_info": "Timestamptz" - }, - { - "name": "user_id?", - "ordinal": 22, - "type_info": "Int8" - }, - { - "name": "user_username?", - "ordinal": 23, - "type_info": "Text" - }, - { - "name": "user_session_last_authentication_id?", - "ordinal": 24, - "type_info": "Int8" - }, - { - "name": "user_session_last_authentication_created_at?", - "ordinal": 25, - "type_info": "Timestamptz" - }, - { - "name": "user_email_id?", - "ordinal": 26, - "type_info": "Int8" - }, - { - "name": "user_email?", - "ordinal": 27, - "type_info": "Text" - }, - { - "name": "user_email_created_at?", - "ordinal": 28, - "type_info": "Timestamptz" - }, - { - "name": "user_email_confirmed_at?", - "ordinal": 29, - "type_info": "Timestamptz" - } - ], - "nullable": [ - false, - false, - true, - true, - true, - false, - true, - false, - false, - true, - true, - true, - false, - true, - false, - false, - false, - true, - true, - false, - false, - false, - false, - false, - false, - false, - false, - false, - false, - true - ], - "parameters": { - "Left": [ - "Int8" - ] - } - }, - "query": "\n SELECT\n og.id AS grant_id,\n og.created_at AS grant_created_at,\n og.cancelled_at AS grant_cancelled_at,\n og.fulfilled_at AS grant_fulfilled_at,\n og.exchanged_at AS grant_exchanged_at,\n og.scope AS grant_scope,\n og.state AS grant_state,\n og.redirect_uri AS grant_redirect_uri,\n og.response_mode AS grant_response_mode,\n og.nonce AS grant_nonce,\n og.max_age AS grant_max_age,\n og.acr_values AS grant_acr_values,\n og.oauth2_client_id AS oauth2_client_id,\n og.code AS grant_code,\n og.response_type_code AS grant_response_type_code,\n og.response_type_token AS grant_response_type_token,\n og.response_type_id_token AS grant_response_type_id_token,\n og.code_challenge AS grant_code_challenge,\n og.code_challenge_method AS grant_code_challenge_method,\n os.id AS \"session_id?\",\n us.id AS \"user_session_id?\",\n us.created_at AS \"user_session_created_at?\",\n u.id AS \"user_id?\",\n u.username AS \"user_username?\",\n usa.id AS \"user_session_last_authentication_id?\",\n usa.created_at AS \"user_session_last_authentication_created_at?\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n FROM\n oauth2_authorization_grants og\n LEFT JOIN oauth2_sessions os\n ON os.id = og.oauth2_session_id\n LEFT JOIN user_sessions us\n ON us.id = os.user_session_id\n LEFT JOIN users u\n ON u.id = us.user_id\n LEFT JOIN user_session_authentications usa\n ON usa.session_id = us.id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE og.id = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n " - }, "51158bfcaa1a8d8e051bffe7c5ba0369bf53fb162f7622626054e89e68fc07bd": { "describe": { "columns": [ @@ -1048,7 +1106,7 @@ }, "query": "\n DELETE FROM oauth2_access_tokens\n WHERE id = $1\n " }, - "99270fd3ddcc7421c5b26d0b8e0116356c13166887e7cf6ed6352cc879c80a68": { + "9882e49f34dff80c1442565f035a1b47ed4dbae1a405f58cf2db198885bb9f47": { "describe": { "columns": [ { @@ -1147,58 +1205,63 @@ "type_info": "Text" }, { - "name": "session_id?", + "name": "grant_requires_consent", "ordinal": 19, - "type_info": "Int8" + "type_info": "Bool" }, { - "name": "user_session_id?", + "name": "session_id?", "ordinal": 20, "type_info": "Int8" }, { - "name": "user_session_created_at?", + "name": "user_session_id?", "ordinal": 21, + "type_info": "Int8" + }, + { + "name": "user_session_created_at?", + "ordinal": 22, "type_info": "Timestamptz" }, { "name": "user_id?", - "ordinal": 22, + "ordinal": 23, "type_info": "Int8" }, { "name": "user_username?", - "ordinal": 23, + "ordinal": 24, "type_info": "Text" }, { "name": "user_session_last_authentication_id?", - "ordinal": 24, + "ordinal": 25, "type_info": "Int8" }, { "name": "user_session_last_authentication_created_at?", - "ordinal": 25, + "ordinal": 26, "type_info": "Timestamptz" }, { "name": "user_email_id?", - "ordinal": 26, + "ordinal": 27, "type_info": "Int8" }, { "name": "user_email?", - "ordinal": 27, + "ordinal": 28, "type_info": "Text" }, { "name": "user_email_created_at?", - "ordinal": 28, + "ordinal": 29, "type_info": "Timestamptz" }, { "name": "user_email_confirmed_at?", - "ordinal": 29, + "ordinal": 30, "type_info": "Timestamptz" } ], @@ -1232,6 +1295,7 @@ false, false, false, + false, true ], "parameters": { @@ -1240,7 +1304,7 @@ ] } }, - "query": "\n SELECT\n og.id AS grant_id,\n og.created_at AS grant_created_at,\n og.cancelled_at AS grant_cancelled_at,\n og.fulfilled_at AS grant_fulfilled_at,\n og.exchanged_at AS grant_exchanged_at,\n og.scope AS grant_scope,\n og.state AS grant_state,\n og.redirect_uri AS grant_redirect_uri,\n og.response_mode AS grant_response_mode,\n og.nonce AS grant_nonce,\n og.max_age AS grant_max_age,\n og.acr_values AS grant_acr_values,\n og.oauth2_client_id AS oauth2_client_id,\n og.code AS grant_code,\n og.response_type_code AS grant_response_type_code,\n og.response_type_token AS grant_response_type_token,\n og.response_type_id_token AS grant_response_type_id_token,\n og.code_challenge AS grant_code_challenge,\n og.code_challenge_method AS grant_code_challenge_method,\n os.id AS \"session_id?\",\n us.id AS \"user_session_id?\",\n us.created_at AS \"user_session_created_at?\",\n u.id AS \"user_id?\",\n u.username AS \"user_username?\",\n usa.id AS \"user_session_last_authentication_id?\",\n usa.created_at AS \"user_session_last_authentication_created_at?\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n FROM\n oauth2_authorization_grants og\n LEFT JOIN oauth2_sessions os\n ON os.id = og.oauth2_session_id\n LEFT JOIN user_sessions us\n ON us.id = os.user_session_id\n LEFT JOIN users u\n ON u.id = us.user_id\n LEFT JOIN user_session_authentications usa\n ON usa.session_id = us.id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE og.code = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n " + "query": "\n SELECT\n og.id AS grant_id,\n og.created_at AS grant_created_at,\n og.cancelled_at AS grant_cancelled_at,\n og.fulfilled_at AS grant_fulfilled_at,\n og.exchanged_at AS grant_exchanged_at,\n og.scope AS grant_scope,\n og.state AS grant_state,\n og.redirect_uri AS grant_redirect_uri,\n og.response_mode AS grant_response_mode,\n og.nonce AS grant_nonce,\n og.max_age AS grant_max_age,\n og.acr_values AS grant_acr_values,\n og.oauth2_client_id AS oauth2_client_id,\n og.code AS grant_code,\n og.response_type_code AS grant_response_type_code,\n og.response_type_token AS grant_response_type_token,\n og.response_type_id_token AS grant_response_type_id_token,\n og.code_challenge AS grant_code_challenge,\n og.code_challenge_method AS grant_code_challenge_method,\n og.requires_consent AS grant_requires_consent,\n os.id AS \"session_id?\",\n us.id AS \"user_session_id?\",\n us.created_at AS \"user_session_created_at?\",\n u.id AS \"user_id?\",\n u.username AS \"user_username?\",\n usa.id AS \"user_session_last_authentication_id?\",\n usa.created_at AS \"user_session_last_authentication_created_at?\",\n ue.id AS \"user_email_id?\",\n ue.email AS \"user_email?\",\n ue.created_at AS \"user_email_created_at?\",\n ue.confirmed_at AS \"user_email_confirmed_at?\"\n FROM\n oauth2_authorization_grants og\n LEFT JOIN oauth2_sessions os\n ON os.id = og.oauth2_session_id\n LEFT JOIN user_sessions us\n ON us.id = os.user_session_id\n LEFT JOIN users u\n ON u.id = us.user_id\n LEFT JOIN user_session_authentications usa\n ON usa.session_id = us.id\n LEFT JOIN user_emails ue\n ON ue.id = u.primary_email_id\n\n WHERE og.code = $1\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n " }, "99a1504e3cf80fb4eaad40e8593ac722ba1da7ee29ae674fa9ffe37dffa8b361": { "describe": { @@ -1280,45 +1344,6 @@ }, "query": "\n INSERT INTO oauth2_client_redirect_uris (oauth2_client_id, redirect_uri)\n SELECT $1, uri FROM UNNEST($2::text[]) uri\n " }, - "aadf15f5f4396c9f571419784ef776827ec44e2b3b1b11c2934276c66f96f7d9": { - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Int8" - }, - { - "name": "created_at", - "ordinal": 1, - "type_info": "Timestamptz" - } - ], - "nullable": [ - false, - false - ], - "parameters": { - "Left": [ - "Int8", - "Text", - "Text", - "Text", - "Text", - "Int4", - "Text", - "Text", - "Text", - "Text", - "Bool", - "Bool", - "Bool", - "Text" - ] - } - }, - "query": "\n INSERT INTO oauth2_authorization_grants\n (oauth2_client_id, redirect_uri, scope, state, nonce, max_age,\n acr_values, response_mode, code_challenge, code_challenge_method,\n response_type_code, response_type_token, response_type_id_token,\n code)\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n RETURNING id, created_at\n " - }, "aea289a04e151da235825305a5085bc6aa100fce139dbf10a2c1bed4867fc52a": { "describe": { "columns": [ diff --git a/crates/storage/src/oauth2/authorization_grant.rs b/crates/storage/src/oauth2/authorization_grant.rs index a01eb999..441b347a 100644 --- a/crates/storage/src/oauth2/authorization_grant.rs +++ b/crates/storage/src/oauth2/authorization_grant.rs @@ -44,6 +44,7 @@ pub async fn new_authorization_grant( response_mode: ResponseMode, response_type_token: bool, response_type_id_token: bool, + requires_consent: bool, ) -> anyhow::Result> { let code_challenge = code .as_ref() @@ -61,9 +62,9 @@ pub async fn new_authorization_grant( (oauth2_client_id, redirect_uri, scope, state, nonce, max_age, acr_values, response_mode, code_challenge, code_challenge_method, response_type_code, response_type_token, response_type_id_token, - code) + code, requires_consent) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at "#, &client.data, @@ -81,6 +82,7 @@ pub async fn new_authorization_grant( response_type_token, response_type_id_token, code_str, + requires_consent, ) .fetch_one(executor) .await @@ -101,9 +103,11 @@ pub async fn new_authorization_grant( created_at: res.created_at, response_type_token, response_type_id_token, + requires_consent, }) } +#[allow(clippy::struct_excessive_bools)] struct GrantLookup { grant_id: i64, grant_created_at: DateTime, @@ -123,6 +127,7 @@ struct GrantLookup { grant_code: Option, grant_code_challenge: Option, grant_code_challenge_method: Option, + grant_requires_consent: bool, oauth2_client_id: i64, session_id: Option, user_session_id: Option, @@ -315,6 +320,7 @@ impl GrantLookup { created_at: self.grant_created_at, response_type_token: self.grant_response_type_token, response_type_id_token: self.grant_response_type_id_token, + requires_consent: self.grant_requires_consent, }) } } @@ -347,6 +353,7 @@ pub async fn get_grant_by_id( og.response_type_id_token AS grant_response_type_id_token, og.code_challenge AS grant_code_challenge, og.code_challenge_method AS grant_code_challenge_method, + og.requires_consent AS grant_requires_consent, os.id AS "session_id?", us.id AS "user_session_id?", us.created_at AS "user_session_created_at?", @@ -415,6 +422,7 @@ pub async fn lookup_grant_by_code( og.response_type_id_token AS grant_response_type_id_token, og.code_challenge AS grant_code_challenge, og.code_challenge_method AS grant_code_challenge_method, + og.requires_consent AS grant_requires_consent, os.id AS "session_id?", us.id AS "user_session_id?", us.created_at AS "user_session_created_at?", @@ -511,13 +519,35 @@ pub async fn fulfill_grant( ) .fetch_one(executor) .await - .context("could not makr grant as fulfilled")?; + .context("could not mark grant as fulfilled")?; grant.stage = grant.stage.fulfill(fulfilled_at, session)?; Ok(grant) } +pub async fn give_consent_to_grant( + executor: impl PgExecutor<'_>, + mut grant: AuthorizationGrant, +) -> Result, sqlx::Error> { + sqlx::query!( + r#" + UPDATE oauth2_authorization_grants AS og + SET + requires_consent = 'f' + WHERE + og.id = $1 + "#, + grant.data, + ) + .execute(executor) + .await?; + + grant.requires_consent = false; + + Ok(grant) +} + pub async fn exchange_grant( executor: impl PgExecutor<'_>, mut grant: AuthorizationGrant, diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index bccc9733..6899f5ae 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -205,10 +205,6 @@ impl Templates { bail!("Builtin templates are not included in dev binaries") } - tokio::fs::create_dir_all(&path) - .await - .context("could not create destination folder")?; - let templates = TEMPLATES.into_iter().chain(EXTRA_TEMPLATES); let mut options = OpenOptions::new(); @@ -224,6 +220,12 @@ impl Templates { if let Some(source) = source { let path = path.join(name); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(&parent) + .await + .context("could not create destination")?; + } + let mut file = match options.open(&path).await { Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { // Not overwriting a template is a soft error