customize jwt claims with format string in config file

This commit is contained in:
Camille 2021-12-10 14:56:43 +00:00 committed by Éloi Rivard
parent c8759fe088
commit cefeac4e5b
5 changed files with 145 additions and 47 deletions

View file

@ -138,17 +138,18 @@ EXP = 3600
[JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass. Default values fits inetOrgPerson.
SUB = "uid"
NAME = "cn"
PHONE_NUMBER = "telephoneNumber"
EMAIL = "mail"
GIVEN_NAME = "givenName"
FAMILY_NAME = "sn"
PREFERRED_USERNAME = "displayName"
LOCALE = "preferredLanguage"
PICTURE = "jpegPhoto"
ADDRESS = "postalAddress"
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
SUB = "{uid}"
NAME = "{cn}"
PHONE_NUMBER = "{telephoneNumber}"
EMAIL = "{mail}"
GIVEN_NAME = "{givenName}"
FAMILY_NAME = "{sn}"
PREFERRED_USERNAME = "{displayName}"
LOCALE = "{preferredLanguage}"
ADDRESS = "{postalAddress}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.

View file

@ -17,6 +17,7 @@ from authlib.oidc.core.grants import (
OpenIDHybridGrant as _OpenIDHybridGrant,
)
from authlib.oidc.core import UserInfo
from collections import defaultdict
from flask import current_app
from .models import Client, AuthorizationCode, Token, User
@ -38,9 +39,9 @@ def get_jwt_config(grant):
def generate_user_info(user, scope):
user = User.get(dn=user)
fields = ["sub"]
claims = ["sub"]
if "profile" in scope:
fields += [
claims += [
"name",
"family_name",
"given_name",
@ -56,26 +57,40 @@ def generate_user_info(user, scope):
"updated_at",
]
if "email" in scope:
fields += ["email", "email_verified"]
claims += ["email", "email_verified"]
if "address" in scope:
fields += ["address"]
claims += ["address"]
if "phone" in scope:
fields += ["phone_number", "phone_number_verified"]
claims += ["phone_number", "phone_number_verified"]
if "groups" in scope:
fields += ["groups"]
claims += ["groups"]
data = generate_user_claims(user, claims)
return UserInfo(**data)
def generate_user_claims(user, claims, jwt_mapping_config=None):
jwt_mapping_config = jwt_mapping_config or current_app.config["JWT"]["MAPPING"]
user_ldap_attributes = defaultdict(str)
for attr in user.attrs:
user_ldap_attributes[attr] = user.__getattr__(attr)
if isinstance(user_ldap_attributes[attr], list):
user_ldap_attributes[attr] = user_ldap_attributes[attr][0]
data = {}
for field in fields:
ldap_field_match = current_app.config["JWT"]["MAPPING"].get(field.upper())
if ldap_field_match and ldap_field_match in user.attrs:
data[field] = user.__getattr__(ldap_field_match)
if isinstance(data[field], list):
data[field] = data[field][0]
if field == "groups":
for claim in claims:
format_claim = jwt_mapping_config.get(claim.upper())
if format_claim:
formatted_claim = format_claim.format_map(user_ldap_attributes)
if formatted_claim:
# According to https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
# it's better to not insert a null or empty string value
data[claim] = formatted_claim
if claim == "groups":
group_name_attr = current_app.config["LDAP"]["GROUP_NAME_ATTRIBUTE"]
data[field] = [getattr(g, group_name_attr)[0] for g in user.groups]
return UserInfo(**data)
data[claim] = [getattr(g, group_name_attr)[0] for g in user.groups]
return data
def save_authorization_code(code, request):

View file

@ -145,17 +145,18 @@ EXP = 3600
[JWT.MAPPING]
# Mapping between JWT fields and LDAP attributes from your
# User objectClass. Default values fits inetOrgPerson.
SUB = "uid"
NAME = "cn"
PHONE_NUMBER = "telephoneNumber"
EMAIL = "mail"
GIVEN_NAME = "givenName"
FAMILY_NAME = "sn"
PREFERRED_USERNAME = "displayName"
LOCALE = "preferredLanguage"
PICTURE = "jpegPhoto"
ADDRESS = "postalAddress"
# User objectClass.
# {attribute} will be replaced by the user ldap attribute value.
# Default values fits inetOrgPerson.
SUB = "{uid}"
NAME = "{cn}"
PHONE_NUMBER = "{telephoneNumber}"
EMAIL = "{mail}"
GIVEN_NAME = "{givenName}"
FAMILY_NAME = "{sn}"
PREFERRED_USERNAME = "{displayName}"
LOCALE = "{preferredLanguage}"
ADDRESS = "{postalAddress}"
# The SMTP server options. If not set, mail related features such as
# user invitations, and password reset emails, will be disabled.

View file

@ -186,15 +186,14 @@ def configuration(slapd_server, smtpd, keypair_path):
"KTY": "RSA",
"EXP": 3600,
"MAPPING": {
"SUB": "uid",
"NAME": "cn",
"PHONE_NUMBER": "telephoneNumber",
"EMAIL": "mail",
"GIVEN_NAME": "givenName",
"FAMILY_NAME": "sn",
"PREFERRED_USERNAME": "displayName",
"LOCALE": "preferredLanguage",
"PICTURE": "jpegPhoto",
"SUB": "{uid}",
"NAME": "{cn}",
"PHONE_NUMBER": "{telephoneNumber}",
"EMAIL": "{mail}",
"GIVEN_NAME": "{givenName}",
"FAMILY_NAME": "{sn}",
"PREFERRED_USERNAME": "{displayName}",
"LOCALE": "{preferredLanguage}",
},
},
"SMTP": {

82
tests/test_oauth2utils.py Normal file
View file

@ -0,0 +1,82 @@
from canaille.models import User
from canaille.oauth2utils import generate_user_claims
STANDARD_CLAIMS = [
"sub",
"name",
"given_name",
"family_name",
"middle_name",
"nickname",
"preferred_username",
"profile",
"picture",
"website",
"email",
"email_verified",
"gender",
"birthdate",
"zoneinfo",
"locale",
"phone_number",
"phone_number_verified",
"address",
"updated_at",
]
DEFAULT_JWT_MAPPING_CONFIG = {
"SUB": "{uid}",
"NAME": "{cn}",
"PHONE_NUMBER": "{telephoneNumber}",
"EMAIL": "{mail}",
"GIVEN_NAME": "{givenName}",
"FAMILY_NAME": "{sn}",
"PREFERRED_USERNAME": "{displayName}",
"LOCALE": "{preferredLanguage}",
}
def test_generate_user_standard_claims_with_default_config(slapd_connection, user):
User.ldap_object_classes(slapd_connection)
data = generate_user_claims(user, STANDARD_CLAIMS, DEFAULT_JWT_MAPPING_CONFIG)
assert data == {
"name": "John (johnny) Doe",
"family_name": "Doe",
"email": "john@doe.com",
"sub": "user",
}
def test_custom_config_format_claim_is_well_formated(slapd_connection, user):
User.ldap_object_classes(slapd_connection)
jwt_mapping_config = DEFAULT_JWT_MAPPING_CONFIG.copy()
jwt_mapping_config["EMAIL"] = "{uid}@mydomain.tld"
data = generate_user_claims(user, STANDARD_CLAIMS, jwt_mapping_config)
assert data["email"] == "user@mydomain.tld"
def test_claim_is_omitted_if_empty(slapd_connection, user):
# According to https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
# it's better to not insert a null or empty string value
User.ldap_object_classes(slapd_connection)
user.mail = ""
user.save(slapd_connection)
data = generate_user_claims(user, STANDARD_CLAIMS, DEFAULT_JWT_MAPPING_CONFIG)
assert "email" not in data
def test_custom_format_claim_is_formatted_with_empty_value_and_not_omitted(slapd_connection, user):
# If the jwt mapping config is customized, it's not canaille's responsability to verify value consistency when one user attribute is not set or null.
# Attribute field is left empty in the formatted string.
User.ldap_object_classes(slapd_connection)
jwt_mapping_config = DEFAULT_JWT_MAPPING_CONFIG.copy()
jwt_mapping_config["EMAIL"] = "{givenName}@mydomain.tld"
data = generate_user_claims(user, STANDARD_CLAIMS, jwt_mapping_config)
assert data["email"] == "@mydomain.tld"