Repository: sissbruecker/linkding (python)

Issue:
OIDC: Option for Username instead of Email
It would be nice if one could configure it so when logging in with OIDC is reads the username field rather than email field.

Edit: Biggest reason is if my OIDC users want to share their bookmarks, they'd be able to do that without sharing their email address with the world (or just other users if not sharing publicly).

Pull request:
Allow customizing username when creating user through OIDC
Fixes #793

Allow config of oidc claim used for username generation (default to email claim to avoid breaking existing installs)




Patch:
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`.
 
 <details>
 
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")


Test patch:
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"]  # 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": "ＮｏｒｍａｌｉｚｅｄＵｓｅｒ",
+            "nickname": "test nickname",
+            "groups": [],
+        }
+
+        username = utils.generate_username(claims["email"], claims)
 
-        del os.environ["LD_ENABLE_OIDC"]
+        self.assertEqual("NormalizedUser", username)