providers/oauth2: OpenID conformance (#4758)
* don't open inspector by default when debug is enabled Signed-off-by: Jens Langhammer <jens@goauthentik.io> * encode error in fragment when using hybrid grant_type Signed-off-by: Jens Langhammer <jens@goauthentik.io> * require nonce for all response_types that get an id_token from the authorization endpoint Signed-off-by: Jens Langhammer <jens@goauthentik.io> * don't set empty family_name Signed-off-by: Jens Langhammer <jens@goauthentik.io> * only set at_hash when response has token Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cleaner way to get login time Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove authentication requirement from authentication flow Signed-off-by: Jens Langhammer <jens@goauthentik.io> * use wrapper Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix auth_time not being handled correctly Signed-off-by: Jens Langhammer <jens@goauthentik.io> * minor cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add test files Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove USER_LOGIN_AUTHENTICATED Signed-off-by: Jens Langhammer <jens@goauthentik.io> * rework prompt=login handling Signed-off-by: Jens Langhammer <jens@goauthentik.io> * also set last login uid for max_age check to prevent double login when max_age and prompt=login is set Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
		| @ -4,6 +4,7 @@ from base64 import b64encode | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.test import TestCase | ||||
| from django.utils import timezone | ||||
| from rest_framework.exceptions import AuthenticationFailed | ||||
|  | ||||
| from authentik.api.authentication import bearer_auth | ||||
| @ -68,6 +69,7 @@ class TestAPIAuth(TestCase): | ||||
|             user=create_test_admin_user(), | ||||
|             provider=provider, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope=SCOPE_AUTHENTIK_API, | ||||
|             _id_token=json.dumps({}), | ||||
|         ) | ||||
| @ -82,6 +84,7 @@ class TestAPIAuth(TestCase): | ||||
|             user=create_test_admin_user(), | ||||
|             provider=provider, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope="", | ||||
|             _id_token=json.dumps({}), | ||||
|         ) | ||||
|  | ||||
| @ -37,7 +37,6 @@ from authentik.lib.utils.file import ( | ||||
| from authentik.policies.api.exec import PolicyTestResultSerializer | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.types import PolicyResult | ||||
| from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| @ -186,10 +185,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet): | ||||
|         if superuser_full_list and request.user.is_superuser: | ||||
|             return super().list(request) | ||||
|  | ||||
|         # To prevent the user from having to double login when prompt is set to login | ||||
|         # and the user has just signed it. This session variable is set in the UserLoginStage | ||||
|         # and is (quite hackily) removed from the session in applications's API's List method | ||||
|         self.request.session.pop(USER_LOGIN_AUTHENTICATED, None) | ||||
|         queryset = self._filter_queryset_for_list(self.get_queryset()) | ||||
|         self.paginate_queryset(queryset) | ||||
|  | ||||
|  | ||||
| @ -231,7 +231,7 @@ class AccessDeniedChallengeView(ChallengeStageView): | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         return AccessDeniedChallenge( | ||||
|             data={ | ||||
|                 "error_message": self.error_message or "Unknown error", | ||||
|                 "error_message": str(self.error_message or "Unknown error"), | ||||
|                 "type": ChallengeTypes.NATIVE.value, | ||||
|                 "component": "ak-stage-access-denied", | ||||
|             } | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| """OAuth2Provider API Views""" | ||||
| from django.urls import reverse | ||||
| from django.utils import timezone | ||||
| from drf_spectacular.utils import OpenApiResponse, extend_schema | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.fields import CharField | ||||
| @ -153,6 +154,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet): | ||||
|                 user=request.user, | ||||
|                 provider=provider, | ||||
|                 _scope=" ".join(scope_names), | ||||
|                 auth_time=timezone.now(), | ||||
|             ), | ||||
|             request, | ||||
|         ) | ||||
|  | ||||
| @ -174,10 +174,12 @@ class AuthorizeError(OAuth2Error): | ||||
|  | ||||
|         # See: | ||||
|         # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError | ||||
|         hash_or_question = "#" if self.grant_type == GrantTypes.IMPLICIT else "?" | ||||
|         fragment_or_query = ( | ||||
|             "#" if self.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID] else "?" | ||||
|         ) | ||||
|  | ||||
|         uri = ( | ||||
|             f"{self.redirect_uri}{hash_or_question}error=" | ||||
|             f"{self.redirect_uri}{fragment_or_query}error=" | ||||
|             f"{self.error}&error_description={description}" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @ -110,12 +110,11 @@ class IDToken: | ||||
|         # Convert datetimes into timestamps. | ||||
|         now = timezone.now() | ||||
|         id_token.iat = int(now.timestamp()) | ||||
|         id_token.auth_time = int(token.auth_time.timestamp()) | ||||
|  | ||||
|         # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time | ||||
|         auth_event = get_login_event(request) | ||||
|         if auth_event: | ||||
|             auth_time = auth_event.created | ||||
|             id_token.auth_time = int(auth_time.timestamp()) | ||||
|             # Also check which method was used for authentication | ||||
|             method = auth_event.context.get(PLAN_CONTEXT_METHOD, "") | ||||
|             method_args = auth_event.context.get(PLAN_CONTEXT_METHOD_ARGS, {}) | ||||
|  | ||||
| @ -0,0 +1,40 @@ | ||||
| # Generated by Django 4.1.7 on 2023-02-22 22:23 | ||||
|  | ||||
| import django.utils.timezone | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("authentik_providers_oauth2", "0014_alter_refreshtoken_options_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="accesstoken", | ||||
|             name="auth_time", | ||||
|             field=models.DateTimeField( | ||||
|                 default=django.utils.timezone.now, | ||||
|                 verbose_name="Authentication time", | ||||
|             ), | ||||
|             preserve_default=False, | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="authorizationcode", | ||||
|             name="auth_time", | ||||
|             field=models.DateTimeField( | ||||
|                 default=django.utils.timezone.now, | ||||
|                 verbose_name="Authentication time", | ||||
|             ), | ||||
|             preserve_default=False, | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="refreshtoken", | ||||
|             name="auth_time", | ||||
|             field=models.DateTimeField( | ||||
|                 default=django.utils.timezone.now, | ||||
|                 verbose_name="Authentication time", | ||||
|             ), | ||||
|             preserve_default=False, | ||||
|         ), | ||||
|     ] | ||||
| @ -226,7 +226,7 @@ class OAuth2Provider(Provider): | ||||
|     def get_issuer(self, request: HttpRequest) -> Optional[str]: | ||||
|         """Get issuer, based on request""" | ||||
|         if self.issuer_mode == IssuerMode.GLOBAL: | ||||
|             return request.build_absolute_uri("/") | ||||
|             return request.build_absolute_uri(reverse("authentik_core:root-redirect")) | ||||
|         try: | ||||
|             url = reverse( | ||||
|                 "authentik_providers_oauth2:provider-root", | ||||
| @ -282,6 +282,7 @@ class BaseGrantModel(models.Model): | ||||
|     user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) | ||||
|     revoked = models.BooleanField(default=False) | ||||
|     _scope = models.TextField(default="", verbose_name=_("Scopes")) | ||||
|     auth_time = models.DateTimeField(verbose_name="Authentication time") | ||||
|  | ||||
|     @property | ||||
|     def scope(self) -> list[str]: | ||||
|  | ||||
| @ -204,6 +204,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|                 "redirect_uri": "http://local.invalid/Foo", | ||||
|                 "scope": "openid", | ||||
|                 "state": "foo", | ||||
|                 "nonce": generate_id(), | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual( | ||||
| @ -325,6 +326,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|                     "state": state, | ||||
|                     "scope": "openid", | ||||
|                     "redirect_uri": "http://localhost", | ||||
|                     "nonce": generate_id(), | ||||
|                 }, | ||||
|             ) | ||||
|             response = self.client.get( | ||||
| @ -378,6 +380,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|                 "state": state, | ||||
|                 "scope": "openid", | ||||
|                 "redirect_uri": "http://localhost", | ||||
|                 "nonce": generate_id(), | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|  | ||||
| @ -4,6 +4,7 @@ from base64 import b64encode | ||||
| from dataclasses import asdict | ||||
|  | ||||
| from django.urls import reverse | ||||
| from django.utils import timezone | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||
| @ -41,6 +42,7 @@ class TesOAuth2Introspection(OAuthTestCase): | ||||
|             provider=self.provider, | ||||
|             user=self.user, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope="openid user profile", | ||||
|             _id_token=json.dumps( | ||||
|                 asdict( | ||||
| @ -72,6 +74,7 @@ class TesOAuth2Introspection(OAuthTestCase): | ||||
|             provider=self.provider, | ||||
|             user=self.user, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope="openid user profile", | ||||
|             _id_token=json.dumps( | ||||
|                 asdict( | ||||
|  | ||||
| @ -4,6 +4,7 @@ from base64 import b64encode | ||||
| from dataclasses import asdict | ||||
|  | ||||
| from django.urls import reverse | ||||
| from django.utils import timezone | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow | ||||
| @ -40,6 +41,7 @@ class TesOAuth2Revoke(OAuthTestCase): | ||||
|             provider=self.provider, | ||||
|             user=self.user, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope="openid user profile", | ||||
|             _id_token=json.dumps( | ||||
|                 asdict( | ||||
| @ -62,6 +64,7 @@ class TesOAuth2Revoke(OAuthTestCase): | ||||
|             provider=self.provider, | ||||
|             user=self.user, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope="openid user profile", | ||||
|             _id_token=json.dumps( | ||||
|                 asdict( | ||||
|  | ||||
| @ -4,6 +4,7 @@ from json import dumps | ||||
|  | ||||
| from django.test import RequestFactory | ||||
| from django.urls import reverse | ||||
| from django.utils import timezone | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||
| @ -45,7 +46,9 @@ class TestToken(OAuthTestCase): | ||||
|         ) | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         user = create_test_admin_user() | ||||
|         code = AuthorizationCode.objects.create(code="foobar", provider=provider, user=user) | ||||
|         code = AuthorizationCode.objects.create( | ||||
|             code="foobar", provider=provider, user=user, auth_time=timezone.now() | ||||
|         ) | ||||
|         request = self.factory.post( | ||||
|             "/", | ||||
|             data={ | ||||
| @ -99,6 +102,7 @@ class TestToken(OAuthTestCase): | ||||
|             provider=provider, | ||||
|             user=user, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|         ) | ||||
|         request = self.factory.post( | ||||
|             "/", | ||||
| @ -127,7 +131,9 @@ class TestToken(OAuthTestCase): | ||||
|         self.app.save() | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         user = create_test_admin_user() | ||||
|         code = AuthorizationCode.objects.create(code="foobar", provider=provider, user=user) | ||||
|         code = AuthorizationCode.objects.create( | ||||
|             code="foobar", provider=provider, user=user, auth_time=timezone.now() | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
|             data={ | ||||
| @ -173,6 +179,7 @@ class TestToken(OAuthTestCase): | ||||
|             user=user, | ||||
|             token=generate_id(), | ||||
|             _id_token=dumps({}), | ||||
|             auth_time=timezone.now(), | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -221,6 +228,7 @@ class TestToken(OAuthTestCase): | ||||
|             user=user, | ||||
|             token=generate_id(), | ||||
|             _id_token=dumps({}), | ||||
|             auth_time=timezone.now(), | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -271,6 +279,7 @@ class TestToken(OAuthTestCase): | ||||
|             user=user, | ||||
|             token=generate_id(), | ||||
|             _id_token=dumps({}), | ||||
|             auth_time=timezone.now(), | ||||
|         ) | ||||
|         # Create initial refresh token | ||||
|         response = self.client.post( | ||||
|  | ||||
| @ -3,6 +3,7 @@ import json | ||||
| from dataclasses import asdict | ||||
|  | ||||
| from django.urls import reverse | ||||
| from django.utils import timezone | ||||
|  | ||||
| from authentik.blueprints.tests import apply_blueprint | ||||
| from authentik.core.models import Application | ||||
| @ -37,6 +38,7 @@ class TestUserinfo(OAuthTestCase): | ||||
|             provider=self.provider, | ||||
|             user=self.user, | ||||
|             token=generate_id(), | ||||
|             auth_time=timezone.now(), | ||||
|             _scope="openid user profile", | ||||
|             _id_token=json.dumps( | ||||
|                 asdict( | ||||
| @ -56,7 +58,6 @@ class TestUserinfo(OAuthTestCase): | ||||
|             { | ||||
|                 "name": self.user.name, | ||||
|                 "given_name": self.user.name, | ||||
|                 "family_name": "", | ||||
|                 "preferred_username": self.user.name, | ||||
|                 "nickname": self.user.name, | ||||
|                 "groups": [group.name for group in self.user.ak_groups.all()], | ||||
| @ -79,7 +80,6 @@ class TestUserinfo(OAuthTestCase): | ||||
|             { | ||||
|                 "name": self.user.name, | ||||
|                 "given_name": self.user.name, | ||||
|                 "family_name": "", | ||||
|                 "preferred_username": self.user.name, | ||||
|                 "nickname": self.user.name, | ||||
|                 "groups": [group.name for group in self.user.ak_groups.all()], | ||||
|  | ||||
| @ -17,7 +17,7 @@ from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import Application | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.events.utils import get_user | ||||
| from authentik.events.signals import get_login_event | ||||
| from authentik.flows.challenge import ( | ||||
|     PLAN_CONTEXT_TITLE, | ||||
|     AutosubmitChallenge, | ||||
| @ -64,12 +64,11 @@ from authentik.stages.consent.stage import ( | ||||
|     PLAN_CONTEXT_CONSENT_PERMISSIONS, | ||||
|     ConsentStageView, | ||||
| ) | ||||
| from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| PLAN_CONTEXT_PARAMS = "params" | ||||
| SESSION_KEY_NEEDS_LOGIN = "authentik/providers/oauth2/needs_login" | ||||
| SESSION_KEY_LAST_LOGIN_UID = "authentik/providers/oauth2/last_login_uid" | ||||
|  | ||||
| ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN} | ||||
|  | ||||
| @ -235,19 +234,22 @@ class OAuthAuthorizationParams: | ||||
|  | ||||
|     def check_nonce(self): | ||||
|         """Nonce parameter validation.""" | ||||
|         # https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDTValidation | ||||
|         # Nonce is only required for Implicit flows | ||||
|         if self.grant_type != GrantTypes.IMPLICIT: | ||||
|         # nonce is required for all flows that return an id_token from the authorization endpoint, | ||||
|         # see https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest or | ||||
|         # https://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken and | ||||
|         # https://bitbucket.org/openid/connect/issues/972/nonce-requirement-in-hybrid-auth-request | ||||
|         if self.response_type not in [ | ||||
|             ResponseTypes.ID_TOKEN, | ||||
|             ResponseTypes.ID_TOKEN_TOKEN, | ||||
|             ResponseTypes.CODE_ID_TOKEN, | ||||
|             ResponseTypes.CODE_ID_TOKEN_TOKEN, | ||||
|         ]: | ||||
|             return | ||||
|         if SCOPE_OPENID not in self.scope: | ||||
|             return | ||||
|         if not self.nonce: | ||||
|             self.nonce = self.state | ||||
|             LOGGER.warning("Using state as nonce for OpenID Request") | ||||
|         if not self.nonce: | ||||
|             if SCOPE_OPENID in self.scope: | ||||
|             LOGGER.warning("Missing nonce for OpenID Request") | ||||
|                 raise AuthorizeError( | ||||
|                     self.redirect_uri, "invalid_request", self.grant_type, self.state | ||||
|                 ) | ||||
|             raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state) | ||||
|  | ||||
|     def check_code_challenge(self): | ||||
|         """PKCE validation of the transformation method.""" | ||||
| @ -262,19 +264,24 @@ class OAuthAuthorizationParams: | ||||
|  | ||||
|     def create_code(self, request: HttpRequest) -> AuthorizationCode: | ||||
|         """Create an AuthorizationCode object for the request""" | ||||
|         code = AuthorizationCode() | ||||
|         code.user = request.user | ||||
|         code.provider = self.provider | ||||
|         auth_event = get_login_event(request) | ||||
|  | ||||
|         code.code = uuid4().hex | ||||
|         now = timezone.now() | ||||
|  | ||||
|         code = AuthorizationCode( | ||||
|             user=request.user, | ||||
|             provider=self.provider, | ||||
|             auth_time=auth_event.created if auth_event else now, | ||||
|             code=uuid4().hex, | ||||
|             expires=now + timedelta_from_string(self.provider.access_code_validity), | ||||
|             scope=self.scope, | ||||
|             nonce=self.nonce, | ||||
|         ) | ||||
|  | ||||
|         if self.code_challenge and self.code_challenge_method: | ||||
|             code.code_challenge = self.code_challenge | ||||
|             code.code_challenge_method = self.code_challenge_method | ||||
|  | ||||
|         code.expires = timezone.now() + timedelta_from_string(self.provider.access_code_validity) | ||||
|         code.scope = self.scope | ||||
|         code.nonce = self.nonce | ||||
|         return code | ||||
|  | ||||
|  | ||||
| @ -309,7 +316,6 @@ class AuthorizationFlowInitView(PolicyAccessView): | ||||
|                 self.params.grant_type, | ||||
|                 self.params.state, | ||||
|             ) | ||||
|             error.to_event(redirect_uri=error.redirect_uri).from_http(self.request) | ||||
|             raise RequestValidationError(error.get_response(self.request)) | ||||
|  | ||||
|     def resolve_provider_application(self): | ||||
| @ -329,27 +335,39 @@ class AuthorizationFlowInitView(PolicyAccessView): | ||||
|  | ||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
|         """Start FlowPLanner, return to flow executor shell""" | ||||
|         # Require a login event to be set, otherwise make the user re-login | ||||
|         login_event = get_login_event(request) | ||||
|         if not login_event: | ||||
|             LOGGER.warning("request with no login event") | ||||
|             return self.handle_no_permission() | ||||
|         login_uid = str(login_event.pk) | ||||
|         # After we've checked permissions, and the user has access, check if we need | ||||
|         # to re-authenticate the user | ||||
|         if self.params.max_age: | ||||
|             current_age: timedelta = ( | ||||
|                 timezone.now() | ||||
|                 - Event.objects.filter(action=EventAction.LOGIN, user=get_user(self.request.user)) | ||||
|                 .latest("created") | ||||
|                 .created | ||||
|             ) | ||||
|             # Attempt to check via the session's login event if set, otherwise we can't | ||||
|             # check | ||||
|             login_time = login_event.created | ||||
|             current_age: timedelta = timezone.now() - login_time | ||||
|             if current_age.total_seconds() > self.params.max_age: | ||||
|                 LOGGER.debug( | ||||
|                     "Triggering authentication as max_age requirement", | ||||
|                     max_age=self.params.max_age, | ||||
|                     ago=int(current_age.total_seconds()), | ||||
|                 ) | ||||
|                 # Since we already need to re-authenticate the user, set the old login UID | ||||
|                 # in case this request has both max_age and prompt=login | ||||
|                 self.request.session[SESSION_KEY_LAST_LOGIN_UID] = login_uid | ||||
|                 return self.handle_no_permission() | ||||
|         # If prompt=login, we need to re-authenticate the user regardless | ||||
|         # Check if we're not already doing the re-authentication | ||||
|         if PROMPT_LOGIN in self.params.prompt: | ||||
|             # No previous login UID saved, so save the current uid and trigger | ||||
|             # re-login, or previous login UID matches current one, so no re-login happened yet | ||||
|             if ( | ||||
|             PROMPT_LOGIN in self.params.prompt | ||||
|             and SESSION_KEY_NEEDS_LOGIN not in self.request.session | ||||
|             # To prevent the user from having to double login when prompt is set to login | ||||
|             # and the user has just signed it. This session variable is set in the UserLoginStage | ||||
|             # and is (quite hackily) removed from the session in applications's API's List method | ||||
|             and USER_LOGIN_AUTHENTICATED not in self.request.session | ||||
|                 SESSION_KEY_LAST_LOGIN_UID not in self.request.session | ||||
|                 or login_uid == self.request.session[SESSION_KEY_LAST_LOGIN_UID] | ||||
|             ): | ||||
|             self.request.session[SESSION_KEY_NEEDS_LOGIN] = True | ||||
|                 self.request.session[SESSION_KEY_LAST_LOGIN_UID] = login_uid | ||||
|                 return self.handle_no_permission() | ||||
|         scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope) | ||||
|         # Regardless, we start the planner and return to it | ||||
| @ -525,6 +543,7 @@ class OAuthFulfillmentStage(StageView): | ||||
|     def create_implicit_response(self, code: Optional[AuthorizationCode]) -> dict: | ||||
|         """Create implicit response's URL Fragment dictionary""" | ||||
|         query_fragment = {} | ||||
|         auth_event = get_login_event(self.request) | ||||
|  | ||||
|         now = timezone.now() | ||||
|         access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) | ||||
| @ -533,6 +552,7 @@ class OAuthFulfillmentStage(StageView): | ||||
|             scope=self.params.scope, | ||||
|             expires=access_token_expiry, | ||||
|             provider=self.provider, | ||||
|             auth_time=auth_event.created if auth_event else now, | ||||
|         ) | ||||
|  | ||||
|         id_token = IDToken.new(self.provider, token, self.request) | ||||
| @ -553,6 +573,8 @@ class OAuthFulfillmentStage(StageView): | ||||
|             ResponseTypes.CODE_TOKEN, | ||||
|         ]: | ||||
|             query_fragment["access_token"] = token.token | ||||
|             # Get at_hash of the current token and update the id_token | ||||
|             id_token.at_hash = token.at_hash | ||||
|  | ||||
|         # Check if response_type must include id_token in the response. | ||||
|         if self.params.response_type in [ | ||||
| @ -561,8 +583,6 @@ class OAuthFulfillmentStage(StageView): | ||||
|             ResponseTypes.CODE_ID_TOKEN, | ||||
|             ResponseTypes.CODE_ID_TOKEN_TOKEN, | ||||
|         ]: | ||||
|             # Get at_hash of the current token and update the id_token | ||||
|             id_token.at_hash = token.at_hash | ||||
|             query_fragment["id_token"] = self.provider.encode(id_token.to_dict()) | ||||
|             token._id_token = dumps(id_token.to_dict()) | ||||
|  | ||||
|  | ||||
| @ -26,6 +26,7 @@ from authentik.core.models import ( | ||||
|     User, | ||||
| ) | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.events.signals import get_login_event | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| @ -479,6 +480,7 @@ class TokenView(View): | ||||
|             expires=access_token_expiry, | ||||
|             # Keep same scopes as previous token | ||||
|             scope=self.params.authorization_code.scope, | ||||
|             auth_time=self.params.authorization_code.auth_time, | ||||
|         ) | ||||
|         access_token.id_token = IDToken.new( | ||||
|             self.provider, | ||||
| @ -493,6 +495,7 @@ class TokenView(View): | ||||
|             scope=self.params.authorization_code.scope, | ||||
|             expires=refresh_token_expiry, | ||||
|             provider=self.provider, | ||||
|             auth_time=self.params.authorization_code.auth_time, | ||||
|         ) | ||||
|         id_token = IDToken.new( | ||||
|             self.provider, | ||||
| @ -521,7 +524,6 @@ class TokenView(View): | ||||
|         unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope) | ||||
|         if unauthorized_scopes: | ||||
|             raise TokenError("invalid_scope") | ||||
|  | ||||
|         now = timezone.now() | ||||
|         access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) | ||||
|         access_token = AccessToken( | ||||
| @ -530,6 +532,7 @@ class TokenView(View): | ||||
|             expires=access_token_expiry, | ||||
|             # Keep same scopes as previous token | ||||
|             scope=self.params.refresh_token.scope, | ||||
|             auth_time=self.params.refresh_token.auth_time, | ||||
|         ) | ||||
|         access_token.id_token = IDToken.new( | ||||
|             self.provider, | ||||
| @ -544,6 +547,7 @@ class TokenView(View): | ||||
|             scope=self.params.refresh_token.scope, | ||||
|             expires=refresh_token_expiry, | ||||
|             provider=self.provider, | ||||
|             auth_time=self.params.refresh_token.auth_time, | ||||
|         ) | ||||
|         id_token = IDToken.new( | ||||
|             self.provider, | ||||
| @ -578,6 +582,7 @@ class TokenView(View): | ||||
|             user=self.params.user, | ||||
|             expires=access_token_expiry, | ||||
|             scope=self.params.scope, | ||||
|             auth_time=now, | ||||
|         ) | ||||
|         access_token.id_token = IDToken.new( | ||||
|             self.provider, | ||||
| @ -600,11 +605,13 @@ class TokenView(View): | ||||
|             raise DeviceCodeError("authorization_pending") | ||||
|         now = timezone.now() | ||||
|         access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) | ||||
|         auth_event = get_login_event(self.request) | ||||
|         access_token = AccessToken( | ||||
|             provider=self.provider, | ||||
|             user=self.params.device_code.user, | ||||
|             expires=access_token_expiry, | ||||
|             scope=self.params.device_code.scope, | ||||
|             auth_time=auth_event.created if auth_event else now, | ||||
|         ) | ||||
|         access_token.id_token = IDToken.new( | ||||
|             self.provider, | ||||
| @ -619,6 +626,7 @@ class TokenView(View): | ||||
|             scope=self.params.device_code.scope, | ||||
|             expires=refresh_token_expiry, | ||||
|             provider=self.provider, | ||||
|             auth_time=auth_event.created if auth_event else now, | ||||
|         ) | ||||
|         id_token = IDToken.new( | ||||
|             self.provider, | ||||
|  | ||||
| @ -11,8 +11,6 @@ from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.stages.password import BACKEND_INBUILT | ||||
| from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND | ||||
|  | ||||
| USER_LOGIN_AUTHENTICATED = "user_login_authenticated" | ||||
|  | ||||
|  | ||||
| class UserLoginStageView(StageView): | ||||
|     """Finalise Authentication flow by logging the user in""" | ||||
| @ -51,7 +49,6 @@ class UserLoginStageView(StageView): | ||||
|             flow_slug=self.executor.flow.slug, | ||||
|             session_duration=self.executor.current_stage.session_duration, | ||||
|         ) | ||||
|         self.request.session[USER_LOGIN_AUTHENTICATED] = True | ||||
|         # Only show success message if we don't have a source in the flow | ||||
|         # as sources show their own success messages | ||||
|         if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None): | ||||
|  | ||||
| @ -11,7 +11,7 @@ entries: | ||||
|     designation: authentication | ||||
|     name: Welcome to authentik! | ||||
|     title: Welcome to authentik! | ||||
|     authentication: require_unauthenticated | ||||
|     authentication: none | ||||
|   identifiers: | ||||
|     slug: default-authentication-flow | ||||
|   model: authentik_flows.flow | ||||
|  | ||||
| @ -40,7 +40,6 @@ entries: | ||||
|             # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")` | ||||
|             "name": request.user.name, | ||||
|             "given_name": request.user.name, | ||||
|             "family_name": "", | ||||
|             "preferred_username": request.user.username, | ||||
|             "nickname": request.user.username, | ||||
|             # groups is not part of the official userinfo schema, but is a quasi-standard | ||||
|  | ||||
							
								
								
									
										8
									
								
								tests/manual/openid-conformance/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								tests/manual/openid-conformance/README.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| # #Test files for OpenID Conformance testing. | ||||
|  | ||||
| These config files assume testing is being done using the [OpenID Conformance Suite | ||||
| ](https://openid.net/certification/about-conformance-suite/), locally. | ||||
|  | ||||
| See https://gitlab.com/openid/conformance-suite/-/wikis/Developers/Build-&-Run for running the conformance suite locally. | ||||
|  | ||||
| Requires docker containers to be able to access the host via `host.docker.internal` and an entry in the hosts file that maps `host.docker.internal` to localhost. | ||||
							
								
								
									
										81
									
								
								tests/manual/openid-conformance/oidc-conformance.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								tests/manual/openid-conformance/oidc-conformance.yaml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,81 @@ | ||||
| version: 1 | ||||
| metadata: | ||||
|   name: OIDC conformance testing | ||||
| entries: | ||||
|   - identifiers: | ||||
|       managed: goauthentik.io/providers/oauth2/scope-address | ||||
|     model: authentik_providers_oauth2.scopemapping | ||||
|     attrs: | ||||
|       name: "authentik default OAuth Mapping: OpenID 'address'" | ||||
|       scope_name: address | ||||
|       description: "General Address Information" | ||||
|       expression: | | ||||
|         return { | ||||
|             "address": { | ||||
|                 "formatted": "foo", | ||||
|             } | ||||
|         } | ||||
|   - identifiers: | ||||
|       managed: goauthentik.io/providers/oauth2/scope-phone | ||||
|     model: authentik_providers_oauth2.scopemapping | ||||
|     attrs: | ||||
|       name: "authentik default OAuth Mapping: OpenID 'phone'" | ||||
|       scope_name: phone | ||||
|       description: "General phone Information" | ||||
|       expression: | | ||||
|         return { | ||||
|             "phone_number": "+1234", | ||||
|             "phone_number_verified": True, | ||||
|         } | ||||
|  | ||||
|   - model: authentik_providers_oauth2.oauth2provider | ||||
|     id: provider | ||||
|     identifiers: | ||||
|       name: provider | ||||
|     attrs: | ||||
|       authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] | ||||
|       issuer_mode: global | ||||
|       client_id: 4054d882aff59755f2f279968b97ce8806a926e1 | ||||
|       client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867 | ||||
|       redirect_uris: | | ||||
|         https://localhost:8443/test/a/authentik/callback | ||||
|         https://localhost.emobix.co.uk:8443/test/a/authentik/callback | ||||
|       property_mappings: | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] | ||||
|       signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] | ||||
|   - model: authentik_core.application | ||||
|     identifiers: | ||||
|       slug: conformance | ||||
|     attrs: | ||||
|       provider: !KeyOf provider | ||||
|       name: Conformance | ||||
|  | ||||
|   - model: authentik_providers_oauth2.oauth2provider | ||||
|     id: oidc-conformance-2 | ||||
|     identifiers: | ||||
|       name: oidc-conformance-2 | ||||
|     attrs: | ||||
|       authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] | ||||
|       issuer_mode: global | ||||
|       client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26 | ||||
|       client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789 | ||||
|       redirect_uris: | | ||||
|         https://localhost:8443/test/a/authentik/callback | ||||
|         https://localhost.emobix.co.uk:8443/test/a/authentik/callback | ||||
|       property_mappings: | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] | ||||
|       signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] | ||||
|   - model: authentik_core.application | ||||
|     identifiers: | ||||
|       slug: oidc-conformance-2 | ||||
|     attrs: | ||||
|       provider: !KeyOf oidc-conformance-2 | ||||
|       name: OIDC Conformance | ||||
							
								
								
									
										20
									
								
								tests/manual/openid-conformance/test-config.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								tests/manual/openid-conformance/test-config.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| { | ||||
|     "alias": "authentik", | ||||
|     "description": "authentik", | ||||
|     "server": { | ||||
|         "discoveryUrl": "http://host.docker.internal:9000/application/o/conformance/.well-known/openid-configuration" | ||||
|     }, | ||||
|     "client": { | ||||
|         "client_id": "4054d882aff59755f2f279968b97ce8806a926e1", | ||||
|         "client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867" | ||||
|     }, | ||||
|     "client_secret_post": { | ||||
|         "client_id": "4054d882aff59755f2f279968b97ce8806a926e1", | ||||
|         "client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867" | ||||
|     }, | ||||
|     "client2": { | ||||
|         "client_id": "ad64aeaf1efe388ecf4d28fcc537e8de08bcae26", | ||||
|         "client_secret": "ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789" | ||||
|     }, | ||||
|     "consent": {} | ||||
| } | ||||
| @ -41,7 +41,6 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css"; | ||||
| import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
|  | ||||
| import { | ||||
|     CapabilitiesEnum, | ||||
|     ChallengeChoices, | ||||
|     ChallengeTypes, | ||||
|     ContextualFlowInfo, | ||||
| @ -97,7 +96,7 @@ export class FlowExecutor extends AKElement implements StageHost { | ||||
|     tenant!: CurrentTenant; | ||||
|  | ||||
|     @state() | ||||
|     inspectorOpen: boolean; | ||||
|     inspectorOpen = false; | ||||
|  | ||||
|     _flowInfo?: ContextualFlowInfo; | ||||
|  | ||||
| @ -177,8 +176,6 @@ export class FlowExecutor extends AKElement implements StageHost { | ||||
|         super(); | ||||
|         this.ws = new WebsocketClient(); | ||||
|         this.flowSlug = window.location.pathname.split("/")[3]; | ||||
|         this.inspectorOpen = | ||||
|             globalAK()?.config.capabilities.includes(CapabilitiesEnum.Debug) || false; | ||||
|         if (window.location.search.includes("inspector")) { | ||||
|             this.inspectorOpen = !this.inspectorOpen; | ||||
|         } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L