Repository: sissbruecker/linkding

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).


Discussion:
https://github.com/sissbruecker/linkding/blob/fbc97a38410d136bbfb3c60f312d695542e02510/siteroot/settings/base.py#L208

https://github.com/sissbruecker/linkding/blob/fbc97a38410d136bbfb3c60f312d695542e02510/bookmarks/utils.py#L117-L123

I don't understand how the `email` parameter is being passed at all, but my proposal is basically an additional config option, like `OIDC_USERNAME_CLAIM` or something that takes a string with the desired scope. If unset, the default should of course be `email` for the current behavior.

---

Authelia, for instance, offers a lot of claim options:

```json
  "claims_supported": [
    "amr",
    "aud",
    "azp",
    "client_id",
    "exp",
    "iat",
    "iss",
    "jti",
    "rat",
    "sub",
    "auth_time",
    "nonce",
    "email",
    "email_verified",
    "alt_emails",
    "groups",
    "preferred_username",
    "name"
  ],
```
This seems the relevant documentation: https://mozilla-django-oidc.readthedocs.io/en/stable/installation.html#changing-how-django-users-are-created
was actually looking at the same thing,
@sissbruecker would it be alright if i submit a PR for this, i'll try to keep the current behaviour as much as possible to avoid breaking existing installs
Sure you can give it a shot. Looks like a custom auth backend is required in any case to be able to customize this. For backwards compatibility that could just delegate to the default implementation. Then a new env variable could be introduced that, if defined, makes the auth backend use a different claim for the username.
i actually just tested it without a new authBackend, 
just had to do this in  https://github.com/sissbruecker/linkding/blob/master/bookmarks/utils.py#L131

![Image](https://github.com/user-attachments/assets/7ecf946a-ce84-4415-a0c3-9b1640ed70a2)
and 

![Image](https://github.com/user-attachments/assets/619c01ee-a3d5-4b9f-bf04-4c0b9fe629cf)

would it be better to implement a new authBackend ? 
i guess yes, that would also let us link existing users with something other than email (whic is the default of the mozilla_oidc lib)



Patch:
diff --git a/bookmarks/utils.py b/bookmarks/utils.py
index a9ec075b..27db7d42 100644
--- a/bookmarks/utils.py
+++ b/bookmarks/utils.py
@@ -9,6 +9,7 @@
 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 3938ddf3..506f00e0 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 f8220751..a9487a9f 100644
--- a/siteroot/settings/base.py
+++ b/siteroot/settings/base.py
@@ -194,8 +194,10 @@
     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 b3525a5e..df1640b8 100644
--- a/bookmarks/tests/test_oidc_support.py
+++ b/bookmarks/tests/test_oidc_support.py
@@ -4,6 +4,8 @@
 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 @@ def test_default_settings(self):
         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)