From af43330fd60dee9c08737189fa6a5052f305b3ab Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 9 Feb 2023 20:19:48 +0100 Subject: [PATCH] providers/oauth2: rework OAuth2 Provider (#4652) * always treat flow as openid flow Signed-off-by: Jens Langhammer * improve issuer URL generation Signed-off-by: Jens Langhammer * more refactoring Signed-off-by: Jens Langhammer * update introspection Signed-off-by: Jens Langhammer * more refinement Signed-off-by: Jens Langhammer * migrate more Signed-off-by: Jens Langhammer * fix more things, update api Signed-off-by: Jens Langhammer * regen migrations Signed-off-by: Jens Langhammer * fix a bunch of things Signed-off-by: Jens Langhammer * start updating tests Signed-off-by: Jens Langhammer * fix implicit flow, auto set exp Signed-off-by: Jens Langhammer * fix timeozone not used correctly Signed-off-by: Jens Langhammer * fix revoke Signed-off-by: Jens Langhammer * more timezone shenanigans Signed-off-by: Jens Langhammer * fix userinfo tests Signed-off-by: Jens Langhammer * update web Signed-off-by: Jens Langhammer * fix proxy outpost Signed-off-by: Jens Langhammer * fix api tests Signed-off-by: Jens Langhammer * fix missing at_hash for implicit flows Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * re-include at_hash in implicit auth flow Signed-off-by: Jens Langhammer * use folder context for outpost build Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- .github/workflows/ci-outpost.yml | 5 +- authentik/api/authentication.py | 6 +- authentik/api/tests/test_auth.py | 17 +- authentik/api/v3/urls.py | 7 +- authentik/providers/oauth2/api/providers.py | 25 +- authentik/providers/oauth2/api/tokens.py | 32 +- authentik/providers/oauth2/constants.py | 2 + authentik/providers/oauth2/errors.py | 10 +- authentik/providers/oauth2/id_token.py | 163 ++++++++ ...014_alter_refreshtoken_options_and_more.py | 117 ++++++ authentik/providers/oauth2/models.py | 257 ++++--------- .../providers/oauth2/tests/test_authorize.py | 25 +- .../providers/oauth2/tests/test_introspect.py | 57 ++- .../providers/oauth2/tests/test_revoke.py | 53 ++- .../providers/oauth2/tests/test_token.py | 87 +++-- .../providers/oauth2/tests/test_token_cc.py | 3 +- .../oauth2/tests/test_token_cc_jwt_source.py | 3 +- .../providers/oauth2/tests/test_userinfo.py | 11 +- authentik/providers/oauth2/tests/utils.py | 7 +- authentik/providers/oauth2/urls.py | 5 + authentik/providers/oauth2/utils.py | 9 +- authentik/providers/oauth2/views/authorize.py | 70 ++-- .../providers/oauth2/views/introspection.py | 33 +- authentik/providers/oauth2/views/token.py | 254 +++++++----- .../providers/oauth2/views/token_revoke.py | 28 +- authentik/providers/oauth2/views/userinfo.py | 16 +- authentik/providers/proxy/api.py | 11 +- blueprints/schema.json | 1 + .../outpost/proxyv2/application/session.go | 8 +- schema.yml | 363 +++++++++++++----- .../providers/oauth2/OAuth2ProviderForm.ts | 24 +- .../providers/proxy/ProxyProviderForm.ts | 4 +- web/src/admin/users/UserViewPage.ts | 2 +- web/src/elements/oauth/UserRefreshList.ts | 12 +- website/docs/releases/2023/v2023.2.md | 4 + 35 files changed, 1129 insertions(+), 602 deletions(-) create mode 100644 authentik/providers/oauth2/id_token.py create mode 100644 authentik/providers/oauth2/migrations/0014_alter_refreshtoken_options_and_more.py diff --git a/.github/workflows/ci-outpost.yml b/.github/workflows/ci-outpost.yml index 33c50ad9f..a8e12de7a 100644 --- a/.github/workflows/ci-outpost.yml +++ b/.github/workflows/ci-outpost.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - run: echo mark - build: + build-container: timeout-minutes: 120 needs: - ci-outpost-mark @@ -94,7 +94,8 @@ jobs: GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} platforms: ${{ matrix.arch }} - build-outpost-binary: + context: . + build-binary: timeout-minutes: 120 needs: - ci-outpost-mark diff --git a/authentik/api/authentication.py b/authentik/api/authentication.py index e6377336d..1e4870828 100644 --- a/authentik/api/authentication.py +++ b/authentik/api/authentication.py @@ -42,7 +42,7 @@ def bearer_auth(raw_header: bytes) -> Optional[User]: def auth_user_lookup(raw_header: bytes) -> Optional[User]: """raw_header in the Format of `Bearer ....`""" - from authentik.providers.oauth2.models import RefreshToken + from authentik.providers.oauth2.models import AccessToken auth_credentials = validate_auth(raw_header) if not auth_credentials: @@ -55,8 +55,8 @@ def auth_user_lookup(raw_header: bytes) -> Optional[User]: CTX_AUTH_VIA.set("api_token") return key_token.user # then try to auth via JWT - jwt_token = RefreshToken.filter_not_expired( - refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API + jwt_token = AccessToken.filter_not_expired( + token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API ).first() if jwt_token: # Double-check scopes, since they are saved in a single string diff --git a/authentik/api/tests/test_auth.py b/authentik/api/tests/test_auth.py index 29d0173df..615e307b4 100644 --- a/authentik/api/tests/test_auth.py +++ b/authentik/api/tests/test_auth.py @@ -1,4 +1,5 @@ """Test API Authentication""" +import json from base64 import b64encode from django.conf import settings @@ -11,7 +12,7 @@ from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.lib.generators import generate_id from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API -from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken +from authentik.providers.oauth2.models import AccessToken, OAuth2Provider class TestAPIAuth(TestCase): @@ -63,24 +64,26 @@ class TestAPIAuth(TestCase): provider = OAuth2Provider.objects.create( name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow() ) - refresh = RefreshToken.objects.create( + refresh = AccessToken.objects.create( user=create_test_admin_user(), provider=provider, - refresh_token=generate_id(), + token=generate_id(), _scope=SCOPE_AUTHENTIK_API, + _id_token=json.dumps({}), ) - self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user) + self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user) def test_jwt_missing_scope(self): """Test valid JWT""" provider = OAuth2Provider.objects.create( name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow() ) - refresh = RefreshToken.objects.create( + refresh = AccessToken.objects.create( user=create_test_admin_user(), provider=provider, - refresh_token=generate_id(), + token=generate_id(), _scope="", + _id_token=json.dumps({}), ) with self.assertRaises(AuthenticationFailed): - self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user) + self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user) diff --git a/authentik/api/v3/urls.py b/authentik/api/v3/urls.py index 9492984fd..61d9eaf27 100644 --- a/authentik/api/v3/urls.py +++ b/authentik/api/v3/urls.py @@ -50,7 +50,11 @@ from authentik.policies.reputation.api import ReputationPolicyViewSet, Reputatio from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet from authentik.providers.oauth2.api.providers import OAuth2ProviderViewSet from authentik.providers.oauth2.api.scopes import ScopeMappingViewSet -from authentik.providers.oauth2.api.tokens import AuthorizationCodeViewSet, RefreshTokenViewSet +from authentik.providers.oauth2.api.tokens import ( + AccessTokenViewSet, + AuthorizationCodeViewSet, + RefreshTokenViewSet, +) from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet from authentik.providers.saml.api.providers import SAMLProviderViewSet @@ -162,6 +166,7 @@ router.register("providers/saml", SAMLProviderViewSet) router.register("oauth2/authorization_codes", AuthorizationCodeViewSet) router.register("oauth2/refresh_tokens", RefreshTokenViewSet) +router.register("oauth2/access_tokens", AccessTokenViewSet) router.register("propertymappings/all", PropertyMappingViewSet) router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) diff --git a/authentik/providers/oauth2/api/providers.py b/authentik/providers/oauth2/api/providers.py index 7eb994c40..bea086e30 100644 --- a/authentik/providers/oauth2/api/providers.py +++ b/authentik/providers/oauth2/api/providers.py @@ -13,7 +13,8 @@ from authentik.core.api.providers import ProviderSerializer from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer from authentik.core.models import Provider -from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken, ScopeMapping +from authentik.providers.oauth2.id_token import IDToken +from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, ScopeMapping class OAuth2ProviderSerializer(ProviderSerializer): @@ -27,7 +28,8 @@ class OAuth2ProviderSerializer(ProviderSerializer): "client_id", "client_secret", "access_code_validity", - "token_validity", + "access_token_validity", + "refresh_token_validity", "include_claims_in_id_token", "signing_key", "redirect_uris", @@ -64,7 +66,8 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet): "client_type", "client_id", "access_code_validity", - "token_validity", + "access_token_validity", + "refresh_token_validity", "include_claims_in_id_token", "signing_key", "redirect_uris", @@ -141,13 +144,17 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet): def preview_user(self, request: Request, pk: int) -> Response: """Preview user data for provider""" provider: OAuth2Provider = self.get_object() - temp_token = RefreshToken() - temp_token.scope = ScopeMapping.objects.filter(provider=provider).values_list( + scope_names = ScopeMapping.objects.filter(provider=provider).values_list( "scope_name", flat=True ) - temp_token.provider = provider - temp_token.user = request.user - serializer = PropertyMappingPreviewSerializer( - instance={"preview": temp_token.create_id_token(request.user, request).to_dict()} + temp_token = IDToken.new( + provider, + AccessToken( + user=request.user, + provider=provider, + _scope=" ".join(scope_names), + ), + request, ) + serializer = PropertyMappingPreviewSerializer(instance={"preview": temp_token.to_dict()}) return Response(serializer.data) diff --git a/authentik/providers/oauth2/api/tokens.py b/authentik/providers/oauth2/api/tokens.py index 9c64a7ea7..86b12cef7 100644 --- a/authentik/providers/oauth2/api/tokens.py +++ b/authentik/providers/oauth2/api/tokens.py @@ -13,7 +13,7 @@ from authentik.core.api.used_by import UsedByMixin from authentik.core.api.users import UserSerializer from authentik.core.api.utils import MetaNameSerializer from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer -from authentik.providers.oauth2.models import AuthorizationCode, RefreshToken +from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken class ExpiringBaseGrantModelSerializer(ModelSerializer, MetaNameSerializer): @@ -29,7 +29,7 @@ class ExpiringBaseGrantModelSerializer(ModelSerializer, MetaNameSerializer): depth = 2 -class RefreshTokenModelSerializer(ExpiringBaseGrantModelSerializer): +class TokenModelSerializer(ExpiringBaseGrantModelSerializer): """Serializer for BaseGrantModel and RefreshToken""" id_token = SerializerMethodField() @@ -89,7 +89,33 @@ class RefreshTokenViewSet( """RefreshToken Viewset""" queryset = RefreshToken.objects.all() - serializer_class = RefreshTokenModelSerializer + serializer_class = TokenModelSerializer + filterset_fields = ["user", "provider"] + ordering = ["provider", "expires"] + filter_backends = [ + DjangoFilterBackend, + OrderingFilter, + SearchFilter, + ] + + def get_queryset(self): + user = self.request.user if self.request else get_anonymous_user() + if user.is_superuser: + return super().get_queryset() + return super().get_queryset().filter(user=user.pk) + + +class AccessTokenViewSet( + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + UsedByMixin, + mixins.ListModelMixin, + GenericViewSet, +): + """AccessToken Viewset""" + + queryset = AccessToken.objects.all() + serializer_class = TokenModelSerializer filterset_fields = ["user", "provider"] ordering = ["provider", "expires"] filter_backends = [ diff --git a/authentik/providers/oauth2/constants.py b/authentik/providers/oauth2/constants.py index 2d88ef8b6..5228b0fed 100644 --- a/authentik/providers/oauth2/constants.py +++ b/authentik/providers/oauth2/constants.py @@ -19,6 +19,8 @@ SCOPE_OPENID = "openid" SCOPE_OPENID_PROFILE = "profile" SCOPE_OPENID_EMAIL = "email" +TOKEN_TYPE = "Bearer" # nosec + SCOPE_AUTHENTIK_API = "goauthentik.io/api" # Read/write full user (including email) diff --git a/authentik/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py index 9d78d290d..53e338256 100644 --- a/authentik/providers/oauth2/errors.py +++ b/authentik/providers/oauth2/errors.py @@ -93,7 +93,7 @@ class TokenIntrospectionError(OAuth2Error): """ Specific to the introspection endpoint. This error will be converted to an "active: false" response, as per the spec. - See https://tools.ietf.org/html/rfc7662 + See https://datatracker.ietf.org/doc/html/rfc7662 """ @@ -102,7 +102,7 @@ class AuthorizeError(OAuth2Error): errors = { # OAuth2 errors. - # https://tools.ietf.org/html/rfc6749#section-4.1.2.1 + # https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 "invalid_request": "The request is otherwise malformed", "unauthorized_client": ( "The client is not authorized to request an authorization code using this method" @@ -185,7 +185,7 @@ class AuthorizeError(OAuth2Error): class TokenError(OAuth2Error): """ OAuth2 token endpoint errors. - https://tools.ietf.org/html/rfc6749#section-5.2 + https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 """ errors = { @@ -220,7 +220,7 @@ class TokenError(OAuth2Error): class TokenRevocationError(OAuth2Error): """ Specific to the revocation endpoint. - See https://tools.ietf.org/html/rfc7662 + See https://datatracker.ietf.org/doc/html/rfc7662 """ errors = TokenError.errors | { @@ -266,7 +266,7 @@ class DeviceCodeError(OAuth2Error): class BearerTokenError(OAuth2Error): """ OAuth2 errors. - https://tools.ietf.org/html/rfc6750#section-3.1 + https://datatracker.ietf.org/doc/html/rfc6750#section-3.1 """ errors = { diff --git a/authentik/providers/oauth2/id_token.py b/authentik/providers/oauth2/id_token.py new file mode 100644 index 000000000..d1b899768 --- /dev/null +++ b/authentik/providers/oauth2/id_token.py @@ -0,0 +1,163 @@ +"""id_token utils""" +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING, Any, Optional + +from django.db import models +from django.http import HttpRequest +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from authentik.events.signals import get_login_event +from authentik.lib.generators import generate_id +from authentik.providers.oauth2.constants import ( + ACR_AUTHENTIK_DEFAULT, + AMR_MFA, + AMR_PASSWORD, + AMR_WEBAUTHN, +) +from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS + +if TYPE_CHECKING: + from authentik.providers.oauth2.models import BaseGrantModel, OAuth2Provider + + +class SubModes(models.TextChoices): + """Mode after which 'sub' attribute is generateed, for compatibility reasons""" + + HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID") + USER_ID = "user_id", _("Based on user ID") + USER_USERNAME = "user_username", _("Based on the username") + USER_EMAIL = ( + "user_email", + _("Based on the User's Email. This is recommended over the UPN method."), + ) + USER_UPN = ( + "user_upn", + _( + "Based on the User's UPN, only works if user has a 'upn' attribute set. " + "Use this method only if you have different UPN and Mail domains." + ), + ) + + +@dataclass +# pylint: disable=too-many-instance-attributes +class IDToken: + """The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be + Authenticated is the ID Token data structure. The ID Token is a security token that contains + Claims about the Authentication of an End-User by an Authorization Server when using a Client, + and potentially other requested Claims. The ID Token is represented as a + JSON Web Token (JWT) [JWT]. + + https://openid.net/specs/openid-connect-core-1_0.html#IDToken""" + + # Issuer, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1 + iss: Optional[str] = None + # Subject, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.2 + sub: Optional[str] = None + # Audience, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3 + aud: Optional[str] = None + # Expiration time, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4 + exp: Optional[int] = None + # Issued at, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6 + iat: Optional[int] = None + # Time when the authentication occurred, + # https://openid.net/specs/openid-connect-core-1_0.html#IDToken + auth_time: Optional[int] = None + # Authentication Context Class Reference, + # https://openid.net/specs/openid-connect-core-1_0.html#IDToken + acr: Optional[str] = ACR_AUTHENTIK_DEFAULT + # Authentication Methods References, + # https://openid.net/specs/openid-connect-core-1_0.html#IDToken + amr: Optional[list[str]] = None + # Code hash value, http://openid.net/specs/openid-connect-core-1_0.html + c_hash: Optional[str] = None + # Value used to associate a Client session with an ID Token, + # http://openid.net/specs/openid-connect-core-1_0.html + nonce: Optional[str] = None + # Access Token hash value, http://openid.net/specs/openid-connect-core-1_0.html + at_hash: Optional[str] = None + + claims: dict[str, Any] = field(default_factory=dict) + + @staticmethod + # pylint: disable=too-many-locals + def new( + provider: "OAuth2Provider", token: "BaseGrantModel", request: HttpRequest, **kwargs + ) -> "IDToken": + """Create ID Token""" + id_token = IDToken(provider, token, **kwargs) + id_token.exp = int(token.expires.timestamp()) + id_token.iss = provider.get_issuer(request) + id_token.aud = provider.client_id + id_token.claims = {} + + if provider.sub_mode == SubModes.HASHED_USER_ID: + id_token.sub = token.user.uid + elif provider.sub_mode == SubModes.USER_ID: + id_token.sub = str(token.user.pk) + elif provider.sub_mode == SubModes.USER_EMAIL: + id_token.sub = token.user.email + elif provider.sub_mode == SubModes.USER_USERNAME: + id_token.sub = token.user.username + elif provider.sub_mode == SubModes.USER_UPN: + id_token.sub = token.user.attributes.get("upn", token.user.uid) + else: + raise ValueError( + f"Provider {provider} has invalid sub_mode selected: {provider.sub_mode}" + ) + + # Convert datetimes into timestamps. + now = timezone.now() + id_token.iat = int(now.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, {}) + amr = [] + if method == "password": + amr.append(AMR_PASSWORD) + if method == "auth_webauthn_pwl": + amr.append(AMR_WEBAUTHN) + if "mfa_devices" in method_args: + if len(amr) > 0: + amr.append(AMR_MFA) + if amr: + id_token.amr = amr + + # Include (or not) user standard claims in the id_token. + if provider.include_claims_in_id_token: + from authentik.providers.oauth2.views.userinfo import UserInfoView + + user_info = UserInfoView() + user_info.request = request + id_token.claims = user_info.get_claims(token.provider, token) + return id_token + + def to_dict(self) -> dict[str, Any]: + """Convert dataclass to dict, and update with keys from `claims`""" + id_dict = asdict(self) + # All items without a value should be removed instead being set to None/null + # https://openid.net/specs/openid-connect-core-1_0.html#JSONSerialization + for key in list(id_dict.keys()): + if id_dict[key] is None: + id_dict.pop(key) + id_dict.pop("claims") + id_dict.update(self.claims) + return id_dict + + def to_access_token(self, provider: "OAuth2Provider") -> str: + """Encode id_token for use as access token, adding fields""" + final = self.to_dict() + final["azp"] = provider.client_id + final["uid"] = generate_id() + return provider.encode(final) + + def to_jwt(self, provider: "OAuth2Provider") -> str: + """Shortcut to encode id_token to jwt, signed by self.provider""" + return provider.encode(self.to_dict()) diff --git a/authentik/providers/oauth2/migrations/0014_alter_refreshtoken_options_and_more.py b/authentik/providers/oauth2/migrations/0014_alter_refreshtoken_options_and_more.py new file mode 100644 index 000000000..9a7966c51 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0014_alter_refreshtoken_options_and_more.py @@ -0,0 +1,117 @@ +# Generated by Django 4.1.6 on 2023-02-09 13:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import authentik.core.models +import authentik.lib.generators +import authentik.lib.utils.time + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("authentik_providers_oauth2", "0013_devicetoken"), + ] + + operations = [ + migrations.AlterModelOptions( + name="refreshtoken", + options={ + "verbose_name": "OAuth2 Refresh Token", + "verbose_name_plural": "OAuth2 Refresh Tokens", + }, + ), + migrations.RenameField( + model_name="oauth2provider", + old_name="token_validity", + new_name="refresh_token_validity", + ), + migrations.RemoveField( + model_name="authorizationcode", + name="is_open_id", + ), + migrations.RemoveField( + model_name="refreshtoken", + name="access_token", + ), + migrations.RemoveField( + model_name="refreshtoken", + name="refresh_token", + ), + migrations.AddField( + model_name="oauth2provider", + name="access_token_validity", + field=models.TextField( + default="hours=1", + help_text="Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + migrations.AddField( + model_name="refreshtoken", + name="token", + field=models.TextField(default=authentik.lib.generators.generate_key), + ), + migrations.AlterField( + model_name="oauth2provider", + name="sub_mode", + field=models.TextField( + choices=[ + ("hashed_user_id", "Based on the Hashed User ID"), + ("user_id", "Based on user ID"), + ("user_username", "Based on the username"), + ( + "user_email", + "Based on the User's Email. This is recommended over the UPN method.", + ), + ( + "user_upn", + "Based on the User's UPN, only works if user has a 'upn' attribute set. Use this method only if you have different UPN and Mail domains.", + ), + ], + default="hashed_user_id", + help_text="Configure what data should be used as unique User Identifier. For most cases, the default should be fine.", + ), + ), + migrations.CreateModel( + name="AccessToken", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "expires", + models.DateTimeField(default=authentik.core.models.default_token_duration), + ), + ("expiring", models.BooleanField(default=True)), + ("revoked", models.BooleanField(default=False)), + ("_scope", models.TextField(default="", verbose_name="Scopes")), + ("token", models.TextField()), + ("_id_token", models.TextField()), + ( + "provider", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_providers_oauth2.oauth2provider", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "OAuth2 Access Token", + "verbose_name_plural": "OAuth2 Access Tokens", + }, + ), + ] diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index fac748f7c..66662c6db 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -2,8 +2,6 @@ import base64 import binascii import json -from dataclasses import asdict, dataclass, field -from datetime import datetime, timedelta from functools import cached_property from hashlib import sha256 from typing import Any, Optional @@ -15,26 +13,18 @@ from cryptography.hazmat.primitives.asymmetric.types import PRIVATE_KEY_TYPES from dacite.core import from_dict from django.db import models from django.http import HttpRequest -from django.utils import timezone +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from jwt import encode from rest_framework.serializers import Serializer from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User from authentik.crypto.models import CertificateKeyPair -from authentik.events.signals import get_login_event from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key from authentik.lib.models import SerializerModel from authentik.lib.utils.time import timedelta_string_validator -from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config -from authentik.providers.oauth2.constants import ( - ACR_AUTHENTIK_DEFAULT, - AMR_MFA, - AMR_PASSWORD, - AMR_WEBAUTHN, -) +from authentik.providers.oauth2.id_token import IDToken, SubModes from authentik.sources.oauth.models import OAuthSource -from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS class ClientTypes(models.TextChoices): @@ -61,25 +51,6 @@ class ResponseMode(models.TextChoices): FORM_POST = "form_post" -class SubModes(models.TextChoices): - """Mode after which 'sub' attribute is generateed, for compatibility reasons""" - - HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID") - USER_ID = "user_id", _("Based on user ID") - USER_USERNAME = "user_username", _("Based on the username") - USER_EMAIL = ( - "user_email", - _("Based on the User's Email. This is recommended over the UPN method."), - ) - USER_UPN = ( - "user_upn", - _( - "Based on the User's UPN, only works if user has a 'upn' attribute set. " - "Use this method only if you have different UPN and Mail domains." - ), - ) - - class IssuerMode(models.TextChoices): """Configure how the `iss` field is created.""" @@ -187,7 +158,15 @@ class OAuth2Provider(Provider): "(Format: hours=1;minutes=2;seconds=3)." ), ) - token_validity = models.TextField( + access_token_validity = models.TextField( + default="hours=1", + validators=[timedelta_string_validator], + help_text=_( + "Tokens not valid on or after current time + this value " + "(Format: hours=1;minutes=2;seconds=3)." + ), + ) + refresh_token_validity = models.TextField( default="days=30", validators=[timedelta_string_validator], help_text=_( @@ -230,24 +209,6 @@ class OAuth2Provider(Provider): blank=True, ) - def create_refresh_token( - self, - user: User, - scope: list[str], - request: HttpRequest, - expiry: timedelta, - ) -> "RefreshToken": - """Create and populate a RefreshToken object.""" - token = RefreshToken( - user=user, - provider=self, - refresh_token=base64.urlsafe_b64encode(generate_key().encode()).decode(), - expires=timezone.now() + expiry, - scope=scope, - ) - token.access_token = token.create_access_token(user, request) - return token - @cached_property def jwt_key(self) -> tuple[str | PRIVATE_KEY_TYPES, str]: """Get either the configured certificate or the client secret""" @@ -267,11 +228,15 @@ class OAuth2Provider(Provider): if self.issuer_mode == IssuerMode.GLOBAL: return request.build_absolute_uri("/") try: - mountpoint = AuthentikProviderOAuth2Config.mountpoints[ - "authentik.providers.oauth2.urls" - ] - # pylint: disable=no-member - return request.build_absolute_uri(f"/{mountpoint}{self.application.slug}/") + url = reverse( + "authentik_providers_oauth2:provider-root", + kwargs={ + # pylint: disable=no-member + "application_slug": self.application.slug, + }, + ) + return request.build_absolute_uri(url) + # pylint: disable=no-member except Provider.application.RelatedObjectDoesNotExist: return None @@ -303,8 +268,6 @@ class OAuth2Provider(Provider): if self.signing_key: headers["kid"] = self.signing_key.kid key, alg = self.jwt_key - # If the provider does not have an RSA Key assigned, it was switched to Symmetric - self.refresh_from_db() return encode(payload, key, algorithm=alg, headers=headers) class Meta: @@ -338,7 +301,6 @@ class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel): code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) nonce = models.TextField(null=True, default=None, verbose_name=_("Nonce")) - is_open_id = models.BooleanField(default=False, verbose_name=_("Is Authentication?")) code_challenge = models.CharField(max_length=255, null=True, verbose_name=_("Code Challenge")) code_challenge_method = models.CharField( max_length=255, null=True, verbose_name=_("Code Challenge Method") @@ -368,88 +330,27 @@ class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel): return f"Authorization code for {self.provider} for user {self.user}" -@dataclass -class IDToken: - """The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be - Authenticated is the ID Token data structure. The ID Token is a security token that contains - Claims about the Authentication of an End-User by an Authorization Server when using a Client, - and potentially other requested Claims. The ID Token is represented as a - JSON Web Token (JWT) [JWT]. +class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel): + """OAuth2 access token, non-opaque using a JWT as identifier""" - https://openid.net/specs/openid-connect-core-1_0.html#IDToken""" - - # All these fields need to optional so we can save an empty IDToken for non-OpenID flows. - iss: Optional[str] = None - sub: Optional[str] = None - aud: Optional[str] = None - exp: Optional[int] = None - iat: Optional[int] = None - auth_time: Optional[int] = None - acr: Optional[str] = ACR_AUTHENTIK_DEFAULT - amr: Optional[list[str]] = None - - c_hash: Optional[str] = None - nonce: Optional[str] = None - at_hash: Optional[str] = None - - claims: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict[str, Any]: - """Convert dataclass to dict, and update with keys from `claims`""" - id_dict = asdict(self) - # The following claims should be omitted if they aren't set instead of being - # set to null - if not self.at_hash: - id_dict.pop("at_hash") - if not self.nonce: - id_dict.pop("nonce") - if not self.c_hash: - id_dict.pop("c_hash") - if not self.amr: - id_dict.pop("amr") - if not self.auth_time: - id_dict.pop("auth_time") - id_dict.pop("claims") - id_dict.update(self.claims) - return id_dict - - -class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): - """OAuth2 Refresh Token""" - - access_token = models.TextField(verbose_name=_("Access Token")) - refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_("Refresh Token")) - _id_token = models.TextField(verbose_name=_("ID Token")) - - @property - def serializer(self) -> Serializer: - from authentik.providers.oauth2.api.tokens import ExpiringBaseGrantModelSerializer - - return ExpiringBaseGrantModelSerializer - - class Meta: - verbose_name = _("OAuth2 Token") - verbose_name_plural = _("OAuth2 Tokens") + token = models.TextField() + _id_token = models.TextField() @property def id_token(self) -> IDToken: """Load ID Token from json""" - if self._id_token: - raw_token = json.loads(self._id_token) - return from_dict(IDToken, raw_token) - return IDToken() + raw_token = json.loads(self._id_token) + return from_dict(IDToken, raw_token) @id_token.setter def id_token(self, value: IDToken): - self._id_token = json.dumps(asdict(value)) - - def __str__(self): - return f"Refresh Token for {self.provider} for user {self.user}" + self.token = value.to_access_token(self.provider) + self._id_token = json.dumps(value.to_dict()) @property def at_hash(self): """Get hashed access_token""" - hashed_access_token = sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii") + hashed_access_token = sha256(self.token.encode("ascii")).hexdigest().encode("ascii") return ( base64.urlsafe_b64encode( binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2]) @@ -458,78 +359,52 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): .decode("ascii") ) - def create_access_token(self, user: User, request: HttpRequest) -> str: - """Create access token with a similar format as Okta, Keycloak, ADFS""" - token = self.create_id_token(user, request).to_dict() - token["cid"] = self.provider.client_id - token["uid"] = generate_key() - return self.provider.encode(token) + @property + def serializer(self) -> Serializer: + from authentik.providers.oauth2.api.tokens import TokenModelSerializer - # pylint: disable=too-many-locals - def create_id_token(self, user: User, request: HttpRequest) -> IDToken: - """Creates the id_token. - See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken""" - sub = "" - if self.provider.sub_mode == SubModes.HASHED_USER_ID: - sub = user.uid - elif self.provider.sub_mode == SubModes.USER_ID: - sub = str(user.pk) - elif self.provider.sub_mode == SubModes.USER_EMAIL: - sub = user.email - elif self.provider.sub_mode == SubModes.USER_USERNAME: - sub = user.username - elif self.provider.sub_mode == SubModes.USER_UPN: - sub = user.attributes.get("upn", user.uid) - else: - raise ValueError( - f"Provider {self.provider} has invalid sub_mode selected: {self.provider.sub_mode}" - ) - # Convert datetimes into timestamps. - now = datetime.now() - iat_time = int(now.timestamp()) - exp_time = int(self.expires.timestamp()) + return TokenModelSerializer - token = IDToken( - iss=self.provider.get_issuer(request), - sub=sub, - aud=self.provider.client_id, - exp=exp_time, - iat=iat_time, - ) + class Meta: + verbose_name = _("OAuth2 Access Token") + verbose_name_plural = _("OAuth2 Access Tokens") - # 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 - 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, {}) - amr = [] - if method == "password": - amr.append(AMR_PASSWORD) - if method == "auth_webauthn_pwl": - amr.append(AMR_WEBAUTHN) - if "mfa_devices" in method_args: - if len(amr) > 0: - amr.append(AMR_MFA) - if amr: - token.amr = amr + def __str__(self): + return f"Access Token for {self.provider} for user {self.user}" - # Include (or not) user standard claims in the id_token. - if self.provider.include_claims_in_id_token: - from authentik.providers.oauth2.views.userinfo import UserInfoView - user_info = UserInfoView() - user_info.request = request - claims = user_info.get_claims(self) - token.claims = claims +class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): + """OAuth2 Refresh Token, opaque""" - return token + token = models.TextField(default=generate_key) + _id_token = models.TextField(verbose_name=_("ID Token")) + + @property + def id_token(self) -> IDToken: + """Load ID Token from json""" + raw_token = json.loads(self._id_token) + return from_dict(IDToken, raw_token) + + @id_token.setter + def id_token(self, value: IDToken): + self._id_token = json.dumps(value.to_dict()) + + @property + def serializer(self) -> Serializer: + from authentik.providers.oauth2.api.tokens import TokenModelSerializer + + return TokenModelSerializer + + class Meta: + verbose_name = _("OAuth2 Refresh Token") + verbose_name_plural = _("OAuth2 Refresh Tokens") + + def __str__(self): + return f"Refresh Token for {self.provider} for user {self.user}" class DeviceToken(ExpiringModel): - """Device token for OAuth device flow""" + """Temporary device token for OAuth device flow""" user = models.ForeignKey( "authentik_core.User", default=None, on_delete=models.CASCADE, null=True diff --git a/authentik/providers/oauth2/tests/test_authorize.py b/authentik/providers/oauth2/tests/test_authorize.py index 89c3f93ea..69135968a 100644 --- a/authentik/providers/oauth2/tests/test_authorize.py +++ b/authentik/providers/oauth2/tests/test_authorize.py @@ -11,12 +11,13 @@ from authentik.events.models import Event, EventAction from authentik.flows.challenge import ChallengeTypes from authentik.lib.generators import generate_id, generate_key from authentik.lib.utils.time import timedelta_from_string +from authentik.providers.oauth2.constants import TOKEN_TYPE from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError from authentik.providers.oauth2.models import ( + AccessToken, AuthorizationCode, GrantTypes, OAuth2Provider, - RefreshToken, ) from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams @@ -293,21 +294,20 @@ class TestAuthorize(OAuthTestCase): def test_full_implicit(self): """Test full authorization""" flow = create_test_flow() - provider = OAuth2Provider.objects.create( + provider: OAuth2Provider = OAuth2Provider.objects.create( name=generate_id(), client_id="test", client_secret=generate_key(), authorization_flow=flow, redirect_uris="http://localhost", signing_key=self.keypair, - access_code_validity="seconds=100", ) Application.objects.create(name="app", slug="app", provider=provider) state = generate_id() user = create_test_admin_user() self.client.force_login(user) with patch( - "authentik.providers.oauth2.models.get_login_event", + "authentik.providers.oauth2.id_token.get_login_event", MagicMock( return_value=Event( action=EventAction.LOGIN, @@ -330,16 +330,17 @@ class TestAuthorize(OAuthTestCase): response = self.client.get( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), ) - token: RefreshToken = RefreshToken.objects.filter(user=user).first() - expires = timedelta_from_string(provider.access_code_validity).total_seconds() + token: AccessToken = AccessToken.objects.filter(user=user).first() + expires = timedelta_from_string(provider.access_token_validity).total_seconds() self.assertJSONEqual( response.content.decode(), { "component": "xak-flow-redirect", "type": ChallengeTypes.REDIRECT.value, "to": ( - f"http://localhost#access_token={token.access_token}" - f"&id_token={provider.encode(token.id_token.to_dict())}&token_type=bearer" + f"http://localhost#access_token={token.token}" + f"&id_token={provider.encode(token.id_token.to_dict())}" + f"&token_type={TOKEN_TYPE}" f"&expires_in={int(expires)}&state={state}" ), }, @@ -382,7 +383,7 @@ class TestAuthorize(OAuthTestCase): response = self.client.get( reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), ) - token: RefreshToken = RefreshToken.objects.filter(user=user).first() + token: AccessToken = AccessToken.objects.filter(user=user).first() self.assertJSONEqual( response.content.decode(), { @@ -391,10 +392,10 @@ class TestAuthorize(OAuthTestCase): "url": "http://localhost", "title": f"Redirecting to {app.name}...", "attrs": { - "access_token": token.access_token, + "access_token": token.token, "id_token": provider.encode(token.id_token.to_dict()), - "token_type": "bearer", - "expires_in": "60", + "token_type": TOKEN_TYPE, + "expires_in": "3600", "state": state, }, }, diff --git a/authentik/providers/oauth2/tests/test_introspect.py b/authentik/providers/oauth2/tests/test_introspect.py index 94209e5d6..d35c5fba2 100644 --- a/authentik/providers/oauth2/tests/test_introspect.py +++ b/authentik/providers/oauth2/tests/test_introspect.py @@ -9,7 +9,7 @@ from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.lib.generators import generate_id, generate_key from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT -from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken +from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken from authentik.providers.oauth2.tests.utils import OAuthTestCase @@ -31,11 +31,16 @@ class TesOAuth2Introspection(OAuthTestCase): ) self.app.save() self.user = create_test_admin_user() - self.token: RefreshToken = RefreshToken.objects.create( + self.auth = b64encode( + f"{self.provider.client_id}:{self.provider.client_secret}".encode() + ).decode() + + def test_introspect_refresh(self): + """Test introspect""" + token: RefreshToken = RefreshToken.objects.create( provider=self.provider, user=self.user, - access_token=generate_id(), - refresh_token=generate_id(), + token=generate_id(), _scope="openid user profile", _id_token=json.dumps( asdict( @@ -43,30 +48,52 @@ class TesOAuth2Introspection(OAuthTestCase): ) ), ) - self.auth = b64encode( - f"{self.provider.client_id}:{self.provider.client_secret}".encode() - ).decode() - - def test_introspect(self): - """Test introspect""" res = self.client.post( reverse("authentik_providers_oauth2:token-introspection"), HTTP_AUTHORIZATION=f"Basic {self.auth}", - data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"}, + data={"token": token.token}, ) self.assertEqual(res.status_code, 200) self.assertJSONEqual( res.content.decode(), { "acr": ACR_AUTHENTIK_DEFAULT, - "aud": None, "sub": "bar", - "exp": None, - "iat": None, "iss": "foo", "active": True, "client_id": self.provider.client_id, - "scope": " ".join(self.token.scope), + "scope": " ".join(token.scope), + }, + ) + + def test_introspect_access(self): + """Test introspect""" + token: AccessToken = AccessToken.objects.create( + provider=self.provider, + user=self.user, + token=generate_id(), + _scope="openid user profile", + _id_token=json.dumps( + asdict( + IDToken("foo", "bar"), + ) + ), + ) + res = self.client.post( + reverse("authentik_providers_oauth2:token-introspection"), + HTTP_AUTHORIZATION=f"Basic {self.auth}", + data={"token": token.token}, + ) + self.assertEqual(res.status_code, 200) + self.assertJSONEqual( + res.content.decode(), + { + "acr": ACR_AUTHENTIK_DEFAULT, + "sub": "bar", + "iss": "foo", + "active": True, + "client_id": self.provider.client_id, + "scope": " ".join(token.scope), }, ) diff --git a/authentik/providers/oauth2/tests/test_revoke.py b/authentik/providers/oauth2/tests/test_revoke.py index 0e474d8dc..e956699c4 100644 --- a/authentik/providers/oauth2/tests/test_revoke.py +++ b/authentik/providers/oauth2/tests/test_revoke.py @@ -8,7 +8,7 @@ from django.urls import reverse from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.lib.generators import generate_id, generate_key -from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken +from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken from authentik.providers.oauth2.tests.utils import OAuthTestCase @@ -30,11 +30,16 @@ class TesOAuth2Revoke(OAuthTestCase): ) self.app.save() self.user = create_test_admin_user() - self.token: RefreshToken = RefreshToken.objects.create( + self.auth = b64encode( + f"{self.provider.client_id}:{self.provider.client_secret}".encode() + ).decode() + + def test_revoke_refresh(self): + """Test revoke""" + token: RefreshToken = RefreshToken.objects.create( provider=self.provider, user=self.user, - access_token=generate_id(), - refresh_token=generate_id(), + token=generate_id(), _scope="openid user profile", _id_token=json.dumps( asdict( @@ -42,16 +47,34 @@ class TesOAuth2Revoke(OAuthTestCase): ) ), ) - self.auth = b64encode( - f"{self.provider.client_id}:{self.provider.client_secret}".encode() - ).decode() - - def test_revoke(self): - """Test revoke""" res = self.client.post( reverse("authentik_providers_oauth2:token-revoke"), HTTP_AUTHORIZATION=f"Basic {self.auth}", - data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"}, + data={ + "token": token.token, + }, + ) + self.assertEqual(res.status_code, 200) + + def test_revoke_access(self): + """Test revoke""" + token: AccessToken = AccessToken.objects.create( + provider=self.provider, + user=self.user, + token=generate_id(), + _scope="openid user profile", + _id_token=json.dumps( + asdict( + IDToken("foo", "bar"), + ) + ), + ) + res = self.client.post( + reverse("authentik_providers_oauth2:token-revoke"), + HTTP_AUTHORIZATION=f"Basic {self.auth}", + data={ + "token": token.token, + }, ) self.assertEqual(res.status_code, 200) @@ -60,7 +83,9 @@ class TesOAuth2Revoke(OAuthTestCase): res = self.client.post( reverse("authentik_providers_oauth2:token-revoke"), HTTP_AUTHORIZATION=f"Basic {self.auth}", - data={"token": self.token.refresh_token + "foo", "token_type_hint": "refresh_token"}, + data={ + "token": generate_id(), + }, ) self.assertEqual(res.status_code, 200) @@ -69,6 +94,8 @@ class TesOAuth2Revoke(OAuthTestCase): res = self.client.post( reverse("authentik_providers_oauth2:token-revoke"), HTTP_AUTHORIZATION="Basic fqewr", - data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"}, + data={ + "token": generate_id(), + }, ) self.assertEqual(res.status_code, 401) diff --git a/authentik/providers/oauth2/tests/test_token.py b/authentik/providers/oauth2/tests/test_token.py index 203fd7686..6a41c7738 100644 --- a/authentik/providers/oauth2/tests/test_token.py +++ b/authentik/providers/oauth2/tests/test_token.py @@ -1,5 +1,6 @@ """Test token view""" from base64 import b64encode +from json import dumps from django.test import RequestFactory from django.urls import reverse @@ -11,9 +12,15 @@ from authentik.lib.generators import generate_id, generate_key from authentik.providers.oauth2.constants import ( GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN, + TOKEN_TYPE, ) from authentik.providers.oauth2.errors import TokenError -from authentik.providers.oauth2.models import AuthorizationCode, OAuth2Provider, RefreshToken +from authentik.providers.oauth2.models import ( + AccessToken, + AuthorizationCode, + OAuth2Provider, + RefreshToken, +) from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.views.token import TokenParams @@ -91,13 +98,13 @@ class TestToken(OAuthTestCase): token: RefreshToken = RefreshToken.objects.create( provider=provider, user=user, - refresh_token=generate_id(), + token=generate_id(), ) request = self.factory.post( "/", data={ "grant_type": GRANT_TYPE_REFRESH_TOKEN, - "refresh_token": token.refresh_token, + "refresh_token": token.token, "redirect_uri": "http://local.invalid", }, HTTP_AUTHORIZATION=f"Basic {header}", @@ -120,9 +127,7 @@ 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, is_open_id=True - ) + code = AuthorizationCode.objects.create(code="foobar", provider=provider, user=user) response = self.client.post( reverse("authentik_providers_oauth2:token"), data={ @@ -132,20 +137,21 @@ class TestToken(OAuthTestCase): }, HTTP_AUTHORIZATION=f"Basic {header}", ) - new_token: RefreshToken = RefreshToken.objects.filter(user=user).first() + access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first() + refresh: RefreshToken = RefreshToken.objects.filter(user=user, provider=provider).first() self.assertJSONEqual( response.content.decode(), { - "access_token": new_token.access_token, - "refresh_token": new_token.refresh_token, - "token_type": "bearer", - "expires_in": 2592000, + "access_token": access.token, + "refresh_token": refresh.token, + "token_type": TOKEN_TYPE, + "expires_in": 3600, "id_token": provider.encode( - new_token.id_token.to_dict(), + refresh.id_token.to_dict(), ), }, ) - self.validate_jwt(new_token, provider) + self.validate_jwt(access, provider) def test_refresh_token_view(self): """test request param""" @@ -165,36 +171,38 @@ class TestToken(OAuthTestCase): token: RefreshToken = RefreshToken.objects.create( provider=provider, user=user, - refresh_token=generate_id(), + token=generate_id(), + _id_token=dumps({}), ) response = self.client.post( reverse("authentik_providers_oauth2:token"), data={ "grant_type": GRANT_TYPE_REFRESH_TOKEN, - "refresh_token": token.refresh_token, + "refresh_token": token.token, "redirect_uri": "http://local.invalid", }, HTTP_AUTHORIZATION=f"Basic {header}", HTTP_ORIGIN="http://local.invalid", ) - new_token: RefreshToken = ( - RefreshToken.objects.filter(user=user).exclude(pk=token.pk).first() - ) self.assertEqual(response["Access-Control-Allow-Credentials"], "true") self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid") + access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first() + refresh: RefreshToken = RefreshToken.objects.filter( + user=user, provider=provider, revoked=False + ).first() self.assertJSONEqual( response.content.decode(), { - "access_token": new_token.access_token, - "refresh_token": new_token.refresh_token, - "token_type": "bearer", - "expires_in": 2592000, + "access_token": access.token, + "refresh_token": refresh.token, + "token_type": TOKEN_TYPE, + "expires_in": 3600, "id_token": provider.encode( - new_token.id_token.to_dict(), + refresh.id_token.to_dict(), ), }, ) - self.validate_jwt(new_token, provider) + self.validate_jwt(access, provider) def test_refresh_token_view_invalid_origin(self): """test request param""" @@ -211,32 +219,34 @@ class TestToken(OAuthTestCase): token: RefreshToken = RefreshToken.objects.create( provider=provider, user=user, - refresh_token=generate_id(), + token=generate_id(), + _id_token=dumps({}), ) response = self.client.post( reverse("authentik_providers_oauth2:token"), data={ "grant_type": GRANT_TYPE_REFRESH_TOKEN, - "refresh_token": token.refresh_token, + "refresh_token": token.token, "redirect_uri": "http://local.invalid", }, HTTP_AUTHORIZATION=f"Basic {header}", HTTP_ORIGIN="http://another.invalid", ) - new_token: RefreshToken = ( - RefreshToken.objects.filter(user=user).exclude(pk=token.pk).first() - ) + access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first() + refresh: RefreshToken = RefreshToken.objects.filter( + user=user, provider=provider, revoked=False + ).first() self.assertNotIn("Access-Control-Allow-Credentials", response) self.assertNotIn("Access-Control-Allow-Origin", response) self.assertJSONEqual( response.content.decode(), { - "access_token": new_token.access_token, - "refresh_token": new_token.refresh_token, - "token_type": "bearer", - "expires_in": 2592000, + "access_token": access.token, + "refresh_token": refresh.token, + "token_type": TOKEN_TYPE, + "expires_in": 3600, "id_token": provider.encode( - new_token.id_token.to_dict(), + refresh.id_token.to_dict(), ), }, ) @@ -259,14 +269,15 @@ class TestToken(OAuthTestCase): token: RefreshToken = RefreshToken.objects.create( provider=provider, user=user, - refresh_token=generate_id(), + token=generate_id(), + _id_token=dumps({}), ) # Create initial refresh token response = self.client.post( reverse("authentik_providers_oauth2:token"), data={ "grant_type": GRANT_TYPE_REFRESH_TOKEN, - "refresh_token": token.refresh_token, + "refresh_token": token.token, "redirect_uri": "http://testserver", }, HTTP_AUTHORIZATION=f"Basic {header}", @@ -280,7 +291,7 @@ class TestToken(OAuthTestCase): reverse("authentik_providers_oauth2:token"), data={ "grant_type": GRANT_TYPE_REFRESH_TOKEN, - "refresh_token": new_token.refresh_token, + "refresh_token": new_token.token, "redirect_uri": "http://local.invalid", }, HTTP_AUTHORIZATION=f"Basic {header}", @@ -291,7 +302,7 @@ class TestToken(OAuthTestCase): reverse("authentik_providers_oauth2:token"), data={ "grant_type": GRANT_TYPE_REFRESH_TOKEN, - "refresh_token": new_token.refresh_token, + "refresh_token": new_token.token, "redirect_uri": "http://local.invalid", }, HTTP_AUTHORIZATION=f"Basic {header}", diff --git a/authentik/providers/oauth2/tests/test_token_cc.py b/authentik/providers/oauth2/tests/test_token_cc.py index 6f9f449b4..a578c3be9 100644 --- a/authentik/providers/oauth2/tests/test_token_cc.py +++ b/authentik/providers/oauth2/tests/test_token_cc.py @@ -15,6 +15,7 @@ from authentik.providers.oauth2.constants import ( SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, + TOKEN_TYPE, ) from authentik.providers.oauth2.errors import TokenError from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping @@ -142,7 +143,7 @@ class TestTokenClientCredentials(OAuthTestCase): ) self.assertEqual(response.status_code, 200) body = loads(response.content.decode()) - self.assertEqual(body["token_type"], "bearer") + self.assertEqual(body["token_type"], TOKEN_TYPE) _, alg = self.provider.jwt_key jwt = decode( body["access_token"], diff --git a/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py b/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py index 3c69a6054..3daf55e6e 100644 --- a/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py +++ b/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py @@ -16,6 +16,7 @@ from authentik.providers.oauth2.constants import ( SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, + TOKEN_TYPE, ) from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping from authentik.providers.oauth2.tests.utils import OAuthTestCase @@ -209,7 +210,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): ) self.assertEqual(response.status_code, 200) body = loads(response.content.decode()) - self.assertEqual(body["token_type"], "bearer") + self.assertEqual(body["token_type"], TOKEN_TYPE) _, alg = self.provider.jwt_key jwt = decode( body["access_token"], diff --git a/authentik/providers/oauth2/tests/test_userinfo.py b/authentik/providers/oauth2/tests/test_userinfo.py index 007db5c40..b0be06339 100644 --- a/authentik/providers/oauth2/tests/test_userinfo.py +++ b/authentik/providers/oauth2/tests/test_userinfo.py @@ -9,7 +9,7 @@ from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.events.models import Event, EventAction from authentik.lib.generators import generate_id, generate_key -from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken, ScopeMapping +from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, ScopeMapping from authentik.providers.oauth2.tests.utils import OAuthTestCase @@ -33,11 +33,10 @@ class TestUserinfo(OAuthTestCase): self.app.provider = self.provider self.app.save() self.user = create_test_admin_user() - self.token: RefreshToken = RefreshToken.objects.create( + self.token: AccessToken = AccessToken.objects.create( provider=self.provider, user=self.user, - access_token=generate_id(), - refresh_token=generate_id(), + token=generate_id(), _scope="openid user profile", _id_token=json.dumps( asdict( @@ -50,7 +49,7 @@ class TestUserinfo(OAuthTestCase): """test user info with all normal scopes""" res = self.client.get( reverse("authentik_providers_oauth2:userinfo"), - HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", + HTTP_AUTHORIZATION=f"Bearer {self.token.token}", ) self.assertJSONEqual( res.content.decode(), @@ -73,7 +72,7 @@ class TestUserinfo(OAuthTestCase): res = self.client.get( reverse("authentik_providers_oauth2:userinfo"), - HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", + HTTP_AUTHORIZATION=f"Bearer {self.token.token}", ) self.assertJSONEqual( res.content.decode(), diff --git a/authentik/providers/oauth2/tests/utils.py b/authentik/providers/oauth2/tests/utils.py index 8df5c1ed4..18c1809f2 100644 --- a/authentik/providers/oauth2/tests/utils.py +++ b/authentik/providers/oauth2/tests/utils.py @@ -6,7 +6,7 @@ from jwt import decode from authentik.core.tests.utils import create_test_cert from authentik.crypto.models import CertificateKeyPair -from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider, RefreshToken +from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider class OAuthTestCase(TestCase): @@ -25,19 +25,20 @@ class OAuthTestCase(TestCase): def setUpClass(cls) -> None: cls.keypair = create_test_cert() super().setUpClass() + cls.maxDiff = None def assert_non_none_or_unset(self, container: dict, key: str): """Check that a key, if set, is not none""" if key in container: self.assertIsNotNone(container[key]) - def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider) -> dict[str, Any]: + def validate_jwt(self, token: AccessToken, provider: OAuth2Provider) -> dict[str, Any]: """Validate that all required fields are set""" key, alg = provider.jwt_key if alg != JWTAlgorithms.HS256: key = provider.signing_key.public_key jwt = decode( - token.access_token, + token.token, key, algorithms=[alg], audience=provider.client_id, diff --git a/authentik/providers/oauth2/urls.py b/authentik/providers/oauth2/urls.py index c25fcfc14..8c5b2290f 100644 --- a/authentik/providers/oauth2/urls.py +++ b/authentik/providers/oauth2/urls.py @@ -40,6 +40,11 @@ urlpatterns = [ name="end-session", ), path("/jwks/", JWKSView.as_view(), name="jwks"), + path( + "/", + RedirectView.as_view(pattern_name="authentk_providers_oauth2:provider-info"), + name="provider-root", + ), path( "/.well-known/openid-configuration", ProviderInfoView.as_view(), diff --git a/authentik/providers/oauth2/utils.py b/authentik/providers/oauth2/utils.py index b7975ba7e..21e72aa29 100644 --- a/authentik/providers/oauth2/utils.py +++ b/authentik/providers/oauth2/utils.py @@ -13,7 +13,7 @@ from structlog.stdlib import get_logger from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER from authentik.events.models import Event, EventAction from authentik.providers.oauth2.errors import BearerTokenError -from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken +from authentik.providers.oauth2.models import AccessToken, OAuth2Provider LOGGER = get_logger() @@ -119,7 +119,7 @@ def protected_resource_view(scopes: list[str]): """View decorator. The client accesses protected resources by presenting the access token to the resource server. - https://tools.ietf.org/html/rfc6749#section-7 + https://datatracker.ietf.org/doc/html/rfc6749#section-7 This decorator also injects the token into `kwargs`""" @@ -133,9 +133,8 @@ def protected_resource_view(scopes: list[str]): LOGGER.debug("No token passed") raise BearerTokenError("invalid_token") - try: - token: RefreshToken = RefreshToken.objects.get(access_token=access_token) - except RefreshToken.DoesNotExist: + token = AccessToken.objects.filter(token=access_token).first() + if not token: LOGGER.debug("Token does not exist", access_token=access_token) raise BearerTokenError("invalid_token") diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index 60a17a5d2..98524aeff 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -1,6 +1,7 @@ """authentik OAuth2 Authorization views""" from dataclasses import dataclass, field from datetime import timedelta +from json import dumps from re import error as RegexError from re import fullmatch from typing import Optional @@ -37,6 +38,7 @@ from authentik.providers.oauth2.constants import ( PROMPT_LOGIN, PROMPT_NONE, SCOPE_OPENID, + TOKEN_TYPE, ) from authentik.providers.oauth2.errors import ( AuthorizeError, @@ -44,7 +46,9 @@ from authentik.providers.oauth2.errors import ( OAuth2Error, RedirectUriError, ) +from authentik.providers.oauth2.id_token import IDToken from authentik.providers.oauth2.models import ( + AccessToken, AuthorizationCode, GrantTypes, OAuth2Provider, @@ -264,8 +268,6 @@ class OAuthAuthorizationParams: code.expires = timezone.now() + timedelta_from_string(self.provider.access_code_validity) code.scope = self.scope code.nonce = self.nonce - code.is_open_id = SCOPE_OPENID in self.scope - return code @@ -517,13 +519,25 @@ class OAuthFulfillmentStage(StageView): """Create implicit response's URL Fragment dictionary""" query_fragment = {} - token = self.provider.create_refresh_token( + now = timezone.now() + access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) + token = AccessToken( user=self.request.user, scope=self.params.scope, - request=self.request, - expiry=timedelta_from_string(self.provider.access_code_validity), + expires=access_token_expiry, + provider=self.provider, ) + id_token = IDToken.new(self.provider, token, self.request) + id_token.nonce = self.params.nonce + + if self.params.response_type in [ + ResponseTypes.CODE_ID_TOKEN, + ResponseTypes.CODE_ID_TOKEN_TOKEN, + ]: + id_token.c_hash = code.c_hash + token.id_token = id_token + # Check if response_type must include access_token in the response. if self.params.response_type in [ ResponseTypes.ID_TOKEN_TOKEN, @@ -531,47 +545,29 @@ class OAuthFulfillmentStage(StageView): ResponseTypes.ID_TOKEN, ResponseTypes.CODE_TOKEN, ]: - query_fragment["access_token"] = token.access_token + query_fragment["access_token"] = token.token - # We don't need id_token if it's an OAuth2 request. - if SCOPE_OPENID in self.params.scope: - id_token = token.create_id_token( - user=self.request.user, - request=self.request, - ) - id_token.nonce = self.params.nonce + # Check if response_type must include id_token in the response. + if self.params.response_type in [ + ResponseTypes.ID_TOKEN, + ResponseTypes.ID_TOKEN_TOKEN, + 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()) - # Include at_hash when access_token is being returned. - if "access_token" in query_fragment: - id_token.at_hash = token.at_hash - - if self.params.response_type in [ - ResponseTypes.CODE_ID_TOKEN, - ResponseTypes.CODE_ID_TOKEN_TOKEN, - ]: - id_token.c_hash = code.c_hash - - # Check if response_type must include id_token in the response. - if self.params.response_type in [ - ResponseTypes.ID_TOKEN, - ResponseTypes.ID_TOKEN_TOKEN, - ResponseTypes.CODE_ID_TOKEN, - ResponseTypes.CODE_ID_TOKEN_TOKEN, - ]: - query_fragment["id_token"] = self.provider.encode(id_token.to_dict()) - token.id_token = id_token - - # Store the token. token.save() # Code parameter must be present if it's Hybrid Flow. if self.params.grant_type == GrantTypes.HYBRID: query_fragment["code"] = code.code - query_fragment["token_type"] = "bearer" # nosec + query_fragment["token_type"] = TOKEN_TYPE query_fragment["expires_in"] = int( - timedelta_from_string(self.provider.access_code_validity).total_seconds() + timedelta_from_string(self.provider.access_token_validity).total_seconds() ) query_fragment["state"] = self.params.state if self.params.state else "" - return query_fragment diff --git a/authentik/providers/oauth2/views/introspection.py b/authentik/providers/oauth2/views/introspection.py index d1a93a1f1..6a24d9214 100644 --- a/authentik/providers/oauth2/views/introspection.py +++ b/authentik/providers/oauth2/views/introspection.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt from structlog.stdlib import get_logger from authentik.providers.oauth2.errors import TokenIntrospectionError -from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken +from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider LOGGER = get_logger() @@ -18,7 +18,7 @@ LOGGER = get_logger() class TokenIntrospectionParams: """Parameters for Token Introspection""" - token: RefreshToken + token: RefreshToken | AccessToken provider: OAuth2Provider id_token: IDToken = field(init=False) @@ -41,31 +41,26 @@ class TokenIntrospectionParams: def from_request(request: HttpRequest) -> "TokenIntrospectionParams": """Extract required Parameters from HTTP Request""" raw_token = request.POST.get("token") - token_type_hint = request.POST.get("token_type_hint", "access_token") - token_filter = {token_type_hint: raw_token} - - if token_type_hint not in ["access_token", "refresh_token"]: - LOGGER.debug("token_type_hint has invalid value", value=token_type_hint) - raise TokenIntrospectionError() - provider = authenticate_provider(request) if not provider: raise TokenIntrospectionError - token: RefreshToken = RefreshToken.objects.filter(provider=provider, **token_filter).first() - if not token: - LOGGER.debug("Token does not exist", token=raw_token) - raise TokenIntrospectionError() - - return TokenIntrospectionParams(token=token, provider=provider) + access_token = AccessToken.objects.filter(token=raw_token).first() + if access_token: + return TokenIntrospectionParams(access_token, provider) + refresh_token = RefreshToken.objects.filter(token=raw_token).first() + if refresh_token: + return TokenIntrospectionParams(refresh_token, provider) + LOGGER.debug("Token does not exist", token=raw_token) + raise TokenIntrospectionError() @method_decorator(csrf_exempt, name="dispatch") class TokenIntrospectionView(View): """Token Introspection - https://tools.ietf.org/html/rfc7662""" + https://datatracker.ietf.org/doc/html/rfc7662""" - token: RefreshToken + token: RefreshToken | AccessToken params: TokenIntrospectionParams provider: OAuth2Provider @@ -76,9 +71,9 @@ class TokenIntrospectionView(View): response = {} if self.params.id_token: response.update(self.params.id_token.to_dict()) - response["active"] = True + response["active"] = not self.params.token.is_expired and not self.params.token.revoked response["scope"] = " ".join(self.params.token.scope) - response["client_id"] = self.params.token.provider.client_id + response["client_id"] = self.params.provider.client_id return TokenResponse(response) except TokenIntrospectionError: return TokenResponse({"active": False}) diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index aa019510a..360ab2243 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -1,14 +1,15 @@ """authentik OAuth2 Token views""" from base64 import urlsafe_b64encode from dataclasses import InitVar, dataclass +from datetime import datetime from hashlib import sha256 from re import error as RegexError from re import fullmatch from typing import Any, Optional from django.http import HttpRequest, HttpResponse +from django.utils import timezone from django.utils.decorators import method_decorator -from django.utils.timezone import datetime, now from django.views import View from django.views.decorators.csrf import csrf_exempt from guardian.shortcuts import get_anonymous_user @@ -37,9 +38,12 @@ from authentik.providers.oauth2.constants import ( GRANT_TYPE_DEVICE_CODE, GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN, + TOKEN_TYPE, ) from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError +from authentik.providers.oauth2.id_token import IDToken from authentik.providers.oauth2.models import ( + AccessToken, AuthorizationCode, ClientTypes, DeviceToken, @@ -198,18 +202,18 @@ class TokenParams: ).from_http(request) raise TokenError("invalid_client") - try: - self.authorization_code = AuthorizationCode.objects.get(code=raw_code) - if self.authorization_code.is_expired: - LOGGER.warning( - "Code is expired", - token=raw_code, - ) - raise TokenError("invalid_grant") - except AuthorizationCode.DoesNotExist: + self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first() + if not self.authorization_code: LOGGER.warning("Code does not exist", code=raw_code) raise TokenError("invalid_grant") + if self.authorization_code.is_expired: + LOGGER.warning( + "Code is expired", + token=raw_code, + ) + raise TokenError("invalid_grant") + if self.authorization_code.provider != self.provider or self.authorization_code.is_expired: LOGGER.warning("Invalid code: invalid client or code has expired") raise TokenError("invalid_grant") @@ -234,26 +238,25 @@ class TokenParams: LOGGER.warning("Missing refresh token") raise TokenError("invalid_grant") - try: - self.refresh_token = RefreshToken.objects.get( - refresh_token=raw_token, provider=self.provider - ) - if self.refresh_token.is_expired: - LOGGER.warning( - "Refresh token is expired", - token=raw_token, - ) - raise TokenError("invalid_grant") - # https://tools.ietf.org/html/rfc6749#section-6 - # Fallback to original token's scopes when none are given - if not self.scope: - self.scope = self.refresh_token.scope - except RefreshToken.DoesNotExist: + self.refresh_token = RefreshToken.objects.filter( + token=raw_token, provider=self.provider + ).first() + if not self.refresh_token: LOGGER.warning( "Refresh token does not exist", token=raw_token, ) raise TokenError("invalid_grant") + if self.refresh_token.is_expired: + LOGGER.warning( + "Refresh token is expired", + token=raw_token, + ) + raise TokenError("invalid_grant") + # https://datatracker.ietf.org/doc/html/rfc6749#section-6 + # Fallback to original token's scopes when none are given + if not self.scope: + self.scope = self.refresh_token.scope if self.refresh_token.revoked: LOGGER.warning("Refresh token is revoked", token=raw_token) Event.new( @@ -401,7 +404,7 @@ class TokenParams: "attributes": { USER_ATTRIBUTE_GENERATED: True, }, - "last_login": now(), + "last_login": timezone.now(), "name": f"Autogenerated user from application {app.name} (client credentials JWT)", "path": source.get_user_path(), }, @@ -436,14 +439,10 @@ class TokenView(View): op="authentik.providers.oauth2.post.parse", ): client_id, client_secret = extract_client_auth(request) - try: - self.provider = OAuth2Provider.objects.get(client_id=client_id) - except OAuth2Provider.DoesNotExist: + self.provider = OAuth2Provider.objects.filter(client_id=client_id).first() + if not self.provider: LOGGER.warning("OAuth2Provider does not exist", client_id=client_id) raise TokenError("invalid_client") - - if not self.provider: - raise ValueError self.params = TokenParams.parse(request, self.provider, client_id, client_secret) with Hub.current.start_span( @@ -468,122 +467,173 @@ class TokenView(View): return TokenResponse(error.create_dict(), status=403) def create_code_response(self) -> dict[str, Any]: - """See https://tools.ietf.org/html/rfc6749#section-4.1""" - refresh_token = self.provider.create_refresh_token( + """See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1""" + now = timezone.now() + access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) + access_token = AccessToken( + provider=self.provider, + user=self.params.authorization_code.user, + expires=access_token_expiry, + # Keep same scopes as previous token + scope=self.params.authorization_code.scope, + ) + access_token.id_token = IDToken.new( + self.provider, + access_token, + self.request, + ) + access_token.save() + + refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) + refresh_token = RefreshToken( user=self.params.authorization_code.user, scope=self.params.authorization_code.scope, - request=self.request, - expiry=timedelta_from_string(self.provider.token_validity), + expires=refresh_token_expiry, + provider=self.provider, ) - - if self.params.authorization_code.is_open_id: - id_token = refresh_token.create_id_token( - user=self.params.authorization_code.user, - request=self.request, - ) - id_token.nonce = self.params.authorization_code.nonce - id_token.at_hash = refresh_token.at_hash - refresh_token.id_token = id_token - - # Store the token. + id_token = IDToken.new( + self.provider, + refresh_token, + self.request, + ) + id_token.nonce = self.params.authorization_code.nonce + id_token.at_hash = access_token.at_hash + refresh_token.id_token = id_token refresh_token.save() - # We don't need to store the code anymore. + # Delete old code self.params.authorization_code.delete() - return { - "access_token": refresh_token.access_token, - "refresh_token": refresh_token.refresh_token, - "token_type": "bearer", - "expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()), - "id_token": self.provider.encode(refresh_token.id_token.to_dict()), + "access_token": access_token.token, + "refresh_token": refresh_token.token, + "token_type": TOKEN_TYPE, + "expires_in": int( + timedelta_from_string(self.provider.access_token_validity).total_seconds() + ), + "id_token": id_token.to_jwt(self.provider), } def create_refresh_response(self) -> dict[str, Any]: - """See https://tools.ietf.org/html/rfc6749#section-6""" + """See https://datatracker.ietf.org/doc/html/rfc6749#section-6""" unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope) if unauthorized_scopes: raise TokenError("invalid_scope") - refresh_token: RefreshToken = self.provider.create_refresh_token( + now = timezone.now() + access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) + access_token = AccessToken( + provider=self.provider, user=self.params.refresh_token.user, - scope=self.params.scope, - request=self.request, - expiry=timedelta_from_string(self.provider.token_validity), + expires=access_token_expiry, + # Keep same scopes as previous token + scope=self.params.refresh_token.scope, ) + access_token.id_token = IDToken.new( + self.provider, + access_token, + self.request, + ) + access_token.save() - # If the Token has an id_token it's an Authentication request. - if self.params.refresh_token.id_token: - refresh_token.id_token = refresh_token.create_id_token( - user=self.params.refresh_token.user, - request=self.request, - ) - refresh_token.id_token.at_hash = refresh_token.at_hash - - # Store the refresh_token. - refresh_token.save() + refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) + refresh_token = RefreshToken( + user=self.params.refresh_token.user, + scope=self.params.refresh_token.scope, + expires=refresh_token_expiry, + provider=self.provider, + ) + id_token = IDToken.new( + self.provider, + refresh_token, + self.request, + ) + id_token.nonce = self.params.refresh_token.id_token.nonce + id_token.at_hash = access_token.at_hash + refresh_token.id_token = id_token + refresh_token.save() # Mark old token as revoked self.params.refresh_token.revoked = True self.params.refresh_token.save() return { - "access_token": refresh_token.access_token, - "refresh_token": refresh_token.refresh_token, - "token_type": "bearer", - "expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()), - "id_token": self.provider.encode(refresh_token.id_token.to_dict()), + "access_token": access_token.token, + "refresh_token": refresh_token.token, + "token_type": TOKEN_TYPE, + "expires_in": int( + timedelta_from_string(self.provider.access_token_validity).total_seconds() + ), + "id_token": id_token.to_jwt(self.provider), } def create_client_credentials_response(self) -> dict[str, Any]: """See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4""" - refresh_token: RefreshToken = self.provider.create_refresh_token( + now = timezone.now() + access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) + access_token = AccessToken( + provider=self.provider, user=self.params.user, + expires=access_token_expiry, scope=self.params.scope, - request=self.request, - expiry=timedelta_from_string(self.provider.token_validity), ) - refresh_token.id_token = refresh_token.create_id_token( - user=self.params.user, - request=self.request, + access_token.id_token = IDToken.new( + self.provider, + access_token, + self.request, ) - refresh_token.id_token.at_hash = refresh_token.at_hash - - # Store the refresh_token. - refresh_token.save() - + access_token.save() return { - "access_token": refresh_token.access_token, - "token_type": "bearer", - "expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()), - "id_token": self.provider.encode(refresh_token.id_token.to_dict()), + "access_token": access_token.token, + "token_type": TOKEN_TYPE, + "expires_in": int( + timedelta_from_string(self.provider.access_token_validity).total_seconds() + ), + "id_token": access_token.id_token.to_jwt(self.provider), } def create_device_code_response(self) -> dict[str, Any]: """See https://datatracker.ietf.org/doc/html/rfc8628""" if not self.params.device_code.user: raise DeviceCodeError("authorization_pending") + now = timezone.now() + access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) + access_token = AccessToken( + provider=self.provider, + user=self.params.device_code.user, + expires=access_token_expiry, + scope=self.params.device_code.scope, + ) + access_token.id_token = IDToken.new( + self.provider, + access_token, + self.request, + ) + access_token.save() - refresh_token: RefreshToken = self.provider.create_refresh_token( + refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) + refresh_token = RefreshToken( user=self.params.device_code.user, scope=self.params.device_code.scope, - request=self.request, - expiry=timedelta_from_string(self.provider.token_validity), + expires=refresh_token_expiry, + provider=self.provider, ) - refresh_token.id_token = refresh_token.create_id_token( - user=self.params.device_code.user, - request=self.request, + id_token = IDToken.new( + self.provider, + refresh_token, + self.request, ) - refresh_token.id_token.at_hash = refresh_token.at_hash - - # Store the refresh_token. + id_token.at_hash = access_token.at_hash + refresh_token.id_token = id_token refresh_token.save() + # Delete device code + self.params.device_code.delete() return { - "access_token": refresh_token.access_token, - "token_type": "bearer", + "access_token": access_token.token, + "refresh_token": refresh_token.token, + "token_type": TOKEN_TYPE, "expires_in": int( - timedelta_from_string(refresh_token.provider.token_validity).total_seconds() + timedelta_from_string(self.provider.access_token_validity).total_seconds() ), - "id_token": self.provider.encode(refresh_token.id_token.to_dict()), + "id_token": id_token.to_jwt(self.provider), } diff --git a/authentik/providers/oauth2/views/token_revoke.py b/authentik/providers/oauth2/views/token_revoke.py index d7584a5ac..6989b9a54 100644 --- a/authentik/providers/oauth2/views/token_revoke.py +++ b/authentik/providers/oauth2/views/token_revoke.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt from structlog.stdlib import get_logger from authentik.providers.oauth2.errors import TokenRevocationError -from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken +from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, RefreshToken from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider LOGGER = get_logger() @@ -18,31 +18,26 @@ LOGGER = get_logger() class TokenRevocationParams: """Parameters for Token Revocation""" - token: RefreshToken + token: RefreshToken | AccessToken provider: OAuth2Provider @staticmethod def from_request(request: HttpRequest) -> "TokenRevocationParams": """Extract required Parameters from HTTP Request""" raw_token = request.POST.get("token") - token_type_hint = request.POST.get("token_type_hint", "access_token") - token_filter = {token_type_hint: raw_token} - - if token_type_hint not in ["access_token", "refresh_token"]: - LOGGER.debug("token_type_hint has invalid value", value=token_type_hint) - raise TokenRevocationError("unsupported_token_type") provider = authenticate_provider(request) if not provider: raise TokenRevocationError("invalid_client") - try: - token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter) - except RefreshToken.DoesNotExist: - LOGGER.debug("Token does not exist", token=raw_token) - raise Http404 - - return TokenRevocationParams(token=token, provider=provider) + access_token = AccessToken.objects.filter(token=raw_token).first() + if access_token: + return TokenRevocationParams(access_token, provider) + refresh_token = RefreshToken.objects.filter(token=raw_token).first() + if refresh_token: + return TokenRevocationParams(refresh_token, provider) + LOGGER.debug("Token does not exist", token=raw_token) + raise Http404 @method_decorator(csrf_exempt, name="dispatch") @@ -65,5 +60,6 @@ class TokenRevokeView(View): except TokenRevocationError as exc: return TokenResponse(exc.create_dict(), status=401) except Http404: - # Token not found should return a HTTP 200 according to the specs + # Token not found should return a HTTP 200 + # https://datatracker.ietf.org/doc/html/rfc7009#section-2.2 return TokenResponse(data={}, status=200) diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py index cb0968786..2f0578189 100644 --- a/authentik/providers/oauth2/views/userinfo.py +++ b/authentik/providers/oauth2/views/userinfo.py @@ -21,7 +21,12 @@ from authentik.providers.oauth2.constants import ( SCOPE_GITHUB_USER_READ, SCOPE_OPENID, ) -from authentik.providers.oauth2.models import RefreshToken, ScopeMapping +from authentik.providers.oauth2.models import ( + BaseGrantModel, + OAuth2Provider, + RefreshToken, + ScopeMapping, +) from authentik.providers.oauth2.utils import TokenResponse, cors_allow, protected_resource_view LOGGER = get_logger() @@ -56,21 +61,22 @@ class UserInfoView(View): ) return scope_descriptions - def get_claims(self, token: RefreshToken) -> dict[str, Any]: + def get_claims(self, provider: OAuth2Provider, token: BaseGrantModel) -> dict[str, Any]: """Get a dictionary of claims from scopes that the token requires and are assigned to the provider.""" scopes_from_client = token.scope final_claims = {} for scope in ScopeMapping.objects.filter( - provider=token.provider, scope_name__in=scopes_from_client + provider=provider, scope_name__in=scopes_from_client ).order_by("scope_name"): + scope: ScopeMapping value = None try: value = scope.evaluate( user=token.user, request=self.request, - provider=token.provider, + provider=provider, token=token, ) except PropertyMappingExpressionException as exc: @@ -108,7 +114,7 @@ class UserInfoView(View): """Handle GET Requests for UserInfo""" if not self.token: return HttpResponseBadRequest() - claims = self.get_claims(self.token) + claims = self.get_claims(self.token.provider, self.token) claims["sub"] = self.token.id_token.sub if self.token.id_token.nonce: claims["nonce"] = self.token.id_token.nonce diff --git a/authentik/providers/proxy/api.py b/authentik/providers/proxy/api.py index 85830ac8e..4505718ad 100644 --- a/authentik/providers/proxy/api.py +++ b/authentik/providers/proxy/api.py @@ -91,7 +91,8 @@ class ProxyProviderSerializer(ProviderSerializer): "redirect_uris", "cookie_domain", "jwks_sources", - "token_validity", + "access_token_validity", + "refresh_token_validity", "outpost_set", ] @@ -130,7 +131,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer): assigned_application_name = ReadOnlyField(source="application.name") oidc_configuration = SerializerMethodField() - token_validity = SerializerMethodField() + access_token_validity = SerializerMethodField() scopes_to_request = SerializerMethodField() @extend_schema_field(OpenIDConnectConfigurationSerializer) @@ -138,9 +139,9 @@ class ProxyOutpostConfigSerializer(ModelSerializer): """Embed OpenID Connect provider information""" return ProviderInfoView(request=self.context["request"]._request).get_info(obj) - def get_token_validity(self, obj: ProxyProvider) -> Optional[float]: + def get_access_token_validity(self, obj: ProxyProvider) -> Optional[float]: """Get token validity as second count""" - return timedelta_from_string(obj.token_validity).total_seconds() + return timedelta_from_string(obj.access_token_validity).total_seconds() def get_scopes_to_request(self, obj: ProxyProvider) -> list[str]: """Get all the scope names the outpost should request, @@ -169,7 +170,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer): "basic_auth_user_attribute", "mode", "cookie_domain", - "token_validity", + "access_token_validity", "intercept_header_auth", "scopes_to_request", "assigned_application_slug", diff --git a/blueprints/schema.json b/blueprints/schema.json index 7c0070da8..98b4f5435 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -73,6 +73,7 @@ "authentik_policies_reputation.reputation", "authentik_policies_reputation.reputationpolicy", "authentik_providers_ldap.ldapprovider", + "authentik_providers_oauth2.accesstoken", "authentik_providers_oauth2.authorizationcode", "authentik_providers_oauth2.oauth2provider", "authentik_providers_oauth2.refreshtoken", diff --git a/internal/outpost/proxyv2/application/session.go b/internal/outpost/proxyv2/application/session.go index d5cb81718..8a3a7f944 100644 --- a/internal/outpost/proxyv2/application/session.go +++ b/internal/outpost/proxyv2/application/session.go @@ -29,8 +29,8 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL) } rs.SetMaxLength(math.MaxInt) rs.SetKeyPrefix(RedisKeyPrefix) - if p.TokenValidity.IsSet() { - t := p.TokenValidity.Get() + if p.AccessTokenValidity.IsSet() { + t := p.AccessTokenValidity.Get() // Add one to the validity to ensure we don't have a session with indefinite length rs.SetMaxAge(int(*t) + 1) } else { @@ -49,8 +49,8 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL) // Note, when using the FilesystemStore only the session.ID is written to a browser cookie, so this is explicit for the storage on disk cs.MaxLength(math.MaxInt) - if p.TokenValidity.IsSet() { - t := p.TokenValidity.Get() + if p.AccessTokenValidity.IsSet() { + t := p.AccessTokenValidity.Get() // Add one to the validity to ensure we don't have a session with indefinite length cs.MaxAge(int(*t) + 1) } else { diff --git a/schema.yml b/schema.yml index db5d79ad3..2c03689cd 100644 --- a/schema.yml +++ b/schema.yml @@ -8015,6 +8015,165 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /oauth2/access_tokens/: + get: + operationId: oauth2_access_tokens_list + description: AccessToken Viewset + parameters: + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: provider + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: user + schema: + type: integer + tags: + - oauth2 + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedTokenModelList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /oauth2/access_tokens/{id}/: + get: + operationId: oauth2_access_tokens_retrieve + description: AccessToken Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this OAuth2 Access Token. + required: true + tags: + - oauth2 + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TokenModel' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: oauth2_access_tokens_destroy + description: AccessToken Viewset + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this OAuth2 Access Token. + required: true + tags: + - oauth2 + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /oauth2/access_tokens/{id}/used_by/: + get: + operationId: oauth2_access_tokens_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this OAuth2 Access Token. + required: true + tags: + - oauth2 + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /oauth2/authorization_codes/: get: operationId: oauth2_authorization_codes_list @@ -8220,7 +8379,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedRefreshTokenModelList' + $ref: '#/components/schemas/PaginatedTokenModelList' description: '' '400': content: @@ -8243,7 +8402,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this OAuth2 Token. + description: A unique integer value identifying this OAuth2 Refresh Token. required: true tags: - oauth2 @@ -8254,7 +8413,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/RefreshTokenModel' + $ref: '#/components/schemas/TokenModel' description: '' '400': content: @@ -8276,7 +8435,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this OAuth2 Token. + description: A unique integer value identifying this OAuth2 Refresh Token. required: true tags: - oauth2 @@ -8306,7 +8465,7 @@ paths: name: id schema: type: integer - description: A unique integer value identifying this OAuth2 Token. + description: A unique integer value identifying this OAuth2 Refresh Token. required: true tags: - oauth2 @@ -14167,6 +14326,10 @@ paths: name: access_code_validity schema: type: string + - in: query + name: access_token_validity + schema: + type: string - in: query name: application schema: @@ -14237,6 +14400,10 @@ paths: name: redirect_uris schema: type: string + - in: query + name: refresh_token_validity + schema: + type: string - name: search required: false in: query @@ -14260,10 +14427,6 @@ paths: - user_username description: Configure what data should be used as unique User Identifier. For most cases, the default should be fine. - - in: query - name: token_validity - schema: - type: string tags: - providers security: @@ -29311,7 +29474,11 @@ components: type: string description: 'Access codes not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).' - token_validity: + access_token_validity: + type: string + description: 'Tokens not valid on or after current time + this value (Format: + hours=1;minutes=2;seconds=3).' + refresh_token_validity: type: string description: 'Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).' @@ -29388,7 +29555,12 @@ components: minLength: 1 description: 'Access codes not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).' - token_validity: + access_token_validity: + type: string + minLength: 1 + description: 'Tokens not valid on or after current time + this value (Format: + hours=1;minutes=2;seconds=3).' + refresh_token_validity: type: string minLength: 1 description: 'Tokens not valid on or after current time + this value (Format: @@ -31765,41 +31937,6 @@ components: required: - pagination - results - PaginatedRefreshTokenModelList: - type: object - properties: - pagination: - type: object - properties: - next: - type: number - previous: - type: number - count: - type: number - current: - type: number - total_pages: - type: number - start_index: - type: number - end_index: - type: number - required: - - next - - previous - - count - - current - - total_pages - - start_index - - end_index - results: - type: array - items: - $ref: '#/components/schemas/RefreshTokenModel' - required: - - pagination - - results PaginatedReputationList: type: object properties: @@ -32290,6 +32427,41 @@ components: required: - pagination - results + PaginatedTokenModelList: + type: object + properties: + pagination: + type: object + properties: + next: + type: number + previous: + type: number + count: + type: number + current: + type: number + total_pages: + type: number + start_index: + type: number + end_index: + type: number + required: + - next + - previous + - count + - current + - total_pages + - start_index + - end_index + results: + type: array + items: + $ref: '#/components/schemas/TokenModel' + required: + - pagination + - results PaginatedUserConsentList: type: object properties: @@ -33948,7 +34120,12 @@ components: minLength: 1 description: 'Access codes not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).' - token_validity: + access_token_validity: + type: string + minLength: 1 + description: 'Tokens not valid on or after current time + this value (Format: + hours=1;minutes=2;seconds=3).' + refresh_token_validity: type: string minLength: 1 description: 'Tokens not valid on or after current time + this value (Format: @@ -34413,7 +34590,12 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. - token_validity: + access_token_validity: + type: string + minLength: 1 + description: 'Tokens not valid on or after current time + this value (Format: + hours=1;minutes=2;seconds=3).' + refresh_token_validity: type: string minLength: 1 description: 'Tokens not valid on or after current time + this value (Format: @@ -35723,7 +35905,7 @@ components: Exclusive with internal_host. cookie_domain: type: string - token_validity: + access_token_validity: type: number format: double nullable: true @@ -35746,6 +35928,7 @@ components: description: Application's display Name. readOnly: true required: + - access_token_validity - assigned_application_name - assigned_application_slug - external_host @@ -35753,7 +35936,6 @@ components: - oidc_configuration - pk - scopes_to_request - - token_validity ProxyProvider: type: object description: ProxyProvider Serializer @@ -35850,7 +36032,11 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. - token_validity: + access_token_validity: + type: string + description: 'Tokens not valid on or after current time + this value (Format: + hours=1;minutes=2;seconds=3).' + refresh_token_validity: type: string description: 'Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).' @@ -35941,7 +36127,12 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. - token_validity: + access_token_validity: + type: string + minLength: 1 + description: 'Tokens not valid on or after current time + this value (Format: + hours=1;minutes=2;seconds=3).' + refresh_token_validity: type: string minLength: 1 description: 'Tokens not valid on or after current time + this value (Format: @@ -35972,40 +36163,6 @@ components: required: - to - type - RefreshTokenModel: - type: object - description: Serializer for BaseGrantModel and RefreshToken - properties: - pk: - type: integer - readOnly: true - title: ID - provider: - $ref: '#/components/schemas/OAuth2Provider' - user: - $ref: '#/components/schemas/User' - is_expired: - type: boolean - readOnly: true - expires: - type: string - format: date-time - scope: - type: array - items: - type: string - id_token: - type: string - readOnly: true - revoked: - type: boolean - required: - - id_token - - is_expired - - pk - - provider - - scope - - user Reputation: type: object description: Reputation Serializer @@ -37428,6 +37585,40 @@ components: - identifier - pk - user_obj + TokenModel: + type: object + description: Serializer for BaseGrantModel and RefreshToken + properties: + pk: + type: integer + readOnly: true + title: ID + provider: + $ref: '#/components/schemas/OAuth2Provider' + user: + $ref: '#/components/schemas/User' + is_expired: + type: boolean + readOnly: true + expires: + type: string + format: date-time + scope: + type: array + items: + type: string + id_token: + type: string + readOnly: true + revoked: + type: boolean + required: + - id_token + - is_expired + - pk + - provider + - scope + - user TokenRequest: type: object description: Token Serializer diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index 4fdc85317..9050df558 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -253,18 +253,34 @@ ${this.instance?.redirectUris}

- ${t`Configure how long refresh tokens and their id_tokens are valid for.`} + ${t`Configure how long access tokens are valid for.`} +

+ +
+ + +

+ ${t`Configure how long refresh tokens are valid for.`}

diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index 38ed6ebfe..f98b15848 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -342,10 +342,10 @@ export class ProxyProviderFormPage extends ModelForm { - +

${t`Configure how long tokens are valid for.`}

diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index 147119585..cfa535c22 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -345,7 +345,7 @@ export class UserViewPage extends AKElement {
diff --git a/web/src/elements/oauth/UserRefreshList.ts b/web/src/elements/oauth/UserRefreshList.ts index 13b189ebf..135162747 100644 --- a/web/src/elements/oauth/UserRefreshList.ts +++ b/web/src/elements/oauth/UserRefreshList.ts @@ -12,10 +12,10 @@ import { customElement, property } from "lit/decorators.js"; import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css"; -import { ExpiringBaseGrantModel, Oauth2Api, RefreshTokenModel } from "@goauthentik/api"; +import { ExpiringBaseGrantModel, Oauth2Api, TokenModel } from "@goauthentik/api"; @customElement("ak-user-oauth-refresh-list") -export class UserOAuthRefreshList extends Table { +export class UserOAuthRefreshList extends Table { expandable = true; @property({ type: Number }) @@ -25,7 +25,7 @@ export class UserOAuthRefreshList extends Table { return super.styles.concat(PFFlex); } - async apiEndpoint(page: number): Promise> { + async apiEndpoint(page: number): Promise> { return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensList({ user: this.userId, ordering: "expires", @@ -46,7 +46,7 @@ export class UserOAuthRefreshList extends Table { ]; } - renderExpanded(item: RefreshTokenModel): TemplateResult { + renderExpanded(item: TokenModel): TemplateResult { return html`
@@ -64,7 +64,7 @@ export class UserOAuthRefreshList extends Table { renderToolbarSelected(): TemplateResult { const disabled = this.selectedElements.length < 1; return html` { return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensUsedByList({ @@ -83,7 +83,7 @@ export class UserOAuthRefreshList extends Table { `; } - row(item: RefreshTokenModel): TemplateResult[] { + row(item: TokenModel): TemplateResult[] { return [ html` ${item.provider?.name} `, html` diff --git a/website/docs/releases/2023/v2023.2.md b/website/docs/releases/2023/v2023.2.md index d1926bd46..a50be6697 100644 --- a/website/docs/releases/2023/v2023.2.md +++ b/website/docs/releases/2023/v2023.2.md @@ -13,6 +13,10 @@ slug: "/releases/2023.2" As with the previous improvements, we've made a lot of minor improvements to the general authentik UX to make your life easier. +- OAuth2 Provider improvements + + The OAuth2 provider has been reworked to be closer to OAuth specifications and better support refresh tokens and offline access. Additionally the expiry for access tokens and refresh tokens can be adjusted separately now. + ## Upgrading This release does not introduce any new requirements.