From 29738126268800decb50d8e4b20c35fd36d64c10 Mon Sep 17 00:00:00 2001 From: Kyuuk <16566073+kyuuk@users.noreply.github.com> Date: Thu, 30 Jan 2025 03:40:52 +0100 Subject: [PATCH] Allow customizing username when creating user through OIDC (#971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add ability to cutomize claim user for username generation on oidc login * update documentation with new OIDC options * oidc: also normalize custom claim as username * improve tests * improve docs * some more cleanup --------- Co-authored-by: Sascha Ißbrücker --- bookmarks/tests/test_oidc_support.py | 86 ++++++++++++++++++++++++++-- bookmarks/utils.py | 10 +++- docs/src/content/docs/options.md | 4 +- siteroot/settings/base.py | 2 + 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/bookmarks/tests/test_oidc_support.py b/bookmarks/tests/test_oidc_support.py index b3525a5..df1640b 100644 --- a/bookmarks/tests/test_oidc_support.py +++ b/bookmarks/tests/test_oidc_support.py @@ -4,6 +4,8 @@ import os from django.test import TestCase, override_settings from django.urls import URLResolver +from bookmarks import utils + class OidcSupportTest(TestCase): def test_should_not_add_oidc_urls_by_default(self): @@ -55,9 +57,83 @@ class OidcSupportTest(TestCase): base_settings = importlib.import_module("siteroot.settings.base") importlib.reload(base_settings) - self.assertEqual( - True, - base_settings.OIDC_VERIFY_SSL, - ) + self.assertEqual(True, base_settings.OIDC_VERIFY_SSL) + self.assertEqual("openid email profile", base_settings.OIDC_RP_SCOPES) + self.assertEqual("email", base_settings.OIDC_USERNAME_CLAIM) - del os.environ["LD_ENABLE_OIDC"] + del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable + + @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="email") + def test_username_should_use_email_by_default(self): + claims = { + "email": "test@example.com", + "name": "test name", + "given_name": "test given name", + "preferred_username": "test preferred username", + "nickname": "test nickname", + "groups": [], + } + + username = utils.generate_username(claims["email"], claims) + + self.assertEqual(claims["email"], username) + + @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username") + def test_username_should_use_custom_claim(self): + claims = { + "email": "test@example.com", + "name": "test name", + "given_name": "test given name", + "preferred_username": "test preferred username", + "nickname": "test nickname", + "groups": [], + } + + username = utils.generate_username(claims["email"], claims) + + self.assertEqual(claims["preferred_username"], username) + + @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="nonexistant_claim") + def test_username_should_fallback_to_email_for_non_existing_claim(self): + claims = { + "email": "test@example.com", + "name": "test name", + "given_name": "test given name", + "preferred_username": "test preferred username", + "nickname": "test nickname", + "groups": [], + } + + username = utils.generate_username(claims["email"], claims) + + self.assertEqual(claims["email"], username) + + @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username") + def test_username_should_fallback_to_email_for_empty_claim(self): + claims = { + "email": "test@example.com", + "name": "test name", + "given_name": "test given name", + "preferred_username": "", + "nickname": "test nickname", + "groups": [], + } + + username = utils.generate_username(claims["email"], claims) + + self.assertEqual(claims["email"], username) + + @override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username") + def test_username_should_be_normalized(self): + claims = { + "email": "test@example.com", + "name": "test name", + "given_name": "test given name", + "preferred_username": "NormalizedUser", + "nickname": "test nickname", + "groups": [], + } + + username = utils.generate_username(claims["email"], claims) + + self.assertEqual("NormalizedUser", username) diff --git a/bookmarks/utils.py b/bookmarks/utils.py index a9ec075..27db7d4 100644 --- a/bookmarks/utils.py +++ b/bookmarks/utils.py @@ -9,6 +9,7 @@ from dateutil.relativedelta import relativedelta from django.http import HttpResponseRedirect from django.template.defaultfilters import pluralize from django.utils import timezone, formats +from django.conf import settings try: with open("version.txt", "r") as f: @@ -128,10 +129,13 @@ def redirect_with_query(request, redirect_url): return HttpResponseRedirect(redirect_url) -def generate_username(email): +def generate_username(email, claims): # taken from mozilla-django-oidc docs :) - # Using Python 3 and Django 1.11+, usernames can contain alphanumeric # (ascii and unicode), _, @, +, . and - characters. So we normalize # it and slice at 150 characters. - return unicodedata.normalize("NFKC", email)[:150] + if settings.OIDC_USERNAME_CLAIM in claims and claims[settings.OIDC_USERNAME_CLAIM]: + username = claims[settings.OIDC_USERNAME_CLAIM] + else: + username = email + return unicodedata.normalize("NFKC", username)[:150] diff --git a/docs/src/content/docs/options.md b/docs/src/content/docs/options.md index 3938ddf..506f00e 100644 --- a/docs/src/content/docs/options.md +++ b/docs/src/content/docs/options.md @@ -105,7 +105,7 @@ Values: `True`, `False` | Default = `False` Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers. When enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider. -Users are associated by the email address provided from the OIDC provider, which is used as the username in linkding. +Users are associated by the email address provided from the OIDC provider, which is by default also used as username in linkding. You can configure a custom claim to be used as username with `OIDC_USERNAME_CLAIM`. If there is no user with that email address as username, a new user is created automatically. This requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured. @@ -124,6 +124,8 @@ The following options can be configured: - `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`. - `OIDC_USE_PKCE` - Whether to use PKCE for the OIDC flow. Default is `True`. - `OIDC_VERIFY_SSL` - Whether to verify the SSL certificate of the OIDC provider. Set to `False` if using self-signed certificates or custom certificate authority. Default is `True`. +- `OIDC_RP_SCOPES` - Scopes asked for on the authorization flow. Default is `oidc email profile`. +- `OIDC_USERNAME_CLAIM` - A custom claim to used as username for new accounts, for example `preferred_username`. If the configured claim does not exist or is empty, the email claim is used as fallback. Default is `email`.
diff --git a/siteroot/settings/base.py b/siteroot/settings/base.py index f822075..a9487a9 100644 --- a/siteroot/settings/base.py +++ b/siteroot/settings/base.py @@ -194,8 +194,10 @@ if LD_ENABLE_OIDC: OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID") OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET") OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256") + OIDC_RP_SCOPES = os.getenv("OIDC_RP_SCOPES", "openid email profile") OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1") OIDC_VERIFY_SSL = os.getenv("OIDC_VERIFY_SSL", True) in (True, "True", "1") + OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "email") # Enable authentication proxy support if configured LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")