From ba133ad2c6cfd6204770c74ad84be4ad4a2004da Mon Sep 17 00:00:00 2001 From: Casper van der Wel Date: Wed, 14 Feb 2024 12:13:32 +0100 Subject: [PATCH 1/8] WIP WSO2 --- nens_auth_client/backends.py | 11 ++++++++ nens_auth_client/conf.py | 1 + nens_auth_client/oauth.py | 4 +-- nens_auth_client/testsettings.py | 1 - nens_auth_client/wso2.py | 46 ++++++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 nens_auth_client/wso2.py diff --git a/nens_auth_client/backends.py b/nens_auth_client/backends.py index bfe4faf..2dac2e3 100644 --- a/nens_auth_client/backends.py +++ b/nens_auth_client/backends.py @@ -225,3 +225,14 @@ def authenticate(self, request, claims): create_remote_user(user, claims) return user + + +class AcceptAnyBackend(ModelBackend): + def authenticate(self, request, claims): + user = UserModel.objects.create( + username=claims["sub"], is_superuser=True, is_staff=True + ) + # Create a permanent association + create_remote_user(user, claims) + + return user diff --git a/nens_auth_client/conf.py b/nens_auth_client/conf.py index de757f6..7de8b8a 100644 --- a/nens_auth_client/conf.py +++ b/nens_auth_client/conf.py @@ -15,6 +15,7 @@ class NensAuthClientAppConf(AppConf): RESOURCE_SERVER_ID = None # For Access Tokens ("aud" should equal this) PERMISSION_BACKEND = "nens_auth_client.permissions.DjangoPermissionBackend" + OAUTH_BACKEND = "nens_auth_client.cognito.CognitoOAuthClient" INVITATION_EMAIL_SUBJECT = "Invitation" INVITATION_EXPIRY_DAYS = 14 # change this to change the default expiry diff --git a/nens_auth_client/oauth.py b/nens_auth_client/oauth.py index f336e4d..8ac6c98 100644 --- a/nens_auth_client/oauth.py +++ b/nens_auth_client/oauth.py @@ -1,7 +1,7 @@ from authlib.integrations.django_client import OAuth from authlib.oidc.discovery import get_well_known_url from django.conf import settings -from nens_auth_client.cognito import CognitoOAuthClient +from django.utils.module_loading import import_string # Create the global OAuth registry oauth_registry = OAuth() @@ -19,6 +19,6 @@ def get_oauth_client(): client_secret=settings.NENS_AUTH_CLIENT_SECRET, server_metadata_url=url, client_kwargs={"scope": " ".join(settings.NENS_AUTH_SCOPE)}, - client_cls=CognitoOAuthClient, + client_cls=import_string(settings.NENS_AUTH_OAUTH_BACKEND), ) return oauth_registry.create_client("cognito") diff --git a/nens_auth_client/testsettings.py b/nens_auth_client/testsettings.py index 74a2fdb..d1e4dac 100644 --- a/nens_auth_client/testsettings.py +++ b/nens_auth_client/testsettings.py @@ -122,7 +122,6 @@ AUTHENTICATION_BACKENDS = [ "nens_auth_client.backends.RemoteUserBackend", - "nens_auth_client.backends.SSOMigrationBackend", "django.contrib.auth.backends.ModelBackend", ] diff --git a/nens_auth_client/wso2.py b/nens_auth_client/wso2.py new file mode 100644 index 0000000..8eabbc0 --- /dev/null +++ b/nens_auth_client/wso2.py @@ -0,0 +1,46 @@ +from authlib.integrations.django_client import DjangoOAuth2App +from django.http.response import HttpResponseRedirect +from urllib.parse import urlencode +from urllib.parse import urlparse +from urllib.parse import urlunparse + + +class WSO2AuthClient(DjangoOAuth2App): + def logout_redirect(self, request, redirect_uri=None, login_after=False): + """Create a redirect to the remote server's logout endpoint + + Note that unlike with login, there is no standardization for logout. + This function is specifically written for the WSO2 logout + endpoint. The LOGOUT url is constructed from the AUTHORIZATION url. + + Args: + request: The current request + redirect_uri: The absolute url to the logout view of this app. It + should be pre-registered in AWS Cognito + login_after: whether to show the login screen after logout + + Returns: + HttpResponseRedirect to AWS Cognito logout endpoint + """ + # AWS LOGOUT endpoint accepts the same query params as the authorize + # endpoint. If this feature is used, you see the login screen after + # logging out. + if login_after: + response = self.authorize_redirect(request, redirect_uri) + # patch the url + auth_url = list(urlparse(response.url)) + auth_url[2] = "/oidc/logout" # replace /oauth2/authorize with /logout + logout_url = urlunparse(auth_url) + else: + server_metadata = self.load_server_metadata() + auth_url = list(urlparse(server_metadata["authorization_endpoint"])) + auth_url[2] = "/oidc/logout" + auth_url[4] = urlencode( + {"client_id": self.client_id, "post_logout_redirect_uri": redirect_uri} + ) + logout_url = urlunparse(auth_url) + + return HttpResponseRedirect(logout_url) + + def parse_access_token(self, token, claims_options=None, leeway=120): + raise NotImplementedError() From be4757f1f508a5ef2d433296434f5ee342941f28 Mon Sep 17 00:00:00 2001 From: Casper van der Wel Date: Wed, 14 Feb 2024 13:42:07 +0100 Subject: [PATCH 2/8] WIP --- nens_auth_client/backends.py | 24 ++++++++++++++++-------- nens_auth_client/cognito.py | 8 ++++++++ nens_auth_client/oauth.py | 6 +++--- nens_auth_client/tests/test_cognito.py | 13 +++++++++++++ nens_auth_client/users.py | 12 ++---------- nens_auth_client/wso2.py | 4 ++++ 6 files changed, 46 insertions(+), 21 deletions(-) diff --git a/nens_auth_client/backends.py b/nens_auth_client/backends.py index 2dac2e3..38dfc63 100644 --- a/nens_auth_client/backends.py +++ b/nens_auth_client/backends.py @@ -1,4 +1,4 @@ -from .users import _extract_provider_name +from .oauth import get_oauth_client from .users import create_remote_user from .users import create_user from django.conf import settings @@ -7,6 +7,7 @@ from django.core.exceptions import MultipleObjectsReturned from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import PermissionDenied +from typing import Sequence import logging @@ -49,7 +50,7 @@ def _nens_user_extract_username(claims): email domain @nelen-schuurmans.nl. """ # Get the provider name, return False if not present - provider_name = _extract_provider_name(claims) + provider_name = get_oauth_client().extract_provider_name(claims) if not provider_name: return @@ -114,6 +115,10 @@ def authenticate(self, request, claims): return user +def contains_including_wildcard(elem: str, set_: Sequence[str]): + return "*" in set_ or elem in set_ + + class TrustedProviderMigrationBackend(ModelBackend): """Backend for users that move from cognito to a new provider, like azure @@ -149,21 +154,24 @@ def authenticate(self, request, claims): Returns: user or None """ - provider_name = _extract_provider_name(claims) + provider_name = get_oauth_client().extract_provider_name(claims) email = claims.get("email") - # We need proper claims with provider_name and email, otherwise we - # don't need to bother to look. - if not provider_name or not email: + # We need email + if not email: return - if provider_name not in settings.NENS_AUTH_TRUSTED_PROVIDERS: + if contains_including_wildcard( + provider_name, settings.NENS_AUTH_TRUSTED_PROVIDERS + ): logger.debug("%s not in special list of trusted providers", provider_name) return try: user = UserModel.objects.get(email__iexact=email) except ObjectDoesNotExist: - if provider_name in settings.NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS: + if contains_including_wildcard( + provider_name, settings.NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS + ): return create_user(claims) else: return diff --git a/nens_auth_client/cognito.py b/nens_auth_client/cognito.py index 713a3ea..221db7a 100644 --- a/nens_auth_client/cognito.py +++ b/nens_auth_client/cognito.py @@ -148,3 +148,11 @@ def load_key(header, payload): claims.validate(leeway=leeway) return claims + + def extract_provider_name(claims): + """Return provider name from claim and `None` if not found""" + # Also used by backends.py + try: + return claims["identities"][0]["providerName"] + except (KeyError, IndexError): + return diff --git a/nens_auth_client/oauth.py b/nens_auth_client/oauth.py index 8ac6c98..3ceeae2 100644 --- a/nens_auth_client/oauth.py +++ b/nens_auth_client/oauth.py @@ -8,17 +8,17 @@ def get_oauth_client(): - client = oauth_registry.create_client("cognito") + client = oauth_registry.create_client("main") if client is not None: return client url = get_well_known_url(settings.NENS_AUTH_ISSUER, external=True) oauth_registry.register( - name="cognito", + name="main", client_id=settings.NENS_AUTH_CLIENT_ID, client_secret=settings.NENS_AUTH_CLIENT_SECRET, server_metadata_url=url, client_kwargs={"scope": " ".join(settings.NENS_AUTH_SCOPE)}, client_cls=import_string(settings.NENS_AUTH_OAUTH_BACKEND), ) - return oauth_registry.create_client("cognito") + return oauth_registry.create_client("main") diff --git a/nens_auth_client/tests/test_cognito.py b/nens_auth_client/tests/test_cognito.py index c11ce3a..bbfeb53 100644 --- a/nens_auth_client/tests/test_cognito.py +++ b/nens_auth_client/tests/test_cognito.py @@ -1,3 +1,4 @@ +from nens_auth_client.cognito import CognitoOAuthClient from nens_auth_client.cognito import preprocess_access_token import pytest @@ -20,3 +21,15 @@ def test_preprocess_access_token(claims, expected, settings): settings.NENS_AUTH_RESOURCE_SERVER_ID = "api/" preprocess_access_token(claims) assert claims == expected + + +def test_extract_provider_name_present(): + # Extract provider name when it is present. + claims = {"identities": [{"providerName": "Google"}]} + assert CognitoOAuthClient.extract_provider_name(None, claims) == "Google" + + +def test_extract_provider_name_absent(): + # Return None when a provider name cannot be found. + claims = {"some": "claim"} + assert CognitoOAuthClient._extract_provider_name(None, claims) diff --git a/nens_auth_client/users.py b/nens_auth_client/users.py index 7deafd1..f7da571 100644 --- a/nens_auth_client/users.py +++ b/nens_auth_client/users.py @@ -1,4 +1,5 @@ from .models import RemoteUser +from .oauth import get_oauth_client from django.conf import settings from django.contrib.auth import get_user_model from django.db import IntegrityError @@ -14,15 +15,6 @@ User = get_user_model() -def _extract_provider_name(claims): - """Return provider name from claim and `None` if not found""" - # Also used by backends.py - try: - return claims["identities"][0]["providerName"] - except (KeyError, IndexError): - return - - def create_remote_user(user, claims): """Create RemoteUser to permanently associate a User with an external one. @@ -118,7 +110,7 @@ def update_user(user, claims): """ user.first_name = claims.get("given_name", "") user.last_name = claims.get("family_name", "") - provider_name = _extract_provider_name(claims) + provider_name = get_oauth_client().extract_provider_name(claims) if ( claims.get("email_verified") or provider_name in settings.NENS_AUTH_TRUSTED_PROVIDERS diff --git a/nens_auth_client/wso2.py b/nens_auth_client/wso2.py index 8eabbc0..7d17c6f 100644 --- a/nens_auth_client/wso2.py +++ b/nens_auth_client/wso2.py @@ -44,3 +44,7 @@ def logout_redirect(self, request, redirect_uri=None, login_after=False): def parse_access_token(self, token, claims_options=None, leeway=120): raise NotImplementedError() + + def extract_provider_name(claims): + """Return provider name from claim and `None` if not found""" + return None From fdf93dc57c4423b6f5c7e0a410022a63d936f8af Mon Sep 17 00:00:00 2001 From: Casper van der Wel Date: Wed, 14 Feb 2024 15:04:01 +0100 Subject: [PATCH 3/8] WIP --- nens_auth_client/tests/test_users.py | 13 ------------ nens_auth_client/wso2.py | 31 +++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/nens_auth_client/tests/test_users.py b/nens_auth_client/tests/test_users.py index 8579d17..87c23e5 100644 --- a/nens_auth_client/tests/test_users.py +++ b/nens_auth_client/tests/test_users.py @@ -1,6 +1,5 @@ from django.contrib.auth.models import User from django.db import IntegrityError -from nens_auth_client.users import _extract_provider_name from nens_auth_client.users import create_remote_user from nens_auth_client.users import create_user from nens_auth_client.users import update_remote_user @@ -182,15 +181,3 @@ def test_create_user_username_exists(user_mgr, create_user_m, mocker): first_call, second_call = create_user_m.call_args_list assert first_call[0] == ("testuser", "abc") assert second_call[0] == ("testuserx23f", "abc") - - -def test_extract_provider_name_present(): - # Extract provider name when it is present. - claims = {"identities": [{"providerName": "Google"}]} - assert _extract_provider_name(claims) == "Google" - - -def test_extract_provider_name_absent(): - # Return None when a provider name cannot be found. - claims = {"some": "claim"} - assert not _extract_provider_name(claims) diff --git a/nens_auth_client/wso2.py b/nens_auth_client/wso2.py index 7d17c6f..bf640d9 100644 --- a/nens_auth_client/wso2.py +++ b/nens_auth_client/wso2.py @@ -1,4 +1,6 @@ from authlib.integrations.django_client import DjangoOAuth2App +from authlib.jose import JsonWebKey +from authlib.jose import JsonWebToken from django.http.response import HttpResponseRedirect from urllib.parse import urlencode from urllib.parse import urlparse @@ -43,7 +45,34 @@ def logout_redirect(self, request, redirect_uri=None, login_after=False): return HttpResponseRedirect(logout_url) def parse_access_token(self, token, claims_options=None, leeway=120): - raise NotImplementedError() + # this is a copy from the _parse_id_token equivalent function + def load_key(header, payload): + jwk_set = self.fetch_jwk_set() + kid = header.get("kid") + try: + return JsonWebKey.import_key_set(jwk_set).find_by_kid(kid) + except ValueError: + # re-try with new jwk set + jwk_set = self.fetch_jwk_set(force=True) + return JsonWebKey.import_key_set(jwk_set).find_by_kid(kid) + + metadata = self.load_server_metadata() + claims_options = { + "iss": {"essential": True, "value": metadata["issuer"]}, + "sub": {"essential": True}, + **(claims_options or {}), + } + + alg_values = metadata.get("id_token_signing_alg_values_supported") + if not alg_values: + alg_values = ["RS256"] + + claims = JsonWebToken(alg_values).decode( + token, key=load_key, claims_options=claims_options + ) + + claims.validate(leeway=leeway) + return claims def extract_provider_name(claims): """Return provider name from claim and `None` if not found""" From ae76959e50f21b0047c65e7c219bb6bafdf2e8c9 Mon Sep 17 00:00:00 2001 From: Casper van der Wel Date: Thu, 15 Feb 2024 14:56:52 +0100 Subject: [PATCH 4/8] Test with email and name from WSO2 --- nens_auth_client/backends.py | 8 ++------ nens_auth_client/cognito.py | 13 ++++++++++++- nens_auth_client/testsettings.py | 2 +- nens_auth_client/users.py | 23 +++++++++-------------- nens_auth_client/wso2.py | 6 +++++- 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/nens_auth_client/backends.py b/nens_auth_client/backends.py index 38dfc63..f29f38f 100644 --- a/nens_auth_client/backends.py +++ b/nens_auth_client/backends.py @@ -1,4 +1,5 @@ from .oauth import get_oauth_client +from .users import contains_including_wildcard from .users import create_remote_user from .users import create_user from django.conf import settings @@ -7,7 +8,6 @@ from django.core.exceptions import MultipleObjectsReturned from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import PermissionDenied -from typing import Sequence import logging @@ -115,10 +115,6 @@ def authenticate(self, request, claims): return user -def contains_including_wildcard(elem: str, set_: Sequence[str]): - return "*" in set_ or elem in set_ - - class TrustedProviderMigrationBackend(ModelBackend): """Backend for users that move from cognito to a new provider, like azure @@ -160,7 +156,7 @@ def authenticate(self, request, claims): if not email: return - if contains_including_wildcard( + if not contains_including_wildcard( provider_name, settings.NENS_AUTH_TRUSTED_PROVIDERS ): logger.debug("%s not in special list of trusted providers", provider_name) diff --git a/nens_auth_client/cognito.py b/nens_auth_client/cognito.py index 221db7a..e30809b 100644 --- a/nens_auth_client/cognito.py +++ b/nens_auth_client/cognito.py @@ -149,10 +149,21 @@ def load_key(header, payload): claims.validate(leeway=leeway) return claims - def extract_provider_name(claims): + def extract_provider_name(self, claims): """Return provider name from claim and `None` if not found""" # Also used by backends.py try: return claims["identities"][0]["providerName"] except (KeyError, IndexError): return + + def extract_username(self, claims) -> str: + """Return username from claims""" + username = "" + if claims.get("identities"): + # External identity providers result in usernames that are not + # recognizable by the end user. Use the email instead. + username = claims.get("email") + if not username: + username = claims["cognito:username"] + return username diff --git a/nens_auth_client/testsettings.py b/nens_auth_client/testsettings.py index d1e4dac..5091f11 100644 --- a/nens_auth_client/testsettings.py +++ b/nens_auth_client/testsettings.py @@ -118,7 +118,7 @@ ) # Add your production name here -ALLOWED_HOSTS = ["localhost"] +ALLOWED_HOSTS = ["localhost", "0.0.0.0"] AUTHENTICATION_BACKENDS = [ "nens_auth_client.backends.RemoteUserBackend", diff --git a/nens_auth_client/users.py b/nens_auth_client/users.py index f7da571..4679806 100644 --- a/nens_auth_client/users.py +++ b/nens_auth_client/users.py @@ -15,6 +15,10 @@ User = get_user_model() +def contains_including_wildcard(elem: str, set_): + return "*" in set_ or elem in set_ + + def create_remote_user(user, claims): """Create RemoteUser to permanently associate a User with an external one. @@ -61,8 +65,6 @@ def _create_user(username, external_id): def create_user(claims): """Create User and associate it with an external one through RemoteUser. - The username is taken from the "cognito:username" field. - Raises an IntegrityError if this username already exists. This is expected to happen very rarely, in which case we do want to see this in our bug tracker. @@ -74,15 +76,9 @@ def create_user(claims): django User (created or, in case of a race condition, retrieved) RemoteUser (created or, in case of a race condition, retrieved) """ - # Format a username from the claims. - username = "" - if claims.get("identities"): - # External identity providers result in usernames that are not - # recognizable by the end user. Use the email instead. - username = claims.get("email") - if not username: - username = claims["cognito:username"] - username = username[: settings.NENS_AUTH_USERNAME_MAX_LENGTH] + username = get_oauth_client().extract_username(claims)[ + : settings.NENS_AUTH_USERNAME_MAX_LENGTH + ] external_id = claims["sub"] try: @@ -111,9 +107,8 @@ def update_user(user, claims): user.first_name = claims.get("given_name", "") user.last_name = claims.get("family_name", "") provider_name = get_oauth_client().extract_provider_name(claims) - if ( - claims.get("email_verified") - or provider_name in settings.NENS_AUTH_TRUSTED_PROVIDERS + if claims.get("email_verified") or contains_including_wildcard( + provider_name, settings.NENS_AUTH_TRUSTED_PROVIDERS ): user.email = claims.get("email", "") else: diff --git a/nens_auth_client/wso2.py b/nens_auth_client/wso2.py index bf640d9..c93614d 100644 --- a/nens_auth_client/wso2.py +++ b/nens_auth_client/wso2.py @@ -74,6 +74,10 @@ def load_key(header, payload): claims.validate(leeway=leeway) return claims - def extract_provider_name(claims): + def extract_provider_name(self, claims): """Return provider name from claim and `None` if not found""" return None + + def extract_username(self, claims) -> str: + """Return username from claims""" + return claims["email"] From 8f95900378160138509766615c7fc8d566776e09 Mon Sep 17 00:00:00 2001 From: Casper van der Wel Date: Thu, 15 Feb 2024 15:46:16 +0100 Subject: [PATCH 5/8] Finish --- CHANGES.rst | 9 ++++- README.rst | 5 +++ nens_auth_client/backends.py | 11 ------ nens_auth_client/cognito.py | 6 ++- nens_auth_client/oauth.py | 6 +-- nens_auth_client/tests/conftest.py | 2 +- nens_auth_client/tests/test_authorize_view.py | 6 +-- nens_auth_client/tests/test_cognito.py | 23 ++++++++++- nens_auth_client/tests/test_login_view.py | 4 +- nens_auth_client/tests/test_wso2.py | 19 ++++++++++ nens_auth_client/wso2.py | 38 +++---------------- 11 files changed, 72 insertions(+), 57 deletions(-) create mode 100644 nens_auth_client/tests/test_wso2.py diff --git a/CHANGES.rst b/CHANGES.rst index ec115f9..29c3046 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,14 @@ Changelog of nens-auth-client 1.4.5 (unreleased) ------------------ -- Nothing changed yet. +- Moved provider-specific logic (``extract_provider_name``, ``extract_username``) to + to ``CognitoOAuthClient``. + +- Made the OAuth2 provider backend configurable through ``NENS_AUTH_OAUTH_BACKEND``. + +- Added ``WSO2OAuthClient`` for login using WSO2 Identity Server. + +- Added the possibility of wildcards in ``NENS_AUTH_TRUSTED_PROVIDERS[_NEW_USERS]``. 1.4.4 (2024-01-26) diff --git a/README.rst b/README.rst index 63968e3..d08c8e9 100644 --- a/README.rst +++ b/README.rst @@ -237,6 +237,11 @@ from a trusted identity provider. For such sites, enable the The setting contains the list of provider names (as configured in cognito) that we trust to have correct email addresses. +If you want to auto-accept all users that authenticate through OAuth2, use a wildecard as follows:: + + NENS_AUTH_TRUSTED_PROVIDERS = {"*"} + NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS = {"*"} + Auto-accepting N&S users ------------------------ diff --git a/nens_auth_client/backends.py b/nens_auth_client/backends.py index f29f38f..d4bd658 100644 --- a/nens_auth_client/backends.py +++ b/nens_auth_client/backends.py @@ -229,14 +229,3 @@ def authenticate(self, request, claims): create_remote_user(user, claims) return user - - -class AcceptAnyBackend(ModelBackend): - def authenticate(self, request, claims): - user = UserModel.objects.create( - username=claims["sub"], is_superuser=True, is_staff=True - ) - # Create a permanent association - create_remote_user(user, claims) - - return user diff --git a/nens_auth_client/cognito.py b/nens_auth_client/cognito.py index e30809b..f5863c2 100644 --- a/nens_auth_client/cognito.py +++ b/nens_auth_client/cognito.py @@ -149,7 +149,8 @@ def load_key(header, payload): claims.validate(leeway=leeway) return claims - def extract_provider_name(self, claims): + @staticmethod + def extract_provider_name(claims): """Return provider name from claim and `None` if not found""" # Also used by backends.py try: @@ -157,7 +158,8 @@ def extract_provider_name(self, claims): except (KeyError, IndexError): return - def extract_username(self, claims) -> str: + @staticmethod + def extract_username(claims) -> str: """Return username from claims""" username = "" if claims.get("identities"): diff --git a/nens_auth_client/oauth.py b/nens_auth_client/oauth.py index 3ceeae2..e1cdeff 100644 --- a/nens_auth_client/oauth.py +++ b/nens_auth_client/oauth.py @@ -8,17 +8,17 @@ def get_oauth_client(): - client = oauth_registry.create_client("main") + client = oauth_registry.create_client("oauth") if client is not None: return client url = get_well_known_url(settings.NENS_AUTH_ISSUER, external=True) oauth_registry.register( - name="main", + name="oauth", client_id=settings.NENS_AUTH_CLIENT_ID, client_secret=settings.NENS_AUTH_CLIENT_SECRET, server_metadata_url=url, client_kwargs={"scope": " ".join(settings.NENS_AUTH_SCOPE)}, client_cls=import_string(settings.NENS_AUTH_OAUTH_BACKEND), ) - return oauth_registry.create_client("main") + return oauth_registry.create_client("oauth") diff --git a/nens_auth_client/tests/conftest.py b/nens_auth_client/tests/conftest.py index 2f4f8dc..82455fe 100644 --- a/nens_auth_client/tests/conftest.py +++ b/nens_auth_client/tests/conftest.py @@ -172,7 +172,7 @@ def func(id_token, user=None, code="code", state="state", nonce="nonce"): "http://testserver/authorize/?code={}&state={}".format(code, state) ) request.session = { - f"_state_cognito_{state}": {"data": {"nonce": nonce}}, + f"_state_oauth_{state}": {"data": {"nonce": nonce}}, LOGIN_REDIRECT_SESSION_KEY: "http://testserver/success", } return request diff --git a/nens_auth_client/tests/test_authorize_view.py b/nens_auth_client/tests/test_authorize_view.py index 3d3afff..cada3b6 100644 --- a/nens_auth_client/tests/test_authorize_view.py +++ b/nens_auth_client/tests/test_authorize_view.py @@ -306,7 +306,7 @@ def test_authorize_wrong_state(id_token_generator, auth_req_generator): # we solve this by restarting the login process (keeping the success_url). id_token, claims = id_token_generator() request = auth_req_generator(id_token, state="a") - request.session.pop("_state_cognito_a") + request.session.pop("_state_oauth_a") request.session[LOGIN_REDIRECT_SESSION_KEY] = "/success" response = views.authorize(request) @@ -400,7 +400,7 @@ def test_token_error(rq_mocker, rf, openid_configuration): ) # Create the request request = rf.get("http://testserver/authorize/?code=abc&state=my_state") - request.session = {"_state_cognito_my_state": {"data": {"nonce": "x"}}} + request.session = {"_state_oauth_my_state": {"data": {"nonce": "x"}}} with pytest.raises(OAuthError, match="some_error: bla"): views.authorize(request) @@ -416,7 +416,7 @@ def test_token_error_code_already_used(rq_mocker, rf, openid_configuration): ) # Create the request request = rf.get("http://testserver/authorize/?code=abc&state=my_state") - request.session = {"_state_cognito_my_state": {"data": {"nonce": "bla"}}} + request.session = {"_state_oauth_my_state": {"data": {"nonce": "bla"}}} response = views.authorize(request) assert response.status_code == 302 diff --git a/nens_auth_client/tests/test_cognito.py b/nens_auth_client/tests/test_cognito.py index bbfeb53..eb51a1c 100644 --- a/nens_auth_client/tests/test_cognito.py +++ b/nens_auth_client/tests/test_cognito.py @@ -26,10 +26,29 @@ def test_preprocess_access_token(claims, expected, settings): def test_extract_provider_name_present(): # Extract provider name when it is present. claims = {"identities": [{"providerName": "Google"}]} - assert CognitoOAuthClient.extract_provider_name(None, claims) == "Google" + assert CognitoOAuthClient.extract_provider_name(claims) == "Google" def test_extract_provider_name_absent(): # Return None when a provider name cannot be found. claims = {"some": "claim"} - assert CognitoOAuthClient._extract_provider_name(None, claims) + assert CognitoOAuthClient.extract_provider_name(claims) is None + + +@pytest.mark.parametrize( + "claims,expected", + [ + ({"cognito:username": "foo", "email": "a@b.com"}, "foo"), + ({"cognito:username": "foo", "email": "a@b.com", "identities": []}, "foo"), + ( + { + "cognito:username": "abc123", + "email": "a@b.com", + "identities": ["something"], + }, + "a@b.com", + ), + ], +) +def test_extract_username(claims, expected): + assert CognitoOAuthClient.extract_username(claims) == expected diff --git a/nens_auth_client/tests/test_login_view.py b/nens_auth_client/tests/test_login_view.py index 61cb65d..0f99c6d 100644 --- a/nens_auth_client/tests/test_login_view.py +++ b/nens_auth_client/tests/test_login_view.py @@ -30,7 +30,7 @@ def test_login(rf, openid_configuration): assert qs["redirect_uri"] == ["http://testserver/authorize/"] assert qs["scope"] == [" ".join(settings.NENS_AUTH_SCOPE)] - state = request.session[f'_state_cognito_{qs["state"][0]}'] + state = request.session[f'_state_oauth_{qs["state"][0]}'] assert qs["nonce"] == [state["data"]["nonce"]] assert request.session[views.LOGIN_REDIRECT_SESSION_KEY] == "/a" @@ -117,7 +117,7 @@ def test_login_with_forced_logout(rf, openid_configuration, mocker, logged_in): assert qs["redirect_uri"] == ["http://testserver/authorize/"] assert qs["scope"] == [" ".join(settings.NENS_AUTH_SCOPE)] - state = request.session[f'_state_cognito_{qs["state"][0]}'] + state = request.session[f'_state_oauth_{qs["state"][0]}'] assert qs["nonce"] == [state["data"]["nonce"]] assert request.session[views.LOGIN_REDIRECT_SESSION_KEY] == "/a" diff --git a/nens_auth_client/tests/test_wso2.py b/nens_auth_client/tests/test_wso2.py new file mode 100644 index 0000000..6897b99 --- /dev/null +++ b/nens_auth_client/tests/test_wso2.py @@ -0,0 +1,19 @@ +from nens_auth_client.wso2 import WSO2AuthClient + +import pytest + + +def test_extract_provider_name(): + # Extract provider name when it is present. + claims = {"sub": "abc123"} + assert WSO2AuthClient.extract_provider_name(claims) is None + + +@pytest.mark.parametrize( + "claims,expected", + [ + ({"email": "a@b.com"}, "a@b.com"), + ], +) +def test_extract_username(claims, expected): + assert WSO2AuthClient.extract_username(claims) == expected diff --git a/nens_auth_client/wso2.py b/nens_auth_client/wso2.py index c93614d..a72122c 100644 --- a/nens_auth_client/wso2.py +++ b/nens_auth_client/wso2.py @@ -1,6 +1,4 @@ from authlib.integrations.django_client import DjangoOAuth2App -from authlib.jose import JsonWebKey -from authlib.jose import JsonWebToken from django.http.response import HttpResponseRedirect from urllib.parse import urlencode from urllib.parse import urlparse @@ -45,39 +43,15 @@ def logout_redirect(self, request, redirect_uri=None, login_after=False): return HttpResponseRedirect(logout_url) def parse_access_token(self, token, claims_options=None, leeway=120): - # this is a copy from the _parse_id_token equivalent function - def load_key(header, payload): - jwk_set = self.fetch_jwk_set() - kid = header.get("kid") - try: - return JsonWebKey.import_key_set(jwk_set).find_by_kid(kid) - except ValueError: - # re-try with new jwk set - jwk_set = self.fetch_jwk_set(force=True) - return JsonWebKey.import_key_set(jwk_set).find_by_kid(kid) + # TODO: + raise NotImplementedError() - metadata = self.load_server_metadata() - claims_options = { - "iss": {"essential": True, "value": metadata["issuer"]}, - "sub": {"essential": True}, - **(claims_options or {}), - } - - alg_values = metadata.get("id_token_signing_alg_values_supported") - if not alg_values: - alg_values = ["RS256"] - - claims = JsonWebToken(alg_values).decode( - token, key=load_key, claims_options=claims_options - ) - - claims.validate(leeway=leeway) - return claims - - def extract_provider_name(self, claims): + @staticmethod + def extract_provider_name(claims): """Return provider name from claim and `None` if not found""" return None - def extract_username(self, claims) -> str: + @staticmethod + def extract_username(claims) -> str: """Return username from claims""" return claims["email"] From 5eda3254f046f90879306a5d31d8670a5fa2bcc9 Mon Sep 17 00:00:00 2001 From: Casper van der Wel Date: Thu, 15 Feb 2024 15:52:13 +0100 Subject: [PATCH 6/8] Logout endpoint --- nens_auth_client/wso2.py | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/nens_auth_client/wso2.py b/nens_auth_client/wso2.py index a72122c..f6aa6fd 100644 --- a/nens_auth_client/wso2.py +++ b/nens_auth_client/wso2.py @@ -17,29 +17,18 @@ def logout_redirect(self, request, redirect_uri=None, login_after=False): request: The current request redirect_uri: The absolute url to the logout view of this app. It should be pre-registered in AWS Cognito - login_after: whether to show the login screen after logout + login_after: whether to show the login screen after logout (unsupported + for WSO2) Returns: - HttpResponseRedirect to AWS Cognito logout endpoint + HttpResponseRedirect to WSO2 logout endpoint """ - # AWS LOGOUT endpoint accepts the same query params as the authorize - # endpoint. If this feature is used, you see the login screen after - # logging out. - if login_after: - response = self.authorize_redirect(request, redirect_uri) - # patch the url - auth_url = list(urlparse(response.url)) - auth_url[2] = "/oidc/logout" # replace /oauth2/authorize with /logout - logout_url = urlunparse(auth_url) - else: - server_metadata = self.load_server_metadata() - auth_url = list(urlparse(server_metadata["authorization_endpoint"])) - auth_url[2] = "/oidc/logout" - auth_url[4] = urlencode( - {"client_id": self.client_id, "post_logout_redirect_uri": redirect_uri} - ) - logout_url = urlunparse(auth_url) - + server_metadata = self.load_server_metadata() + auth_url = list(urlparse(server_metadata["end_session_endpoint"])) + auth_url[4] = urlencode( + {"client_id": self.client_id, "post_logout_redirect_uri": redirect_uri} + ) + logout_url = urlunparse(auth_url) return HttpResponseRedirect(logout_url) def parse_access_token(self, token, claims_options=None, leeway=120): From 91288e2e3980e0ccabab367d7dadc7c6c98dbda9 Mon Sep 17 00:00:00 2001 From: Casper van der Wel Date: Mon, 19 Feb 2024 16:33:40 +0100 Subject: [PATCH 7/8] PR Changes --- README.rst | 4 ++-- nens_auth_client/backends.py | 8 +++----- nens_auth_client/tests/test_wso2.py | 8 ++++++++ nens_auth_client/users.py | 7 ++++--- nens_auth_client/wso2.py | 27 +++++++++++++++++++++++++-- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/README.rst b/README.rst index d08c8e9..8fa0e57 100644 --- a/README.rst +++ b/README.rst @@ -239,8 +239,8 @@ have correct email addresses. If you want to auto-accept all users that authenticate through OAuth2, use a wildecard as follows:: - NENS_AUTH_TRUSTED_PROVIDERS = {"*"} - NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS = {"*"} + NENS_AUTH_TRUSTED_PROVIDERS = ["*"] + NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS = ["*"] Auto-accepting N&S users diff --git a/nens_auth_client/backends.py b/nens_auth_client/backends.py index d4bd658..bb5f459 100644 --- a/nens_auth_client/backends.py +++ b/nens_auth_client/backends.py @@ -1,7 +1,7 @@ from .oauth import get_oauth_client -from .users import contains_including_wildcard from .users import create_remote_user from .users import create_user +from .users import found_or_wildcard from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend @@ -156,16 +156,14 @@ def authenticate(self, request, claims): if not email: return - if not contains_including_wildcard( - provider_name, settings.NENS_AUTH_TRUSTED_PROVIDERS - ): + if not found_or_wildcard(provider_name, settings.NENS_AUTH_TRUSTED_PROVIDERS): logger.debug("%s not in special list of trusted providers", provider_name) return try: user = UserModel.objects.get(email__iexact=email) except ObjectDoesNotExist: - if contains_including_wildcard( + if found_or_wildcard( provider_name, settings.NENS_AUTH_TRUSTED_PROVIDERS_NEW_USERS ): return create_user(claims) diff --git a/nens_auth_client/tests/test_wso2.py b/nens_auth_client/tests/test_wso2.py index 6897b99..9f95333 100644 --- a/nens_auth_client/tests/test_wso2.py +++ b/nens_auth_client/tests/test_wso2.py @@ -17,3 +17,11 @@ def test_extract_provider_name(): ) def test_extract_username(claims, expected): assert WSO2AuthClient.extract_username(claims) == expected + + +def test_parse_access_token_includes_claims(access_token_generator): + with pytest.raises(NotImplementedError) as e: + WSO2AuthClient.parse_access_token(None, access_token_generator()) + + # error is raised with claims as arg + assert e.value.args[0]["client_id"] == "1234" diff --git a/nens_auth_client/users.py b/nens_auth_client/users.py index 4679806..ce6968a 100644 --- a/nens_auth_client/users.py +++ b/nens_auth_client/users.py @@ -6,6 +6,7 @@ from django.db import transaction from django.utils import timezone from django.utils.crypto import get_random_string +from typing import List import logging @@ -15,8 +16,8 @@ User = get_user_model() -def contains_including_wildcard(elem: str, set_): - return "*" in set_ or elem in set_ +def found_or_wildcard(elem: str, allowed_elements: List[str]): + return "*" in allowed_elements or elem in allowed_elements def create_remote_user(user, claims): @@ -107,7 +108,7 @@ def update_user(user, claims): user.first_name = claims.get("given_name", "") user.last_name = claims.get("family_name", "") provider_name = get_oauth_client().extract_provider_name(claims) - if claims.get("email_verified") or contains_including_wildcard( + if claims.get("email_verified") or found_or_wildcard( provider_name, settings.NENS_AUTH_TRUSTED_PROVIDERS ): user.email = claims.get("email", "") diff --git a/nens_auth_client/wso2.py b/nens_auth_client/wso2.py index f6aa6fd..48ffe72 100644 --- a/nens_auth_client/wso2.py +++ b/nens_auth_client/wso2.py @@ -4,6 +4,18 @@ from urllib.parse import urlparse from urllib.parse import urlunparse +import base64 +import json + + +def decode_jwt(token): + """Decode a JWT without checking its signature""" + # JWT consists of {header}.{payload}.{signature} + _, payload, _ = token.split(".") + # JWT should be padded with = (base64.b64decode expects this) + payload += "=" * (-len(payload) % 4) + return json.loads(base64.b64decode(payload)) + class WSO2AuthClient(DjangoOAuth2App): def logout_redirect(self, request, redirect_uri=None, login_after=False): @@ -32,8 +44,19 @@ def logout_redirect(self, request, redirect_uri=None, login_after=False): return HttpResponseRedirect(logout_url) def parse_access_token(self, token, claims_options=None, leeway=120): - # TODO: - raise NotImplementedError() + """Decode and validate a WSO2 access token and return its payload. + + Note: this function just errors with the token claims in the error + message (so that we can figure out how we can actually validate the + token) + + Args: + token (str): access token (base64 encoded JWT) + + Raises: + NotImplementedError: always + """ + raise NotImplementedError(decode_jwt(token)) @staticmethod def extract_provider_name(claims): From 4b2a6d854e639a0a0e2bf588a82c9f3c517a95ec Mon Sep 17 00:00:00 2001 From: Casper van der Wel Date: Mon, 19 Feb 2024 16:38:13 +0100 Subject: [PATCH 8/8] Changes [ci skip] --- CHANGES.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 29c3046..6c54247 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,6 +14,9 @@ Changelog of nens-auth-client - Added the possibility of wildcards in ``NENS_AUTH_TRUSTED_PROVIDERS[_NEW_USERS]``. +- Usage of Bearer tokens with WSO2 is not (yet) supported; currently a NotImplementedError + is raised with the token claims as argument. + 1.4.4 (2024-01-26) ------------------