Skip to content

Commit

Permalink
Allow customizing username when creating user through OIDC (#971)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
kyuuk and sissbruecker authored Jan 30, 2025
1 parent fc48b26 commit 2973812
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 9 deletions.
86 changes: 81 additions & 5 deletions bookmarks/tests/test_oidc_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"name": "test name",
"given_name": "test given name",
"preferred_username": "NormalizedUser",
"nickname": "test nickname",
"groups": [],
}

username = utils.generate_username(claims["email"], claims)

del os.environ["LD_ENABLE_OIDC"]
self.assertEqual("NormalizedUser", username)
10 changes: 7 additions & 3 deletions bookmarks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
4 changes: 3 additions & 1 deletion docs/src/content/docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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>

Expand Down
2 changes: 2 additions & 0 deletions siteroot/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down

0 comments on commit 2973812

Please sign in to comment.