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:
Jens L 2023-02-23 15:26:41 +01:00 committed by GitHub
parent c6a14fa4f1
commit 80f4fccd35
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 255 additions and 65 deletions

View File

@ -4,6 +4,7 @@ from base64 import b64encode
from django.conf import settings from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import bearer_auth from authentik.api.authentication import bearer_auth
@ -68,6 +69,7 @@ class TestAPIAuth(TestCase):
user=create_test_admin_user(), user=create_test_admin_user(),
provider=provider, provider=provider,
token=generate_id(), token=generate_id(),
auth_time=timezone.now(),
_scope=SCOPE_AUTHENTIK_API, _scope=SCOPE_AUTHENTIK_API,
_id_token=json.dumps({}), _id_token=json.dumps({}),
) )
@ -82,6 +84,7 @@ class TestAPIAuth(TestCase):
user=create_test_admin_user(), user=create_test_admin_user(),
provider=provider, provider=provider,
token=generate_id(), token=generate_id(),
auth_time=timezone.now(),
_scope="", _scope="",
_id_token=json.dumps({}), _id_token=json.dumps({}),
) )

View File

@ -37,7 +37,6 @@ from authentik.lib.utils.file import (
from authentik.policies.api.exec import PolicyTestResultSerializer from authentik.policies.api.exec import PolicyTestResultSerializer
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyResult from authentik.policies.types import PolicyResult
from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED
LOGGER = get_logger() LOGGER = get_logger()
@ -186,10 +185,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
if superuser_full_list and request.user.is_superuser: if superuser_full_list and request.user.is_superuser:
return super().list(request) 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()) queryset = self._filter_queryset_for_list(self.get_queryset())
self.paginate_queryset(queryset) self.paginate_queryset(queryset)

View File

@ -231,7 +231,7 @@ class AccessDeniedChallengeView(ChallengeStageView):
def get_challenge(self, *args, **kwargs) -> Challenge: def get_challenge(self, *args, **kwargs) -> Challenge:
return AccessDeniedChallenge( return AccessDeniedChallenge(
data={ data={
"error_message": self.error_message or "Unknown error", "error_message": str(self.error_message or "Unknown error"),
"type": ChallengeTypes.NATIVE.value, "type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-access-denied", "component": "ak-stage-access-denied",
} }

View File

@ -1,5 +1,6 @@
"""OAuth2Provider API Views""" """OAuth2Provider API Views"""
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import CharField from rest_framework.fields import CharField
@ -153,6 +154,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
user=request.user, user=request.user,
provider=provider, provider=provider,
_scope=" ".join(scope_names), _scope=" ".join(scope_names),
auth_time=timezone.now(),
), ),
request, request,
) )

View File

@ -174,10 +174,12 @@ class AuthorizeError(OAuth2Error):
# See: # See:
# http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError # 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 = ( uri = (
f"{self.redirect_uri}{hash_or_question}error=" f"{self.redirect_uri}{fragment_or_query}error="
f"{self.error}&error_description={description}" f"{self.error}&error_description={description}"
) )

View File

@ -110,12 +110,11 @@ class IDToken:
# Convert datetimes into timestamps. # Convert datetimes into timestamps.
now = timezone.now() now = timezone.now()
id_token.iat = int(now.timestamp()) 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 # We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
auth_event = get_login_event(request) auth_event = get_login_event(request)
if auth_event: if auth_event:
auth_time = auth_event.created
id_token.auth_time = int(auth_time.timestamp())
# Also check which method was used for authentication # Also check which method was used for authentication
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "") method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
method_args = auth_event.context.get(PLAN_CONTEXT_METHOD_ARGS, {}) method_args = auth_event.context.get(PLAN_CONTEXT_METHOD_ARGS, {})

View File

@ -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,
),
]

View File

