1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-29 22:01:14 +03:00

Authorization grant policy (#288)

Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
This commit is contained in:
Quentin Gliech
2022-07-21 11:18:59 -05:00
committed by GitHub
parent a263330ea5
commit ba6a382f2c
12 changed files with 319 additions and 21 deletions

View File

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

View File

@ -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<serde_json::Value>,
@ -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,
}
}

View File

@ -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<CallbackDestinationError> for RouteError {
}
pub(crate) async fn get(
Extension(policy_factory): Extension<Arc<PolicyFactory>>,
Extension(templates): Extension<Templates>,
Extension(pool): Extension<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>,
@ -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<sqlx::Error> for GrantCompletionError {
@ -182,6 +189,7 @@ impl From<InvalidRedirectUriError> for GrantCompletionError {
pub(crate) async fn complete(
grant: AuthorizationGrant<PostgresqlBackend>,
browser_session: BrowserSession<PostgresqlBackend>,
policy_factory: &PolicyFactory,
mut txn: Transaction<'_, Postgres>,
) -> Result<AuthorizationResponse<Option<AccessTokenResponse>>, 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?;

View File

@ -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<Arc<PolicyFactory>>,
Extension(templates): Extension<Templates>,
Extension(pool): Extension<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>,
@ -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) => {

View File

@ -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<Arc<PolicyFactory>>,
Extension(templates): Extension<Templates>,
Extension(pool): Extension<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>,
@ -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<Arc<PolicyFactory>>,
Extension(pool): Extension<PgPool>,
cookie_jar: PrivateCookieJar<Encrypter>,
Path(grant_id): Path<i64>,
@ -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

View File

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

View File

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

View File

@ -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:*"}
}

View File

@ -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<Self, LoadError> {
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<T: StorageBackend + std::fmt::Debug>(
&mut self,
authorization_grant: &AuthorizationGrant<T>,
user: &User<T>,
) -> Result<EvaluationResult, anyhow::Error> {
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();

View File

@ -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<Self>
where
Self: Sized,
{
// TODO
vec![]
}
}
impl PolicyViolationContext {
/// Constructs a context for the policy violation page
#[must_use]
pub fn new<T>(grant: T, action: PostAuthAction) -> Self
where
T: Into<AuthorizationGrant<()>>,
{
Self {
grant: grant.into(),
action,
}
}
}
/// Fields of the reauthentication form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]

View File

@ -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<WithSession<ConsentContext>>) { "pages/consent.html" }
/// Render the client consent page
/// Render the policy violation page
pub fn render_policy_violation(WithCsrf<WithSession<PolicyViolationContext>>) { "pages/policy_violation.html" }
/// Render the legacy SSO login consent page
pub fn render_sso_login(WithCsrf<WithSession<CompatSsoContext>>) { "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?;

View File

@ -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 %}
<section class="flex items-center justify-center flex-1">
<div class="w-96 m-2">
<div class="grid grid-cols-1 gap-6">
<h1 class="text-xl font-bold">The authorization request was denied the policy enforced by this service.</h1>
<p>This might be because of the client which authored the request, the currently logged in user, or the request itself.</p>
<div class="rounded-lg bg-grey-25 dark:bg-grey-450 p-2 flex items-center">
<div class="bg-white rounded w-16 h-16 overflow-hidden mx-auto">
{% if grant.client.logo_uri %}
<img class="w-16 h-16" src="{{ grant.client.logo_uri }}" />
{% endif %}
</div>
<h1 class="text-lg text-center font-medium flex-1"><a target="_blank" href="{{ grant.client.client_uri }}" class="text-accent">{{ grant.client.client_name | default(value=grant.client.client_id) }}</a></h1>
</div>
<div class="rounded-lg bg-grey-25 dark:bg-grey-450 p-2 flex items-center">
<div class="text-center flex-1">
Logged as <span class="font-bold">{{ current_session.user.username }}</span>
</div>
{{ logout::button(text="Sign out", class=button::plain_error_class(), csrf_token=csrf_token, post_logout_action=action) }}
</div>
{{ 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)
) }}
</div>
</div>
</section>
{% endblock content %}