From 80f4fccd35427434d0c67b18f9d4ad886938a50d Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 23 Feb 2023 15:26:41 +0100 Subject: [PATCH] providers/oauth2: OpenID conformance (#4758) * don't open inspector by default when debug is enabled Signed-off-by: Jens Langhammer * encode error in fragment when using hybrid grant_type Signed-off-by: Jens Langhammer * require nonce for all response_types that get an id_token from the authorization endpoint Signed-off-by: Jens Langhammer * don't set empty family_name Signed-off-by: Jens Langhammer * only set at_hash when response has token Signed-off-by: Jens Langhammer * cleaner way to get login time Signed-off-by: Jens Langhammer * remove authentication requirement from authentication flow Signed-off-by: Jens Langhammer * use wrapper Signed-off-by: Jens Langhammer * fix auth_time not being handled correctly Signed-off-by: Jens Langhammer * minor cleanup Signed-off-by: Jens Langhammer * add test files Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * remove USER_LOGIN_AUTHENTICATED Signed-off-by: Jens Langhammer * rework prompt=login handling Signed-off-by: Jens Langhammer * 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 --------- Signed-off-by: Jens Langhammer --- authentik/api/tests/test_auth.py | 3 + authentik/core/api/applications.py | 5 - authentik/flows/stage.py | 2 +- authentik/providers/oauth2/api/providers.py | 2 + authentik/providers/oauth2/errors.py | 6 +- authentik/providers/oauth2/id_token.py | 3 +- ...me_authorizationcode_auth_time_and_more.py | 40 +++++++ authentik/providers/oauth2/models.py | 3 +- .../providers/oauth2/tests/test_authorize.py | 3 + .../providers/oauth2/tests/test_introspect.py | 3 + .../providers/oauth2/tests/test_revoke.py | 3 + .../providers/oauth2/tests/test_token.py | 13 ++- .../providers/oauth2/tests/test_userinfo.py | 4 +- authentik/providers/oauth2/views/authorize.py | 100 +++++++++++------- authentik/providers/oauth2/views/token.py | 10 +- authentik/stages/user_login/stage.py | 3 - .../flow-default-authentication-flow.yaml | 2 +- blueprints/system/providers-oauth2.yaml | 1 - tests/manual/openid-conformance/README.md | 8 ++ .../openid-conformance/oidc-conformance.yaml | 81 ++++++++++++++ .../openid-conformance/test-config.json | 20 ++++ web/src/flow/FlowExecutor.ts | 5 +- 22 files changed, 255 insertions(+), 65 deletions(-) create mode 100644 authentik/providers/oauth2/migrations/0015_accesstoken_auth_time_authorizationcode_auth_time_and_more.py create mode 100644 tests/manual/openid-conformance/README.md create mode 100644 tests/manual/openid-conformance/oidc-conformance.yaml create mode 100644 tests/manual/openid-conformance/test-config.json diff --git a/authentik/api/tests/test_auth.py b/authentik/api/tests/test_auth.py index 615e307b4..510fff6d6 100644 --- a/authentik/api/tests/test_auth.py +++ b/authentik/api/tests/test_auth.py @@ -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({}), ) diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 728f11ed9..5bf0bcb56 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -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) diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index 6943877ad..68589c8fd 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -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", } diff --git a/authentik/providers/oauth2/api/providers.py b/authentik/providers/oauth2/api/providers.py index bea086e30..f93265660 100644 --- a/authentik/providers/oauth2/api/providers.py +++ b/authentik/providers/oauth2/api/providers.py @@ -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, ) diff --git a/authentik/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py index 2537d5de5..1788c9370 100644 --- a/authentik/providers/oauth2/errors.py +++ b/authentik/providers/oauth2/errors.py @@ -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}" ) diff --git a/authentik/providers/oauth2/id_token.py b/authentik/providers/oauth2/id_token.py index d1b899768..32be814d4 100644 --- a/authentik/providers/oauth2/id_token.py +++ b/authentik/providers/oauth2/id_token.py @@ -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, {}) diff --git a/authentik/providers/oauth2/migrations/0015_accesstoken_auth_time_authorizationcode_auth_time_and_more.py b/authentik/providers/oauth2/migrations/0015_accesstoken_auth_time_authorizationcode_auth_time_and_more.py new file mode 100644 index 000000000..7b84077a1 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0015_accesstoken_auth_time_authorizationcode_auth_time_and_more.py @@ -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, + ), + ] diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 66662c6db..c82f20c80 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -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]: diff --git a/authentik/providers/oauth2/tests/test_authorize.py b/authentik/providers/oauth2/tests/test_authorize.py index 69135968a..17c07e350 100644 --- a/authentik/providers/oauth2/tests/test_authorize.py +++ b/authentik/providers/oauth2/tests/test_authorize.py @@ -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( diff --git a/authentik/providers/oauth2/tests/test_introspect.py b/authentik/providers/oauth2/tests/test_introspect.py index d35c5fba2..a55a52825 100644 --- a/authentik/providers/oauth2/tests/test_introspect.py +++ b/authentik/providers/oauth2/tests/test_introspect.py @@ -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( diff --git a/authentik/providers/oauth2/tests/test_revoke.py b/authentik/providers/oauth2/tests/test_revoke.py index e956699c4..43c5de7c5 100644 --- a/authentik/providers/oauth2/tests/test_revoke.py +++ b/authentik/providers/oauth2/tests/test_revoke.py @@ -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( diff --git a/authentik/providers/oauth2/tests/test_token.py b/authentik/providers/oauth2/tests/test_token.py index 6a41c7738..acea27f64 100644 --- a/authentik/providers/oauth2/tests/test_token.py +++ b/authentik/providers/oauth2/tests/test_token.py @@ -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( diff --git a/authentik/providers/oauth2/tests/test_userinfo.py b/authentik/providers/oauth2/tests/test_userinfo.py index b0be06339..576dbca52 100644 --- a/authentik/providers/oauth2/tests/test_userinfo.py +++ b/authentik/providers/oauth2/tests/test_userinfo.py @@ -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()], diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index a5724b354..9743c773a 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -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 - ) + LOGGER.warning("Missing nonce for OpenID Request") + 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,28 +335,40 @@ 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 - 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 - ): - self.request.session[SESSION_KEY_NEEDS_LOGIN] = True - return self.handle_no_permission() + # 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 ( + 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_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 planner = FlowPlanner(self.provider.authorization_flow) @@ -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()) diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 15887e35b..aba0f0082 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -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, diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py index f07af9b15..6536fa6f2 100644 --- a/authentik/stages/user_login/stage.py +++ b/authentik/stages/user_login/stage.py @@ -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): diff --git a/blueprints/default/flow-default-authentication-flow.yaml b/blueprints/default/flow-default-authentication-flow.yaml index 1cee92dcd..bb5f6089c 100644 --- a/blueprints/default/flow-default-authentication-flow.yaml +++ b/blueprints/default/flow-default-authentication-flow.yaml @@ -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 diff --git a/blueprints/system/providers-oauth2.yaml b/blueprints/system/providers-oauth2.yaml index 1e905ffdf..eb4ca5442 100644 --- a/blueprints/system/providers-oauth2.yaml +++ b/blueprints/system/providers-oauth2.yaml @@ -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 diff --git a/tests/manual/openid-conformance/README.md b/tests/manual/openid-conformance/README.md new file mode 100644 index 000000000..41b6fe7f9 --- /dev/null +++ b/tests/manual/openid-conformance/README.md @@ -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. diff --git a/tests/manual/openid-conformance/oidc-conformance.yaml b/tests/manual/openid-conformance/oidc-conformance.yaml new file mode 100644 index 000000000..54f3da1ff --- /dev/null +++ b/tests/manual/openid-conformance/oidc-conformance.yaml @@ -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 diff --git a/tests/manual/openid-conformance/test-config.json b/tests/manual/openid-conformance/test-config.json new file mode 100644 index 000000000..2eed023ff --- /dev/null +++ b/tests/manual/openid-conformance/test-config.json @@ -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": {} +} diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index d835734f3..ef5682240 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -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; }