@ -226,7 +226,7 @@ class OAuth2Provider(Provider):
def get_issuer(self, request: HttpRequest) -> Optional[str]: def get_issuer(self, request: HttpRequest) -> Optional[str]:
"""Get issuer, based on request""" """Get issuer, based on request"""
if self.issuer_mode == IssuerMode.GLOBAL: if self.issuer_mode == IssuerMode.GLOBAL:
return request.build_absolute_uri("/") return request.build_absolute_uri(reverse("authentik_core:root-redirect"))
try: try:
url = reverse( url = reverse(
"authentik_providers_oauth2:provider-root", "authentik_providers_oauth2:provider-root",
@ -282,6 +282,7 @@ class BaseGrantModel(models.Model):
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE) user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
revoked = models.BooleanField(default=False) revoked = models.BooleanField(default=False)
_scope = models.TextField(default="", verbose_name=_("Scopes")) _scope = models.TextField(default="", verbose_name=_("Scopes"))
auth_time = models.DateTimeField(verbose_name="Authentication time")
@property @property
def scope(self) -> list[str]: def scope(self) -> list[str]:

View File

@ -204,6 +204,7 @@ class TestAuthorize(OAuthTestCase):
"redirect_uri": "http://local.invalid/Foo", "redirect_uri": "http://local.invalid/Foo",
"scope": "openid", "scope": "openid",
"state": "foo", "state": "foo",
"nonce": generate_id(),
}, },
) )
self.assertEqual( self.assertEqual(
@ -325,6 +326,7 @@ class TestAuthorize(OAuthTestCase):
"state": state, "state": state,
"scope": "openid", "scope": "openid",
"redirect_uri": "http://localhost", "redirect_uri": "http://localhost",
"nonce": generate_id(),
}, },
) )
response = self.client.get( response = self.client.get(
@ -378,6 +380,7 @@ class TestAuthorize(OAuthTestCase):
"state": state, "state": state,
"scope": "openid", "scope": "openid",
"redirect_uri": "http://localhost", "redirect_uri": "http://localhost",
"nonce": generate_id(),
}, },
) )
response = self.client.get( response = self.client.get(

View File

@ -4,6 +4,7 @@ from base64 import b64encode
from dataclasses import asdict from dataclasses import asdict
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow 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, provider=self.provider,
user=self.user, user=self.user,
token=generate_id(), token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile", _scope="openid user profile",
_id_token=json.dumps( _id_token=json.dumps(
asdict( asdict(
@ -72,6 +74,7 @@ class TesOAuth2Introspection(OAuthTestCase):
provider=self.provider, provider=self.provider,
user=self.user, user=self.user,
token=generate_id(), token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile", _scope="openid user profile",
_id_token=json.dumps( _id_token=json.dumps(
asdict( asdict(

View File

@ -4,6 +4,7 @@ from base64 import b64encode
from dataclasses import asdict from dataclasses import asdict
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow 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, provider=self.provider,
user=self.user, user=self.user,
token=generate_id(), token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile", _scope="openid user profile",
_id_token=json.dumps( _id_token=json.dumps(
asdict( asdict(
@ -62,6 +64,7 @@ class TesOAuth2Revoke(OAuthTestCase):
provider=self.provider, provider=self.provider,
user=self.user, user=self.user,
token=generate_id(), token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile", _scope="openid user profile",
_id_token=json.dumps( _id_token=json.dumps(
asdict( asdict(

View File

@ -4,6 +4,7 @@ from json import dumps
from django.test import RequestFactory from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow 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() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user() 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( request = self.factory.post(
"/", "/",
data={ data={
@ -99,6 +102,7 @@ class TestToken(OAuthTestCase):
provider=provider, provider=provider,
user=user, user=user,
token=generate_id(), token=generate_id(),
auth_time=timezone.now(),
) )
request = self.factory.post( request = self.factory.post(
"/", "/",
@ -127,7 +131,9 @@ class TestToken(OAuthTestCase):
self.app.save() self.app.save()
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user() 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( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
data={ data={
@ -173,6 +179,7 @@ class TestToken(OAuthTestCase):
user=user, user=user,
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(),
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -221,6 +228,7 @@ class TestToken(OAuthTestCase):
user=user, user=user,
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(),
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -271,6 +279,7 @@ class TestToken(OAuthTestCase):
user=user, user=user,
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(),
) )
# Create initial refresh token # Create initial refresh token
response = self.client.post( response = self.client.post(

View File

@ -3,6 +3,7 @@ import json
from dataclasses import asdict from dataclasses import asdict
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application from authentik.core.models import Application
@ -37,6 +38,7 @@ class TestUserinfo(OAuthTestCase):
provider=self.provider, provider=self.provider,
user=self.user, user=self.user,
token=generate_id(), token=generate_id(),
auth_time=timezone.now(),
_scope="openid user profile", _scope="openid user profile",
_id_token=json.dumps( _id_token=json.dumps(
asdict( asdict(
@ -56,7 +58,6 @@ class TestUserinfo(OAuthTestCase):
{ {
"name": self.user.name, "name": self.user.name,
"given_name": self.user.name, "given_name": self.user.name,
"family_name": "",
"preferred_username": self.user.name, "preferred_username": self.user.name,
"nickname": self.user.name, "nickname": self.user.name,
"groups": [group.name for group in self.user.ak_groups.all()], "groups": [group.name for group in self.user.ak_groups.all()],
@ -79,7 +80,6 @@ class TestUserinfo(OAuthTestCase):
{ {
"name": self.user.name, "name": self.user.name,
"given_name": self.user.name, "given_name": self.user.name,
"family_name": "",
"preferred_username": self.user.name, "preferred_username": self.user.name,
"nickname": self.user.name, "nickname": self.user.name,
"groups": [group.name for group in self.user.ak_groups.all()], "groups": [group.name for group in self.user.ak_groups.all()],

View File

@ -17,7 +17,7 @@ from structlog.stdlib import get_logger
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import Event, EventAction 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 ( from authentik.flows.challenge import (
PLAN_CONTEXT_TITLE, PLAN_CONTEXT_TITLE,
AutosubmitChallenge, AutosubmitChallenge,
@ -64,12 +64,11 @@ from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_PERMISSIONS, PLAN_CONTEXT_CONSENT_PERMISSIONS,
ConsentStageView, ConsentStageView,
) )
from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED
LOGGER = get_logger() LOGGER = get_logger()
PLAN_CONTEXT_PARAMS = "params" 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} ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN}
@ -235,19 +234,22 @@ class OAuthAuthorizationParams:
def check_nonce(self): def check_nonce(self):
"""Nonce parameter validation.""" """Nonce parameter validation."""
# https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDTValidation # nonce is required for all flows that return an id_token from the authorization endpoint,
# Nonce is only required for Implicit flows # see https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest or
if self.grant_type != GrantTypes.IMPLICIT: # 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 return
if not self.nonce: 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") LOGGER.warning("Missing nonce for OpenID Request")
raise AuthorizeError( raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state)
self.redirect_uri, "invalid_request", self.grant_type, self.state
)
def check_code_challenge(self): def check_code_challenge(self):
"""PKCE validation of the transformation method.""" """PKCE validation of the transformation method."""
@ -262,19 +264,24 @@ class OAuthAuthorizationParams:
def create_code(self, request: HttpRequest) -> AuthorizationCode: def create_code(self, request: HttpRequest) -> AuthorizationCode:
"""Create an AuthorizationCode object for the request""" """Create an AuthorizationCode object for the request"""
code = AuthorizationCode() auth_event = get_login_event(request)
code.user = request.user
code.provider = self.provider
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: if self.code_challenge and self.code_challenge_method:
code.code_challenge = self.code_challenge code.code_challenge = self.code_challenge
code.code_challenge_method = self.code_challenge_method 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 return code
@ -309,7 +316,6 @@ class AuthorizationFlowInitView(PolicyAccessView):
self.params.grant_type, self.params.grant_type,
self.params.state, self.params.state,
) )
error.to_event(redirect_uri=error.redirect_uri).from_http(self.request)
raise RequestValidationError(error.get_response(self.request)) raise RequestValidationError(error.get_response(self.request))
def resolve_provider_application(self): def resolve_provider_application(self):
@ -329,27 +335,39 @@ class AuthorizationFlowInitView(PolicyAccessView):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Start FlowPLanner, return to flow executor shell""" """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 # After we've checked permissions, and the user has access, check if we need
# to re-authenticate the user # to re-authenticate the user
if self.params.max_age: if self.params.max_age:
current_age: timedelta = ( # Attempt to check via the session's login event if set, otherwise we can't
timezone.now() # check
- Event.objects.filter(action=EventAction.LOGIN, user=get_user(self.request.user)) login_time = login_event.created
.latest("created") current_age: timedelta = timezone.now() - login_time
.created
)
if current_age.total_seconds() > self.params.max_age: 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() return self.handle_no_permission()
# If prompt=login, we need to re-authenticate the user regardless # 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 ( if (
PROMPT_LOGIN in self.params.prompt SESSION_KEY_LAST_LOGIN_UID not in self.request.session
and SESSION_KEY_NEEDS_LOGIN not in self.request.session or login_uid == self.request.session[SESSION_KEY_LAST_LOGIN_UID]
# 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 self.request.session[SESSION_KEY_LAST_LOGIN_UID] = login_uid
return self.handle_no_permission() return self.handle_no_permission()
scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope) scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope)
# Regardless, we start the planner and return to it # 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: def create_implicit_response(self, code: Optional[AuthorizationCode]) -> dict:
"""Create implicit response's URL Fragment dictionary""" """Create implicit response's URL Fragment dictionary"""
query_fragment = {} query_fragment = {}
auth_event = get_login_event(self.request)
now = timezone.now() now = timezone.now()
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
@ -533,6 +552,7 @@ class OAuthFulfillmentStage(StageView):
scope=self.params.scope, scope=self.params.scope,
expires=access_token_expiry, expires=access_token_expiry,
provider=self.provider, provider=self.provider,
auth_time=auth_event.created if auth_event else now,
) )
id_token = IDToken.new(self.provider, token, self.request) id_token = IDToken.new(self.provider, token, self.request)
@ -553,6 +573,8 @@ class OAuthFulfillmentStage(StageView):
ResponseTypes.CODE_TOKEN, ResponseTypes.CODE_TOKEN,
]: ]:
query_fragment["access_token"] = token.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. # Check if response_type must include id_token in the response.
if self.params.response_type in [ if self.params.response_type in [
@ -561,8 +583,6 @@ class OAuthFulfillmentStage(StageView):
ResponseTypes.CODE_ID_TOKEN, ResponseTypes.CODE_ID_TOKEN,
ResponseTypes.CODE_ID_TOKEN_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()) query_fragment["id_token"] = self.provider.encode(id_token.to_dict())
token._id_token = dumps(id_token.to_dict()) token._id_token = dumps(id_token.to_dict())

View File

@ -26,6 +26,7 @@ from authentik.core.models import (
User, User,
) )
from authentik.events.models import Event, EventAction 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.flows.planner import PLAN_CONTEXT_APPLICATION
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
@ -479,6 +480,7 @@ class TokenView(View):
expires=access_token_expiry, expires=access_token_expiry,
# Keep same scopes as previous token # Keep same scopes as previous token
scope=self.params.authorization_code.scope, scope=self.params.authorization_code.scope,
auth_time=self.params.authorization_code.auth_time,
) )
access_token.id_token = IDToken.new( access_token.id_token = IDToken.new(
self.provider, self.provider,
@ -493,6 +495,7 @@ class TokenView(View):
scope=self.params.authorization_code.scope, scope=self.params.authorization_code.scope,
expires=refresh_token_expiry, expires=refresh_token_expiry,
provider=self.provider, provider=self.provider,
auth_time=self.params.authorization_code.auth_time,
) )
id_token = IDToken.new( id_token = IDToken.new(
self.provider, self.provider,
@ -521,7 +524,6 @@ class TokenView(View):
unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope) unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope)
if unauthorized_scopes: if unauthorized_scopes:
raise TokenError("invalid_scope") raise TokenError("invalid_scope")
now = timezone.now() now = timezone.now()
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
access_token = AccessToken( access_token = AccessToken(
@ -530,6 +532,7 @@ class TokenView(View):
expires=access_token_expiry, expires=access_token_expiry,
# Keep same scopes as previous token # Keep same scopes as previous token
scope=self.params.refresh_token.scope, scope=self.params.refresh_token.scope,
auth_time=self.params.refresh_token.auth_time,
) )
access_token.id_token = IDToken.new( access_token.id_token = IDToken.new(
self.provider, self.provider,
@ -544,6 +547,7 @@ class TokenView(View):
scope=self.params.refresh_token.scope, scope=self.params.refresh_token.scope,
expires=refresh_token_expiry, expires=refresh_token_expiry,
provider=self.provider, provider=self.provider,
auth_time=self.params.refresh_token.auth_time,
) )
id_token = IDToken.new( id_token = IDToken.new(
self.provider, self.provider,
@ -578,6 +582,7 @@ class TokenView(View):
user=self.params.user, user=self.params.user,
expires=access_token_expiry, expires=access_token_expiry,
scope=self.params.scope, scope=self.params.scope,
auth_time=now,
) )
access_token.id_token = IDToken.new( access_token.id_token = IDToken.new(
self.provider, self.provider,
@ -600,11 +605,13 @@ class TokenView(View):
raise DeviceCodeError("authorization_pending") raise DeviceCodeError("authorization_pending")
now = timezone.now() now = timezone.now()
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
auth_event = get_login_event(self.request)
access_token = AccessToken( access_token = AccessToken(
provider=self.provider, provider=self.provider,
user=self.params.device_code.user, user=self.params.device_code.user,
expires=access_token_expiry, expires=access_token_expiry,
scope=self.params.device_code.scope, scope=self.params.device_code.scope,
auth_time=auth_event.created if auth_event else now,
) )
access_token.id_token = IDToken.new( access_token.id_token = IDToken.new(
self.provider, self.provider,
@ -619,6 +626,7 @@ class TokenView(View):
scope=self.params.device_code.scope, scope=self.params.device_code.scope,
expires=refresh_token_expiry, expires=refresh_token_expiry,
provider=self.provider, provider=self.provider,
auth_time=auth_event.created if auth_event else now,
) )
id_token = IDToken.new( id_token = IDToken.new(
self.provider, self.provider,

View File

@ -11,8 +11,6 @@ from authentik.lib.utils.time import timedelta_from_string
from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
USER_LOGIN_AUTHENTICATED = "user_login_authenticated"
class UserLoginStageView(StageView): class UserLoginStageView(StageView):
"""Finalise Authentication flow by logging the user in""" """Finalise Authentication flow by logging the user in"""
@ -51,7 +49,6 @@ class UserLoginStageView(StageView):
flow_slug=self.executor.flow.slug, flow_slug=self.executor.flow.slug,
session_duration=self.executor.current_stage.session_duration, 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 # Only show success message if we don't have a source in the flow
# as sources show their own success messages # as sources show their own success messages
if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None): if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None):

View File

@ -11,7 +11,7 @@ entries:
designation: authentication designation: authentication
name: Welcome to authentik! name: Welcome to authentik!
title: Welcome to authentik! title: Welcome to authentik!
authentication: require_unauthenticated authentication: none
identifiers: identifiers:
slug: default-authentication-flow slug: default-authentication-flow
model: authentik_flows.flow model: authentik_flows.flow

View File

@ -40,7 +40,6 @@ entries:
# You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")` # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")`
"name": request.user.name, "name": request.user.name,
"given_name": request.user.name, "given_name": request.user.name,
"family_name": "",
"preferred_username": request.user.username, "preferred_username": request.user.username,
"nickname": request.user.username, "nickname": request.user.username,
# groups is not part of the official userinfo schema, but is a quasi-standard # groups is not part of the official userinfo schema, but is a quasi-standard

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

View 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

View 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": {}
}

View File

@ -41,7 +41,6 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { import {
CapabilitiesEnum,
ChallengeChoices, ChallengeChoices,
ChallengeTypes, ChallengeTypes,
ContextualFlowInfo, ContextualFlowInfo,
@ -97,7 +96,7 @@ export class FlowExecutor extends AKElement implements StageHost {
tenant!: CurrentTenant; tenant!: CurrentTenant;
@state() @state()
inspectorOpen: boolean; inspectorOpen = false;
_flowInfo?: ContextualFlowInfo; _flowInfo?: ContextualFlowInfo;
@ -177,8 +176,6 @@ export class FlowExecutor extends AKElement implements StageHost {
super(); super();
this.ws = new WebsocketClient(); this.ws = new WebsocketClient();
this.flowSlug = window.location.pathname.split("/")[3]; this.flowSlug = window.location.pathname.split("/")[3];
this.inspectorOpen =
globalAK()?.config.capabilities.includes(CapabilitiesEnum.Debug) || false;
if (window.location.search.includes("inspector")) { if (window.location.search.includes("inspector")) {
this.inspectorOpen = !this.inspectorOpen; this.inspectorOpen = !this.inspectorOpen;
} }