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

Use minijinja templates to map OIDC claims to user attributes

This commit is contained in:
Quentin Gliech
2023-11-07 19:31:29 +01:00
parent 939ee57569
commit 6ded397977
10 changed files with 298 additions and 118 deletions

View File

@ -57,6 +57,7 @@ psl = "2.1.4"
time = "0.3.30"
url.workspace = true
mime = "0.3.17"
minijinja.workspace = true
rand.workspace = true
rand_chacha = "0.3.1"
headers = "0.3.9"

View File

@ -20,7 +20,6 @@ use hyper::StatusCode;
use mas_axum_utils::{
cookies::CookieJar, http_client_factory::HttpClientFactory, sentry::SentryEventID,
};
use mas_jose::claims::ClaimError;
use mas_keystore::{Encrypter, Keystore};
use mas_oidc_client::requests::{
authorization_code::AuthorizationValidationData, jose::JwtVerificationData,
@ -83,8 +82,11 @@ pub(crate) enum RouteError {
#[error("Missing ID token")]
MissingIDToken,
#[error("Invalid ID token")]
InvalidIdToken(#[from] ClaimError),
#[error("Could not extract subject from ID token")]
ExtractSubject(#[source] minijinja::Error),
#[error("Subject is empty")]
EmptySubject,
#[error("Error from the provider: {error}")]
ClientError {
@ -236,10 +238,27 @@ pub(crate) async fn get(
)
.await?;
let (_header, mut id_token) = id_token.ok_or(RouteError::MissingIDToken)?.into_parts();
let (_header, id_token) = id_token.ok_or(RouteError::MissingIDToken)?.into_parts();
// Extract the subject from the id_token
let subject = mas_jose::claims::SUB.extract_required(&mut id_token)?;
let env = {
let mut env = minijinja::Environment::new();
env.add_global("user", minijinja::Value::from_serializable(&id_token));
env
};
let template = provider
.claims_imports
.subject
.template
.as_deref()
.unwrap_or("{{ user.sub }}");
let subject = env
.render_str(template, ())
.map_err(RouteError::ExtractSubject)?;
if subject.is_empty() {
return Err(RouteError::EmptySubject);
}
// Look for an existing link
let maybe_link = repo

View File

@ -24,7 +24,7 @@ use mas_axum_utils::{
sentry::SentryEventID,
FancyError, SessionInfoExt,
};
use mas_data_model::{UpstreamOAuthProviderImportPreference, User};
use mas_data_model::{UpstreamOAuthProviderImportAction, User};
use mas_jose::jwt::Jwt;
use mas_policy::Policy;
use mas_router::UrlBuilder;
@ -38,6 +38,7 @@ use mas_templates::{
ErrorContext, TemplateContext, Templates, UpstreamExistingLinkContext, UpstreamRegister,
UpstreamSuggestLink,
};
use minijinja::Environment;
use serde::Deserialize;
use thiserror::Error;
use ulid::Ulid;
@ -64,8 +65,13 @@ pub(crate) enum RouteError {
ProviderNotFound,
/// Required claim was missing in id_token
#[error("Required claim {0:?} missing from the upstream provider's response")]
RequiredClaimMissing(&'static str),
#[error("Template {template:?} could not be rendered from the upstream provider's response for required claim")]
RequiredAttributeRender {
template: String,
#[source]
source: minijinja::Error,
},
/// Session was already consumed
#[error("Session already consumed")]
@ -119,15 +125,6 @@ impl IntoResponse for RouteError {
}
}
#[derive(Deserialize, Default)]
struct StandardClaims {
name: Option<String>,
email: Option<String>,
#[serde(default)]
email_verified: bool,
preferred_username: Option<String>,
}
/// Utility function to import a claim from the upstream provider's response,
/// based on the preference for that attribute.
///
@ -144,23 +141,31 @@ struct StandardClaims {
///
/// Returns an error if the claim is required but missing.
fn import_claim(
name: &'static str,
value: Option<String>,
preference: &UpstreamOAuthProviderImportPreference,
environment: &Environment,
template: &str,
action: &UpstreamOAuthProviderImportAction,
mut run: impl FnMut(String, bool),
) -> Result<(), RouteError> {
// If this claim is ignored, we don't need to do anything.
if preference.ignore() {
if action.ignore() {
return Ok(());
}
// If this claim is required and missing, we can't continue.
if value.is_none() && preference.is_required() {
return Err(RouteError::RequiredClaimMissing(name));
}
match environment.render_str(template, ()) {
Ok(value) if value.is_empty() => { /* Do nothing on empty strings */ }
if let Some(value) = value {
run(value, preference.is_forced());
Ok(value) => run(value, action.is_forced()),
Err(source) => {
if action.is_required() {
return Err(RouteError::RequiredAttributeRender {
template: template.to_owned(),
source,
});
}
tracing::warn!(error = &source as &dyn std::error::Error, %template, "Error while rendering template");
}
}
Ok(())
@ -321,7 +326,7 @@ pub(crate) async fn get(
// account or logging in an existing user
let id_token = upstream_session
.id_token()
.map(Jwt::<'_, StandardClaims>::try_from)
.map(Jwt::<'_, minijinja::Value>::try_from)
.transpose()?;
let provider = repo
@ -336,9 +341,20 @@ pub(crate) async fn get(
let mut ctx = UpstreamRegister::new(&link);
let env = {
let mut e = Environment::new();
e.add_global("user", payload);
e
};
import_claim(
"name",
payload.name,
&env,
provider
.claims_imports
.displayname
.template
.as_deref()
.unwrap_or("{{ user.name }}"),
&provider.claims_imports.displayname,
|value, force| {
ctx.set_display_name(value, force);
@ -346,8 +362,13 @@ pub(crate) async fn get(
)?;
import_claim(
"email",
payload.email,
&env,
provider
.claims_imports
.email
.template
.as_deref()
.unwrap_or("{{ user.email }}"),
&provider.claims_imports.email,
|value, force| {
ctx.set_email(value, force);
@ -355,8 +376,13 @@ pub(crate) async fn get(
)?;
import_claim(
"preferred_username",
payload.preferred_username,
&env,
provider
.claims_imports
.localpart
.template
.as_deref()
.unwrap_or("{{ user.preferred_username }}"),
&provider.claims_imports.localpart,
|value, force| {
ctx.set_localpart(value, force);
@ -450,7 +476,7 @@ pub(crate) async fn post(
let id_token = upstream_session
.id_token()
.map(Jwt::<'_, StandardClaims>::try_from)
.map(Jwt::<'_, minijinja::Value>::try_from)
.transpose()?;
let provider = repo
@ -463,12 +489,28 @@ pub(crate) async fn post(
.map(|id_token| id_token.into_parts().1)
.unwrap_or_default();
let provider_email_verified = payload
.get_item(&minijinja::Value::from("email_verified"))
.map(|v| v.is_true())
.unwrap_or(false);
// Let's try to import the claims from the ID token
let env = {
let mut e = Environment::new();
e.add_global("user", payload);
e
};
let mut name = None;
import_claim(
"name",
payload.name,
&env,
provider
.claims_imports
.displayname
.template
.as_deref()
.unwrap_or("{{ user.name }}"),
&provider.claims_imports.displayname,
|value, force| {
// Import the display name if it is either forced or the user has requested it
@ -480,8 +522,13 @@ pub(crate) async fn post(
let mut email = None;
import_claim(
"email",
payload.email,
&env,
provider
.claims_imports
.email
.template
.as_deref()
.unwrap_or("{{ user.email }}"),
&provider.claims_imports.email,
|value, force| {
// Import the email if it is either forced or the user has requested it
@ -493,8 +540,13 @@ pub(crate) async fn post(
let mut username = username;
import_claim(
"preferred_username",
payload.preferred_username,
&env,
provider
.claims_imports
.localpart
.template
.as_deref()
.unwrap_or("{{ user.preferred_username }}"),
&provider.claims_imports.localpart,
|value, force| {
// If the username is forced, override whatever was in the form
@ -535,13 +587,12 @@ pub(crate) async fn post(
.user_email()
.add(&mut rng, &clock, &user, email)
.await?;
// Mark the email as verified according to the policy and whether the provider
// claims it is, and make it the primary email.
if provider
.claims_imports
.verify_email
.should_mark_as_verified(payload.email_verified)
.should_mark_as_verified(provider_email_verified)
{
let user_email = repo
.user_email()