diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index b998713a..0b62ff80 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -196,6 +196,7 @@ impl Options { config.policy.data.clone().unwrap_or_default(), config.policy.register_entrypoint.clone(), config.policy.client_registration_entrypoint.clone(), + config.policy.authorization_grant_entrypoint.clone(), ) .await .context("failed to load the policy")?; diff --git a/crates/config/src/sections/policy.rs b/crates/config/src/sections/policy.rs index 49cf6bb5..0904f3c6 100644 --- a/crates/config/src/sections/policy.rs +++ b/crates/config/src/sections/policy.rs @@ -29,6 +29,10 @@ fn default_register_endpoint() -> String { "register/violation".to_string() } +fn default_authorization_grant_endpoint() -> String { + "authorization_grant/violation".to_string() +} + /// Application secrets #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -45,6 +49,10 @@ pub struct PolicyConfig { #[serde(default = "default_register_endpoint")] pub register_entrypoint: String, + /// Entrypoint to use when evaluating authorization grants + #[serde(default = "default_authorization_grant_endpoint")] + pub authorization_grant_entrypoint: String, + /// Arbitrary data to pass to the policy #[serde(default)] pub data: Option, @@ -56,6 +64,7 @@ impl Default for PolicyConfig { wasm_module: None, client_registration_entrypoint: default_client_registration_endpoint(), register_entrypoint: default_register_endpoint(), + authorization_grant_entrypoint: default_authorization_grant_endpoint(), data: None, } } diff --git a/crates/handlers/src/oauth2/authorization/complete.rs b/crates/handlers/src/oauth2/authorization/complete.rs index f2b961b5..eebff629 100644 --- a/crates/handlers/src/oauth2/authorization/complete.rs +++ b/crates/handlers/src/oauth2/authorization/complete.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::sync::Arc; + use anyhow::anyhow; use axum::{ extract::Path, @@ -24,6 +26,7 @@ use hyper::StatusCode; use mas_axum_utils::SessionInfoExt; use mas_config::Encrypter; use mas_data_model::{AuthorizationGrant, BrowserSession, TokenType}; +use mas_policy::PolicyFactory; use mas_router::{PostAuthAction, Route}; use mas_storage::{ oauth2::{ @@ -105,6 +108,7 @@ impl From for RouteError { } pub(crate) async fn get( + Extension(policy_factory): Extension>, Extension(templates): Extension, Extension(pool): Extension, cookie_jar: PrivateCookieJar, @@ -129,7 +133,7 @@ pub(crate) async fn get( return Ok((cookie_jar, mas_router::Login::and_then(continue_grant).go()).into_response()); }; - match complete(grant, session, txn).await { + match complete(grant, session, &policy_factory, txn).await { Ok(params) => { let res = callback_destination.go(&templates, params).await?; Ok((cookie_jar, res).into_response()) @@ -139,7 +143,7 @@ pub(crate) async fn get( mas_router::Reauth::and_then(continue_grant).go(), ) .into_response()), - Err(GrantCompletionError::RequiresConsent) => { + Err(GrantCompletionError::RequiresConsent | GrantCompletionError::PolicyViolation) => { let next = mas_router::Consent(grant_id); Ok((cookie_jar, next.go()).into_response()) } @@ -165,6 +169,9 @@ pub enum GrantCompletionError { #[error("client lacks consent")] RequiresConsent, + + #[error("denied by the policy")] + PolicyViolation, } impl From for GrantCompletionError { @@ -182,6 +189,7 @@ impl From for GrantCompletionError { pub(crate) async fn complete( grant: AuthorizationGrant, browser_session: BrowserSession, + policy_factory: &PolicyFactory, mut txn: Transaction<'_, Postgres>, ) -> Result>, GrantCompletionError> { // Verify that the grant is in a pending stage @@ -195,6 +203,16 @@ pub(crate) async fn complete( return Err(GrantCompletionError::RequiresReauth); } + // Run through the policy + let mut policy = policy_factory.instantiate().await?; + let res = policy + .evaluate_authorization_grant(&grant, &browser_session.user) + .await?; + + if !res.valid() { + return Err(GrantCompletionError::PolicyViolation); + } + let current_consent = fetch_client_consent(&mut txn, &browser_session.user, &grant.client).await?; diff --git a/crates/handlers/src/oauth2/authorization/mod.rs b/crates/handlers/src/oauth2/authorization/mod.rs index a6dda439..27529682 100644 --- a/crates/handlers/src/oauth2/authorization/mod.rs +++ b/crates/handlers/src/oauth2/authorization/mod.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::sync::Arc; + use anyhow::{anyhow, Context}; use axum::{ extract::{Extension, Form}, @@ -23,6 +25,7 @@ use mas_axum_utils::SessionInfoExt; use mas_config::Encrypter; use mas_data_model::{AuthorizationCode, Pkce}; use mas_iana::oauth::OAuthAuthorizationEndpointResponseType; +use mas_policy::PolicyFactory; use mas_router::{PostAuthAction, Route}; use mas_storage::oauth2::{ authorization_grant::new_authorization_grant, @@ -31,7 +34,7 @@ use mas_storage::oauth2::{ use mas_templates::Templates; use oauth2_types::{ errors::{ - CONSENT_REQUIRED, INTERACTION_REQUIRED, INVALID_REQUEST, LOGIN_REQUIRED, + ACCESS_DENIED, CONSENT_REQUIRED, INTERACTION_REQUIRED, INVALID_REQUEST, LOGIN_REQUIRED, REGISTRATION_NOT_SUPPORTED, REQUEST_NOT_SUPPORTED, REQUEST_URI_NOT_SUPPORTED, SERVER_ERROR, UNAUTHORIZED_CLIENT, }, @@ -157,6 +160,7 @@ fn resolve_response_mode( #[allow(clippy::too_many_lines)] pub(crate) async fn get( + Extension(policy_factory): Extension>, Extension(templates): Extension, Extension(pool): Extension, cookie_jar: PrivateCookieJar, @@ -306,7 +310,8 @@ pub(crate) async fn get( // 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 { + match self::complete::complete(grant, user_session, &policy_factory, txn).await + { Ok(params) => callback_destination.go(&templates, params).await?, Err(GrantCompletionError::RequiresConsent) => { callback_destination @@ -318,6 +323,9 @@ pub(crate) async fn get( .go(&templates, INTERACTION_REQUIRED) .await? } + Err(GrantCompletionError::PolicyViolation) => { + callback_destination.go(&templates, ACCESS_DENIED).await? + } Err(GrantCompletionError::Anyhow(a)) => return Err(RouteError::Anyhow(a)), Err(GrantCompletionError::Internal(e)) => { return Err(RouteError::Internal(e)) @@ -331,9 +339,17 @@ pub(crate) async fn get( (Some(user_session), _) => { let grant_id = grant.data; // Else, we show the relevant reauth/consent page if necessary - match self::complete::complete(grant, user_session, txn).await { + match self::complete::complete(grant, user_session, &policy_factory, txn).await + { Ok(params) => callback_destination.go(&templates, params).await?, - Err(GrantCompletionError::RequiresConsent) => { + Err( + GrantCompletionError::RequiresConsent + | GrantCompletionError::PolicyViolation, + ) => { + // We're redirecting to the consent URI in both 'consent required' and + // 'policy violation' cases, because we reevaluate the policy on this + // page, and show the error accordingly + // XXX: is this the right approach? mas_router::Consent(grant_id).go().into_response() } Err(GrantCompletionError::RequiresReauth) => { diff --git a/crates/handlers/src/oauth2/consent.rs b/crates/handlers/src/oauth2/consent.rs index e4cd12a8..4bfd79fc 100644 --- a/crates/handlers/src/oauth2/consent.rs +++ b/crates/handlers/src/oauth2/consent.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::sync::Arc; + use anyhow::Context; use axum::{ extract::{Extension, Form, Path}, @@ -25,12 +27,13 @@ use mas_axum_utils::{ }; use mas_config::Encrypter; use mas_data_model::AuthorizationGrantStage; +use mas_policy::PolicyFactory; use mas_router::{PostAuthAction, Route}; use mas_storage::oauth2::{ authorization_grant::{get_grant_by_id, give_consent_to_grant}, consent::insert_client_consent, }; -use mas_templates::{ConsentContext, TemplateContext, Templates}; +use mas_templates::{ConsentContext, PolicyViolationContext, TemplateContext, Templates}; use sqlx::PgPool; use thiserror::Error; @@ -47,6 +50,7 @@ impl IntoResponse for RouteError { } pub(crate) async fn get( + Extension(policy_factory): Extension>, Extension(templates): Extension, Extension(pool): Extension, cookie_jar: PrivateCookieJar, @@ -73,16 +77,34 @@ pub(crate) async fn get( if let Some(session) = maybe_session { let (csrf_token, cookie_jar) = cookie_jar.csrf_token(); - let ctx = ConsentContext::new(grant, PostAuthAction::continue_grant(grant_id)) - .with_session(session) - .with_csrf(csrf_token.form_value()); + let mut policy = policy_factory.instantiate().await?; + let res = policy + .evaluate_authorization_grant(&grant, &session.user) + .await?; - let content = templates - .render_consent(&ctx) - .await - .context("failed to render template")?; + if res.valid() { + let ctx = ConsentContext::new(grant, PostAuthAction::continue_grant(grant_id)) + .with_session(session) + .with_csrf(csrf_token.form_value()); - Ok((cookie_jar, Html(content)).into_response()) + let content = templates + .render_consent(&ctx) + .await + .context("failed to render template")?; + + Ok((cookie_jar, Html(content)).into_response()) + } else { + let ctx = PolicyViolationContext::new(grant, PostAuthAction::continue_grant(grant_id)) + .with_session(session) + .with_csrf(csrf_token.form_value()); + + let content = templates + .render_policy_violation(&ctx) + .await + .context("failed to render template")?; + + Ok((cookie_jar, Html(content)).into_response()) + } } else { let login = mas_router::Login::and_continue_grant(grant_id); Ok((cookie_jar, login.go()).into_response()) @@ -90,6 +112,7 @@ pub(crate) async fn get( } pub(crate) async fn post( + Extension(policy_factory): Extension>, Extension(pool): Extension, cookie_jar: PrivateCookieJar, Path(grant_id): Path, @@ -121,6 +144,15 @@ pub(crate) async fn post( return Ok((cookie_jar, login.go()).into_response()); }; + let mut policy = policy_factory.instantiate().await?; + let res = policy + .evaluate_authorization_grant(&grant, &session.user) + .await?; + + if !res.valid() { + return Err(anyhow::anyhow!("policy violation").into()); + } + // Do not consent for the "urn:matrix:device:*" scope let scope_without_device = grant .scope diff --git a/crates/policy/policies/Makefile b/crates/policy/policies/Makefile index 9ca9d016..5e66b393 100644 --- a/crates/policy/policies/Makefile +++ b/crates/policy/policies/Makefile @@ -9,8 +9,12 @@ else OPA_RW := docker run -i -v $(shell pwd):/policies -w /policies --rm docker.io/openpolicyagent/opa:0.41.0 endif -policy.wasm: client_registration.rego register.rego - $(OPA_RW) build -t wasm -e "client_registration/violation" -e "register/violation" $^ +policy.wasm: client_registration.rego register.rego authorization_grant.rego + $(OPA_RW) build -t wasm \ + -e "client_registration/violation" \ + -e "register/violation" \ + -e "authorization_grant/violation" \ + $^ tar xzf bundle.tar.gz /policy.wasm $(RM) bundle.tar.gz touch $@ diff --git a/crates/policy/policies/authorization_grant.rego b/crates/policy/policies/authorization_grant.rego new file mode 100644 index 00000000..ffb0befe --- /dev/null +++ b/crates/policy/policies/authorization_grant.rego @@ -0,0 +1,38 @@ +package authorization_grant + +import future.keywords.in + +default allow := false + +allow { + count(violation) == 0 +} + +# Special case to make empty scope work +allowed_scope("") = true + +allowed_scope("openid") = true + +allowed_scope("email") = true + +allowed_scope("urn:synapse:admin:*") { + some user in data.admin_users + input.user.username == user +} + +allowed_scope(scope) { + regex.match("urn:matrix:device:[A-Za-z0-9-]{10,}", scope) +} + +allowed_scope("urn:matrix:api:*") = true + +violation[{"msg": msg}] { + some scope in split(input.authorization_grant.scope, " ") + not allowed_scope(scope) + msg := sprintf("scope '%s' not allowed", [scope]) +} + +violation[{"msg": "only one device scope is allowed at a time"}] { + scope_list := split(input.authorization_grant.scope, " ") + count({key | scope_list[key]; startswith(scope_list[key], "urn:matrix:device:")}) > 1 +} diff --git a/crates/policy/policies/authorization_grant_test.rego b/crates/policy/policies/authorization_grant_test.rego new file mode 100644 index 00000000..ba4adf5c --- /dev/null +++ b/crates/policy/policies/authorization_grant_test.rego @@ -0,0 +1,63 @@ +package authorization_grant + +user := {"username": "john"} + +test_standard_scopes { + allow with input.user as user + with input.authorization_grant as {"scope": "openid"} + + allow with input.user as user + with input.authorization_grant as {"scope": "email"} + + allow with input.user as user + with input.authorization_grant as {"scope": "openid email"} + + # Not supported yet + not allow with input.user as user + with input.authorization_grant as {"scope": "phone"} + + # Not supported yet + not allow with input.user as user + with input.authorization_grant as {"scope": "profile"} +} + +test_matrix_scopes { + allow with input.user as user + with input.authorization_grant as {"scope": "urn:matrix:api:*"} +} + +test_device_scopes { + allow with input.user as user + with input.authorization_grant as {"scope": "urn:matrix:device:AAbbCCdd01"} + + allow with input.user as user + with input.authorization_grant as {"scope": "urn:matrix:device:AAbbCCdd01-asdasdsa1-2313"} + + # Invalid characters + not allow with input.user as user + with input.authorization_grant as {"scope": "urn:matrix:device:AABB:CCDDEE"} + + not allow with input.user as user + with input.authorization_grant as {"scope": "urn:matrix:device:AABB*CCDDEE"} + + not allow with input.user as user + with input.authorization_grant as {"scope": "urn:matrix:device:AABB!CCDDEE"} + + # Too short + not allow with input.user as user + with input.authorization_grant as {"scope": "urn:matrix:device:abcd"} + + # Multiple device scope + not allow with input.user as user + with input.authorization_grant as {"scope": "urn:matrix:device:AAbbCCdd01 urn:matrix:device:AAbbCCdd02"} +} + +test_synapse_admin_scopes { + allow with input.user as user + with data.admin_users as ["john"] + with input.authorization_grant as {"scope": "urn:synapse:admin:*"} + + not allow with input.user as user + with data.admin_users as [] + with input.authorization_grant as {"scope": "urn:synapse:admin:*"} +} diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index ef013380..1737c2c6 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -15,6 +15,7 @@ use std::io::Cursor; use anyhow::bail; +use mas_data_model::{AuthorizationGrant, StorageBackend, User}; use oauth2_types::registration::ClientMetadata; use opa_wasm::Runtime; use serde::Deserialize; @@ -52,6 +53,7 @@ pub struct PolicyFactory { data: serde_json::Value, register_entrypoint: String, client_registration_entrypoint: String, + authorization_grant_endpoint: String, } impl PolicyFactory { @@ -60,6 +62,7 @@ impl PolicyFactory { data: serde_json::Value, register_entrypoint: String, client_registration_entrypoint: String, + authorization_grant_endpoint: String, ) -> Result { let mut config = Config::default(); config.async_support(true); @@ -84,6 +87,7 @@ impl PolicyFactory { data, register_entrypoint, client_registration_entrypoint, + authorization_grant_endpoint, }; // Try to instanciate @@ -105,6 +109,7 @@ impl PolicyFactory { for e in [ self.register_entrypoint.as_str(), self.client_registration_entrypoint.as_str(), + self.authorization_grant_endpoint.as_str(), ] { if !entrypoints.contains(e) { bail!("missing entrypoint {e}") @@ -118,6 +123,7 @@ impl PolicyFactory { instance, register_entrypoint: self.register_entrypoint.clone(), client_registration_entrypoint: self.client_registration_entrypoint.clone(), + authorization_grant_endpoint: self.authorization_grant_endpoint.clone(), }) } } @@ -146,6 +152,7 @@ pub struct Policy { instance: opa_wasm::Policy, register_entrypoint: String, client_registration_entrypoint: String, + authorization_grant_endpoint: String, } impl Policy { @@ -193,6 +200,27 @@ impl Policy { Ok(res) } + + #[tracing::instrument] + pub async fn evaluate_authorization_grant( + &mut self, + authorization_grant: &AuthorizationGrant, + user: &User, + ) -> Result { + let authorization_grant = serde_json::to_value(authorization_grant)?; + let user = serde_json::to_value(user)?; + let input = serde_json::json!({ + "authorization_grant": authorization_grant, + "user": user, + }); + + let [res]: [EvaluationResult; 1] = self + .instance + .evaluate(&mut self.store, &self.authorization_grant_endpoint, &input) + .await?; + + Ok(res) + } } #[cfg(test)] @@ -209,6 +237,7 @@ mod tests { }), "register/violation".to_string(), "client_registration/violation".to_string(), + "authorization_grant/violation".to_string(), ) .await .unwrap(); diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 2038d442..28b56a79 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -414,6 +414,37 @@ impl ConsentContext { } } +/// Context used by the `policy_violation.html` template +#[derive(Serialize)] +pub struct PolicyViolationContext { + grant: AuthorizationGrant<()>, + action: PostAuthAction, +} + +impl TemplateContext for PolicyViolationContext { + fn sample() -> Vec + where + Self: Sized, + { + // TODO + vec![] + } +} + +impl PolicyViolationContext { + /// Constructs a context for the policy violation page + #[must_use] + pub fn new(grant: T, action: PostAuthAction) -> Self + where + T: Into>, + { + Self { + grant: grant.into(), + action, + } + } +} + /// Fields of the reauthentication form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 9e608b4c..134648fe 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -47,9 +47,9 @@ pub use self::{ context::{ AccountContext, AccountEmailsContext, CompatSsoContext, ConsentContext, EmailAddContext, EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, - FormPostContext, IndexContext, LoginContext, LoginFormField, PostAuthContext, - ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, TemplateContext, - WithCsrf, WithOptionalSession, WithSession, + FormPostContext, IndexContext, LoginContext, LoginFormField, PolicyViolationContext, + PostAuthContext, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, + TemplateContext, WithCsrf, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -299,7 +299,10 @@ register_templates! { /// Render the client consent page pub fn render_consent(WithCsrf>) { "pages/consent.html" } - /// Render the client consent page + /// Render the policy violation page + pub fn render_policy_violation(WithCsrf>) { "pages/policy_violation.html" } + + /// Render the legacy SSO login consent page pub fn render_sso_login(WithCsrf>) { "pages/sso.html" } /// Render the home page @@ -346,6 +349,7 @@ impl Templates { check::render_login(self).await?; check::render_register(self).await?; check::render_consent(self).await?; + check::render_policy_violation(self).await?; check::render_sso_login(self).await?; check::render_index(self).await?; check::render_account_index(self).await?; diff --git a/crates/templates/src/res/pages/policy_violation.html b/crates/templates/src/res/pages/policy_violation.html new file mode 100644 index 00000000..90a42717 --- /dev/null +++ b/crates/templates/src/res/pages/policy_violation.html @@ -0,0 +1,53 @@ +{# +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. +#} + +{% extends "base.html" %} + +{% block content %} +
+
+
+

The authorization request was denied the policy enforced by this service.

+

This might be because of the client which authored the request, the currently logged in user, or the request itself.

+
+
+ {% if grant.client.logo_uri %} + + {% endif %} +
+

{{ grant.client.client_name | default(value=grant.client.client_id) }}

+
+ +
+
+ Logged as {{ current_session.user.username }} +
+ + {{ logout::button(text="Sign out", class=button::plain_error_class(), csrf_token=csrf_token, post_logout_action=action) }} +
+ + {{ back_to_client::link( + text="Cancel", + class=button::outline_error_class(), + uri=grant.redirect_uri, + mode=grant.response_mode, + params=dict(error="access_denied", state=grant.state) + ) }} +
+
+
+{% endblock content %} +