providers/oauth2: rework OAuth2 Provider (#4652)

* always treat flow as openid flow

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve issuer URL generation

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* more refactoring

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update introspection

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* more refinement

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* migrate more

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix more things, update api

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* regen migrations

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix a bunch of things

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start updating tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix implicit flow, auto set exp

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix timeozone not used correctly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix revoke

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* more timezone shenanigans

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix userinfo tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update web

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix proxy outpost

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix api tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing at_hash for implicit flows

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-include at_hash in implicit auth flow

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use folder context for outpost build

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-02-09 20:19:48 +01:00 committed by GitHub
parent 1f88330133
commit af43330fd6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1129 additions and 602 deletions

View file

@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- run: echo mark - run: echo mark
build: build-container:
timeout-minutes: 120 timeout-minutes: 120
needs: needs:
- ci-outpost-mark - ci-outpost-mark
@ -94,7 +94,8 @@ jobs:
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }} VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
platforms: ${{ matrix.arch }} platforms: ${{ matrix.arch }}
build-outpost-binary: context: .
build-binary:
timeout-minutes: 120 timeout-minutes: 120
needs: needs:
- ci-outpost-mark - ci-outpost-mark

View file

@ -42,7 +42,7 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
def auth_user_lookup(raw_header: bytes) -> Optional[User]: def auth_user_lookup(raw_header: bytes) -> Optional[User]:
"""raw_header in the Format of `Bearer ....`""" """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) auth_credentials = validate_auth(raw_header)
if not auth_credentials: if not auth_credentials:
@ -55,8 +55,8 @@ def auth_user_lookup(raw_header: bytes) -> Optional[User]:
CTX_AUTH_VIA.set("api_token") CTX_AUTH_VIA.set("api_token")
return key_token.user return key_token.user
# then try to auth via JWT # then try to auth via JWT
jwt_token = RefreshToken.filter_not_expired( jwt_token = AccessToken.filter_not_expired(
refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
).first() ).first()
if jwt_token: if jwt_token:
# Double-check scopes, since they are saved in a single string # Double-check scopes, since they are saved in a single string

View file

@ -1,4 +1,5 @@
"""Test API Authentication""" """Test API Authentication"""
import json
from base64 import b64encode from base64 import b64encode
from django.conf import settings 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.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API 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): class TestAPIAuth(TestCase):
@ -63,24 +64,26 @@ class TestAPIAuth(TestCase):
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow() 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(), user=create_test_admin_user(),
provider=provider, provider=provider,
refresh_token=generate_id(), token=generate_id(),
_scope=SCOPE_AUTHENTIK_API, _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): def test_jwt_missing_scope(self):
"""Test valid JWT""" """Test valid JWT"""
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow() 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(), user=create_test_admin_user(),
provider=provider, provider=provider,
refresh_token=generate_id(), token=generate_id(),
_scope="", _scope="",
_id_token=json.dumps({}),
) )
with self.assertRaises(AuthenticationFailed): 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)

View file

@ -50,7 +50,11 @@ from authentik.policies.reputation.api import ReputationPolicyViewSet, Reputatio
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
from authentik.providers.oauth2.api.providers import OAuth2ProviderViewSet from authentik.providers.oauth2.api.providers import OAuth2ProviderViewSet
from authentik.providers.oauth2.api.scopes import ScopeMappingViewSet 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.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
from authentik.providers.saml.api.providers import SAMLProviderViewSet 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/authorization_codes", AuthorizationCodeViewSet)
router.register("oauth2/refresh_tokens", RefreshTokenViewSet) router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
router.register("oauth2/access_tokens", AccessTokenViewSet)
router.register("propertymappings/all", PropertyMappingViewSet) router.register("propertymappings/all", PropertyMappingViewSet)
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)

View file

@ -13,7 +13,8 @@ from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
from authentik.core.models import Provider 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): class OAuth2ProviderSerializer(ProviderSerializer):
@ -27,7 +28,8 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"client_id", "client_id",
"client_secret", "client_secret",
"access_code_validity", "access_code_validity",
"token_validity", "access_token_validity",
"refresh_token_validity",
"include_claims_in_id_token", "include_claims_in_id_token",
"signing_key", "signing_key",
"redirect_uris", "redirect_uris",
@ -64,7 +66,8 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
"client_type", "client_type",
"client_id", "client_id",
"access_code_validity", "access_code_validity",
"token_validity", "access_token_validity",
"refresh_token_validity",
"include_claims_in_id_token", "include_claims_in_id_token",
"signing_key", "signing_key",
"redirect_uris", "redirect_uris",
@ -141,13 +144,17 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
def preview_user(self, request: Request, pk: int) -> Response: def preview_user(self, request: Request, pk: int) -> Response:
"""Preview user data for provider""" """Preview user data for provider"""
provider: OAuth2Provider = self.get_object() provider: OAuth2Provider = self.get_object()
temp_token = RefreshToken() scope_names = ScopeMapping.objects.filter(provider=provider).values_list(
temp_token.scope = ScopeMapping.objects.filter(provider=provider).values_list(
"scope_name", flat=True "scope_name", flat=True
) )
temp_token.provider = provider temp_token = IDToken.new(
temp_token.user = request.user provider,
serializer = PropertyMappingPreviewSerializer( AccessToken(
instance={"preview": temp_token.create_id_token(request.user, request).to_dict()} user=request.user,
provider=provider,
_scope=" ".join(scope_names),
),
request,
) )
serializer = PropertyMappingPreviewSerializer(instance={"preview": temp_token.to_dict()})
return Response(serializer.data) return Response(serializer.data)

View file

@ -13,7 +13,7 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import MetaNameSerializer from authentik.core.api.utils import MetaNameSerializer
from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer 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): class ExpiringBaseGrantModelSerializer(ModelSerializer, MetaNameSerializer):
@ -29,7 +29,7 @@ class ExpiringBaseGrantModelSerializer(ModelSerializer, MetaNameSerializer):
depth = 2 depth = 2
class RefreshTokenModelSerializer(ExpiringBaseGrantModelSerializer): class TokenModelSerializer(ExpiringBaseGrantModelSerializer):
"""Serializer for BaseGrantModel and RefreshToken""" """Serializer for BaseGrantModel and RefreshToken"""
id_token = SerializerMethodField() id_token = SerializerMethodField()
@ -89,7 +89,33 @@ class RefreshTokenViewSet(
"""RefreshToken Viewset""" """RefreshToken Viewset"""
queryset = RefreshToken.objects.all() 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"] filterset_fields = ["user", "provider"]
ordering = ["provider", "expires"] ordering = ["provider", "expires"]
filter_backends = [ filter_backends = [

View file

@ -19,6 +19,8 @@ SCOPE_OPENID = "openid"
SCOPE_OPENID_PROFILE = "profile" SCOPE_OPENID_PROFILE = "profile"
SCOPE_OPENID_EMAIL = "email" SCOPE_OPENID_EMAIL = "email"
TOKEN_TYPE = "Bearer" # nosec
SCOPE_AUTHENTIK_API = "goauthentik.io/api" SCOPE_AUTHENTIK_API = "goauthentik.io/api"
# Read/write full user (including email) # Read/write full user (including email)

View file

@ -93,7 +93,7 @@ class TokenIntrospectionError(OAuth2Error):
""" """
Specific to the introspection endpoint. This error will be converted Specific to the introspection endpoint. This error will be converted
to an "active: false" response, as per the spec. 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 = { errors = {
# OAuth2 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", "invalid_request": "The request is otherwise malformed",
"unauthorized_client": ( "unauthorized_client": (
"The client is not authorized to request an authorization code using this method" "The client is not authorized to request an authorization code using this method"
@ -185,7 +185,7 @@ class AuthorizeError(OAuth2Error):
class TokenError(OAuth2Error): class TokenError(OAuth2Error):
""" """
OAuth2 token endpoint errors. OAuth2 token endpoint errors.
https://tools.ietf.org/html/rfc6749#section-5.2 https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
""" """
errors = { errors = {
@ -220,7 +220,7 @@ class TokenError(OAuth2Error):
class TokenRevocationError(OAuth2Error): class TokenRevocationError(OAuth2Error):
""" """
Specific to the revocation endpoint. Specific to the revocation endpoint.
See https://tools.ietf.org/html/rfc7662 See https://datatracker.ietf.org/doc/html/rfc7662
""" """
errors = TokenError.errors | { errors = TokenError.errors | {
@ -266,7 +266,7 @@ class DeviceCodeError(OAuth2Error):
class BearerTokenError(OAuth2Error): class BearerTokenError(OAuth2Error):
""" """
OAuth2 errors. OAuth2 errors.
https://tools.ietf.org/html/rfc6750#section-3.1 https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
""" """
errors = { errors = {

View file

@ -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())

View file

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

View file

@ -2,8 +2,6 @@
import base64 import base64
import binascii import binascii
import json import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timedelta
from functools import cached_property from functools import cached_property
from hashlib import sha256 from hashlib import sha256
from typing import Any, Optional 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 dacite.core import from_dict
from django.db import models from django.db import models
from django.http import HttpRequest from django.http import HttpRequest
from django.utils import timezone from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from jwt import encode from jwt import encode
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
from authentik.crypto.models import CertificateKeyPair 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.generators import generate_code_fixed_length, generate_id, generate_key
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator from authentik.lib.utils.time import timedelta_string_validator
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config from authentik.providers.oauth2.id_token import IDToken, SubModes
from authentik.providers.oauth2.constants import (
ACR_AUTHENTIK_DEFAULT,
AMR_MFA,
AMR_PASSWORD,
AMR_WEBAUTHN,
)
from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.models import OAuthSource
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
class ClientTypes(models.TextChoices): class ClientTypes(models.TextChoices):
@ -61,25 +51,6 @@ class ResponseMode(models.TextChoices):
FORM_POST = "form_post" 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): class IssuerMode(models.TextChoices):
"""Configure how the `iss` field is created.""" """Configure how the `iss` field is created."""
@ -187,7 +158,15 @@ class OAuth2Provider(Provider):
"(Format: hours=1;minutes=2;seconds=3)." "(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", default="days=30",
validators=[timedelta_string_validator], validators=[timedelta_string_validator],
help_text=_( help_text=_(
@ -230,24 +209,6 @@ class OAuth2Provider(Provider):
blank=True, 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 @cached_property
def jwt_key(self) -> tuple[str | PRIVATE_KEY_TYPES, str]: def jwt_key(self) -> tuple[str | PRIVATE_KEY_TYPES, str]:
"""Get either the configured certificate or the client secret""" """Get either the configured certificate or the client secret"""
@ -267,11 +228,15 @@ class OAuth2Provider(Provider):
if self.issuer_mode == IssuerMode.GLOBAL: if self.issuer_mode == IssuerMode.GLOBAL:
return request.build_absolute_uri("/") return request.build_absolute_uri("/")
try: try:
mountpoint = AuthentikProviderOAuth2Config.mountpoints[ url = reverse(
"authentik.providers.oauth2.urls" "authentik_providers_oauth2:provider-root",
] kwargs={
# pylint: disable=no-member # pylint: disable=no-member
return request.build_absolute_uri(f"/{mountpoint}{self.application.slug}/") "application_slug": self.application.slug,
},
)
return request.build_absolute_uri(url)
# pylint: disable=no-member
except Provider.application.RelatedObjectDoesNotExist: except Provider.application.RelatedObjectDoesNotExist:
return None return None
@ -303,8 +268,6 @@ class OAuth2Provider(Provider):
if self.signing_key: if self.signing_key:
headers["kid"] = self.signing_key.kid headers["kid"] = self.signing_key.kid
key, alg = self.jwt_key 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) return encode(payload, key, algorithm=alg, headers=headers)
class Meta: class Meta:
@ -338,7 +301,6 @@ class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
code = models.CharField(max_length=255, unique=True, verbose_name=_("Code")) code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
nonce = models.TextField(null=True, default=None, verbose_name=_("Nonce")) 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 = models.CharField(max_length=255, null=True, verbose_name=_("Code Challenge"))
code_challenge_method = models.CharField( code_challenge_method = models.CharField(
max_length=255, null=True, verbose_name=_("Code Challenge Method") 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}" return f"Authorization code for {self.provider} for user {self.user}"
@dataclass class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
class IDToken: """OAuth2 access token, non-opaque using a JWT as identifier"""
"""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""" token = models.TextField()
_id_token = models.TextField()
# 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")
@property @property
def id_token(self) -> IDToken: def id_token(self) -> IDToken:
"""Load ID Token from json""" """Load ID Token from json"""
if self._id_token: raw_token = json.loads(self._id_token)
raw_token = json.loads(self._id_token) return from_dict(IDToken, raw_token)
return from_dict(IDToken, raw_token)
return IDToken()
@id_token.setter @id_token.setter
def id_token(self, value: IDToken): def id_token(self, value: IDToken):
self._id_token = json.dumps(asdict(value)) self.token = value.to_access_token(self.provider)
self._id_token = json.dumps(value.to_dict())
def __str__(self):
return f"Refresh Token for {self.provider} for user {self.user}"
@property @property
def at_hash(self): def at_hash(self):
"""Get hashed access_token""" """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 ( return (
base64.urlsafe_b64encode( base64.urlsafe_b64encode(
binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2]) binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2])
@ -458,78 +359,52 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
.decode("ascii") .decode("ascii")
) )
def create_access_token(self, user: User, request: HttpRequest) -> str: @property
"""Create access token with a similar format as Okta, Keycloak, ADFS""" def serializer(self) -> Serializer:
token = self.create_id_token(user, request).to_dict() from authentik.providers.oauth2.api.tokens import TokenModelSerializer
token["cid"] = self.provider.client_id
token["uid"] = generate_key()
return self.provider.encode(token)
# pylint: disable=too-many-locals return TokenModelSerializer
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())
token = IDToken( class Meta:
iss=self.provider.get_issuer(request), verbose_name = _("OAuth2 Access Token")
sub=sub, verbose_name_plural = _("OAuth2 Access Tokens")
aud=self.provider.client_id,
exp=exp_time,
iat=iat_time,
)
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time def __str__(self):
auth_event = get_login_event(request) return f"Access Token for {self.provider} for user {self.user}"
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
# 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() class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
user_info.request = request """OAuth2 Refresh Token, opaque"""
claims = user_info.get_claims(self)
token.claims = claims
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): class DeviceToken(ExpiringModel):
"""Device token for OAuth device flow""" """Temporary device token for OAuth device flow"""
user = models.ForeignKey( user = models.ForeignKey(
"authentik_core.User", default=None, on_delete=models.CASCADE, null=True "authentik_core.User", default=None, on_delete=models.CASCADE, null=True

View file

@ -11,12 +11,13 @@ from authentik.events.models import Event, EventAction
from authentik.flows.challenge import ChallengeTypes from authentik.flows.challenge import ChallengeTypes
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.lib.utils.time import timedelta_from_string 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.errors import AuthorizeError, ClientIdError, RedirectUriError
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode, AuthorizationCode,
GrantTypes, GrantTypes,
OAuth2Provider, OAuth2Provider,
RefreshToken,
) )
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams
@ -293,21 +294,20 @@ class TestAuthorize(OAuthTestCase):
def test_full_implicit(self): def test_full_implicit(self):
"""Test full authorization""" """Test full authorization"""
flow = create_test_flow() flow = create_test_flow()
provider = OAuth2Provider.objects.create( provider: OAuth2Provider = OAuth2Provider.objects.create(
name=generate_id(), name=generate_id(),
client_id="test", client_id="test",
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=flow, authorization_flow=flow,
redirect_uris="http://localhost", redirect_uris="http://localhost",
signing_key=self.keypair, signing_key=self.keypair,
access_code_validity="seconds=100",
) )
Application.objects.create(name="app", slug="app", provider=provider) Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id() state = generate_id()
user = create_test_admin_user() user = create_test_admin_user()
self.client.force_login(user) self.client.force_login(user)
with patch( with patch(
"authentik.providers.oauth2.models.get_login_event", "authentik.providers.oauth2.id_token.get_login_event",
MagicMock( MagicMock(
return_value=Event( return_value=Event(
action=EventAction.LOGIN, action=EventAction.LOGIN,
@ -330,16 +330,17 @@ class TestAuthorize(OAuthTestCase):
response = self.client.get( response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), 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()
expires = timedelta_from_string(provider.access_code_validity).total_seconds() expires = timedelta_from_string(provider.access_token_validity).total_seconds()
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {
"component": "xak-flow-redirect", "component": "xak-flow-redirect",
"type": ChallengeTypes.REDIRECT.value, "type": ChallengeTypes.REDIRECT.value,
"to": ( "to": (
f"http://localhost#access_token={token.access_token}" f"http://localhost#access_token={token.token}"
f"&id_token={provider.encode(token.id_token.to_dict())}&token_type=bearer" f"&id_token={provider.encode(token.id_token.to_dict())}"
f"&token_type={TOKEN_TYPE}"
f"&expires_in={int(expires)}&state={state}" f"&expires_in={int(expires)}&state={state}"
), ),
}, },
@ -382,7 +383,7 @@ class TestAuthorize(OAuthTestCase):
response = self.client.get( response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), 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( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {
@ -391,10 +392,10 @@ class TestAuthorize(OAuthTestCase):
"url": "http://localhost", "url": "http://localhost",
"title": f"Redirecting to {app.name}...", "title": f"Redirecting to {app.name}...",
"attrs": { "attrs": {
"access_token": token.access_token, "access_token": token.token,
"id_token": provider.encode(token.id_token.to_dict()), "id_token": provider.encode(token.id_token.to_dict()),
"token_type": "bearer", "token_type": TOKEN_TYPE,
"expires_in": "60", "expires_in": "3600",
"state": state, "state": state,
}, },
}, },

View file

@ -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.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.lib.generators import generate_id, generate_key
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT 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 from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -31,11 +31,16 @@ class TesOAuth2Introspection(OAuthTestCase):
) )
self.app.save() self.app.save()
self.user = create_test_admin_user() 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, provider=self.provider,
user=self.user, user=self.user,
access_token=generate_id(), token=generate_id(),
refresh_token=generate_id(),
_scope="openid user profile", _scope="openid user profile",
_id_token=json.dumps( _id_token=json.dumps(
asdict( 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( res = self.client.post(
reverse("authentik_providers_oauth2:token-introspection"), reverse("authentik_providers_oauth2:token-introspection"),
HTTP_AUTHORIZATION=f"Basic {self.auth}", 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.assertEqual(res.status_code, 200)
self.assertJSONEqual( self.assertJSONEqual(
res.content.decode(), res.content.decode(),
{ {
"acr": ACR_AUTHENTIK_DEFAULT, "acr": ACR_AUTHENTIK_DEFAULT,
"aud": None,
"sub": "bar", "sub": "bar",
"exp": None,
"iat": None,
"iss": "foo", "iss": "foo",
"active": True, "active": True,
"client_id": self.provider.client_id, "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),
}, },
) )

View file

@ -8,7 +8,7 @@ from django.urls import reverse
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id, generate_key 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 from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -30,11 +30,16 @@ class TesOAuth2Revoke(OAuthTestCase):
) )
self.app.save() self.app.save()
self.user = create_test_admin_user() 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, provider=self.provider,
user=self.user, user=self.user,
access_token=generate_id(), token=generate_id(),
refresh_token=generate_id(),
_scope="openid user profile", _scope="openid user profile",
_id_token=json.dumps( _id_token=json.dumps(
asdict( 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( res = self.client.post(
reverse("authentik_providers_oauth2:token-revoke"), reverse("authentik_providers_oauth2:token-revoke"),
HTTP_AUTHORIZATION=f"Basic {self.auth}", 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) self.assertEqual(res.status_code, 200)
@ -60,7 +83,9 @@ class TesOAuth2Revoke(OAuthTestCase):
res = self.client.post( res = self.client.post(
reverse("authentik_providers_oauth2:token-revoke"), reverse("authentik_providers_oauth2:token-revoke"),
HTTP_AUTHORIZATION=f"Basic {self.auth}", 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) self.assertEqual(res.status_code, 200)
@ -69,6 +94,8 @@ class TesOAuth2Revoke(OAuthTestCase):
res = self.client.post( res = self.client.post(
reverse("authentik_providers_oauth2:token-revoke"), reverse("authentik_providers_oauth2:token-revoke"),
HTTP_AUTHORIZATION="Basic fqewr", 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) self.assertEqual(res.status_code, 401)

View file

@ -1,5 +1,6 @@
"""Test token view""" """Test token view"""
from base64 import b64encode from base64 import b64encode
from json import dumps
from django.test import RequestFactory from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
@ -11,9 +12,15 @@ from authentik.lib.generators import generate_id, generate_key
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_REFRESH_TOKEN, GRANT_TYPE_REFRESH_TOKEN,
TOKEN_TYPE,
) )
from authentik.providers.oauth2.errors import TokenError 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.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.token import TokenParams from authentik.providers.oauth2.views.token import TokenParams
@ -91,13 +98,13 @@ class TestToken(OAuthTestCase):
token: RefreshToken = RefreshToken.objects.create( token: RefreshToken = RefreshToken.objects.create(
provider=provider, provider=provider,
user=user, user=user,
refresh_token=generate_id(), token=generate_id(),
) )
request = self.factory.post( request = self.factory.post(
"/", "/",
data={ data={
"grant_type": GRANT_TYPE_REFRESH_TOKEN, "grant_type": GRANT_TYPE_REFRESH_TOKEN,
"refresh_token": token.refresh_token, "refresh_token": token.token,
"redirect_uri": "http://local.invalid", "redirect_uri": "http://local.invalid",
}, },
HTTP_AUTHORIZATION=f"Basic {header}", HTTP_AUTHORIZATION=f"Basic {header}",
@ -120,9 +127,7 @@ class TestToken(OAuthTestCase):
self.app.save() self.app.save()
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user() user = create_test_admin_user()
code = AuthorizationCode.objects.create( code = AuthorizationCode.objects.create(code="foobar", provider=provider, user=user)
code="foobar", provider=provider, user=user, is_open_id=True
)
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
data={ data={
@ -132,20 +137,21 @@ class TestToken(OAuthTestCase):
}, },
HTTP_AUTHORIZATION=f"Basic {header}", 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( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {
"access_token": new_token.access_token, "access_token": access.token,
"refresh_token": new_token.refresh_token, "refresh_token": refresh.token,
"token_type": "bearer", "token_type": TOKEN_TYPE,
"expires_in": 2592000, "expires_in": 3600,
"id_token": provider.encode( "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): def test_refresh_token_view(self):
"""test request param""" """test request param"""
@ -165,36 +171,38 @@ class TestToken(OAuthTestCase):
token: RefreshToken = RefreshToken.objects.create( token: RefreshToken = RefreshToken.objects.create(
provider=provider, provider=provider,
user=user, user=user,
refresh_token=generate_id(), token=generate_id(),
_id_token=dumps({}),
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
data={ data={
"grant_type": GRANT_TYPE_REFRESH_TOKEN, "grant_type": GRANT_TYPE_REFRESH_TOKEN,
"refresh_token": token.refresh_token, "refresh_token": token.token,
"redirect_uri": "http://local.invalid", "redirect_uri": "http://local.invalid",
}, },
HTTP_AUTHORIZATION=f"Basic {header}", HTTP_AUTHORIZATION=f"Basic {header}",
HTTP_ORIGIN="http://local.invalid", 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-Credentials"], "true")
self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid") 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( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {
"access_token": new_token.access_token, "access_token": access.token,
"refresh_token": new_token.refresh_token, "refresh_token": refresh.token,
"token_type": "bearer", "token_type": TOKEN_TYPE,
"expires_in": 2592000, "expires_in": 3600,
"id_token": provider.encode( "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): def test_refresh_token_view_invalid_origin(self):
"""test request param""" """test request param"""
@ -211,32 +219,34 @@ class TestToken(OAuthTestCase):
token: RefreshToken = RefreshToken.objects.create( token: RefreshToken = RefreshToken.objects.create(
provider=provider, provider=provider,
user=user, user=user,
refresh_token=generate_id(), token=generate_id(),
_id_token=dumps({}),
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
data={ data={
"grant_type": GRANT_TYPE_REFRESH_TOKEN, "grant_type": GRANT_TYPE_REFRESH_TOKEN,
"refresh_token": token.refresh_token, "refresh_token": token.token,
"redirect_uri": "http://local.invalid", "redirect_uri": "http://local.invalid",
}, },
HTTP_AUTHORIZATION=f"Basic {header}", HTTP_AUTHORIZATION=f"Basic {header}",
HTTP_ORIGIN="http://another.invalid", HTTP_ORIGIN="http://another.invalid",
) )
new_token: RefreshToken = ( access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
RefreshToken.objects.filter(user=user).exclude(pk=token.pk).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-Credentials", response)
self.assertNotIn("Access-Control-Allow-Origin", response) self.assertNotIn("Access-Control-Allow-Origin", response)
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {
"access_token": new_token.access_token, "access_token": access.token,
"refresh_token": new_token.refresh_token, "refresh_token": refresh.token,
"token_type": "bearer", "token_type": TOKEN_TYPE,
"expires_in": 2592000, "expires_in": 3600,
"id_token": provider.encode( "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( token: RefreshToken = RefreshToken.objects.create(
provider=provider, provider=provider,
user=user, user=user,
refresh_token=generate_id(), token=generate_id(),
_id_token=dumps({}),
) )
# Create initial refresh token # Create initial refresh token
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
data={ data={
"grant_type": GRANT_TYPE_REFRESH_TOKEN, "grant_type": GRANT_TYPE_REFRESH_TOKEN,
"refresh_token": token.refresh_token, "refresh_token": token.token,
"redirect_uri": "http://testserver", "redirect_uri": "http://testserver",
}, },
HTTP_AUTHORIZATION=f"Basic {header}", HTTP_AUTHORIZATION=f"Basic {header}",
@ -280,7 +291,7 @@ class TestToken(OAuthTestCase):
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
data={ data={
"grant_type": GRANT_TYPE_REFRESH_TOKEN, "grant_type": GRANT_TYPE_REFRESH_TOKEN,
"refresh_token": new_token.refresh_token, "refresh_token": new_token.token,
"redirect_uri": "http://local.invalid", "redirect_uri": "http://local.invalid",
}, },
HTTP_AUTHORIZATION=f"Basic {header}", HTTP_AUTHORIZATION=f"Basic {header}",
@ -291,7 +302,7 @@ class TestToken(OAuthTestCase):
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
data={ data={
"grant_type": GRANT_TYPE_REFRESH_TOKEN, "grant_type": GRANT_TYPE_REFRESH_TOKEN,
"refresh_token": new_token.refresh_token, "refresh_token": new_token.token,
"redirect_uri": "http://local.invalid", "redirect_uri": "http://local.invalid",
}, },
HTTP_AUTHORIZATION=f"Basic {header}", HTTP_AUTHORIZATION=f"Basic {header}",

View file

@ -15,6 +15,7 @@ from authentik.providers.oauth2.constants import (
SCOPE_OPENID, SCOPE_OPENID,
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
TOKEN_TYPE,
) )
from authentik.providers.oauth2.errors import TokenError from authentik.providers.oauth2.errors import TokenError
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
@ -142,7 +143,7 @@ class TestTokenClientCredentials(OAuthTestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertEqual(body["token_type"], "bearer") self.assertEqual(body["token_type"], TOKEN_TYPE)
_, alg = self.provider.jwt_key _, alg = self.provider.jwt_key
jwt = decode( jwt = decode(
body["access_token"], body["access_token"],

View file

@ -16,6 +16,7 @@ from authentik.providers.oauth2.constants import (
SCOPE_OPENID, SCOPE_OPENID,
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
TOKEN_TYPE,
) )
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -209,7 +210,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content.decode()) body = loads(response.content.decode())
self.assertEqual(body["token_type"], "bearer") self.assertEqual(body["token_type"], TOKEN_TYPE)
_, alg = self.provider.jwt_key _, alg = self.provider.jwt_key
jwt = decode( jwt = decode(
body["access_token"], body["access_token"],

View file

@ -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.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id, generate_key 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 from authentik.providers.oauth2.tests.utils import OAuthTestCase
@ -33,11 +33,10 @@ class TestUserinfo(OAuthTestCase):
self.app.provider = self.provider self.app.provider = self.provider
self.app.save() self.app.save()
self.user = create_test_admin_user() self.user = create_test_admin_user()
self.token: RefreshToken = RefreshToken.objects.create( self.token: AccessToken = AccessToken.objects.create(
provider=self.provider, provider=self.provider,
user=self.user, user=self.user,
access_token=generate_id(), token=generate_id(),
refresh_token=generate_id(),
_scope="openid user profile", _scope="openid user profile",
_id_token=json.dumps( _id_token=json.dumps(
asdict( asdict(
@ -50,7 +49,7 @@ class TestUserinfo(OAuthTestCase):
"""test user info with all normal scopes""" """test user info with all normal scopes"""
res = self.client.get( res = self.client.get(
reverse("authentik_providers_oauth2:userinfo"), reverse("authentik_providers_oauth2:userinfo"),
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", HTTP_AUTHORIZATION=f"Bearer {self.token.token}",
) )
self.assertJSONEqual( self.assertJSONEqual(
res.content.decode(), res.content.decode(),
@ -73,7 +72,7 @@ class TestUserinfo(OAuthTestCase):
res = self.client.get( res = self.client.get(
reverse("authentik_providers_oauth2:userinfo"), reverse("authentik_providers_oauth2:userinfo"),
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}", HTTP_AUTHORIZATION=f"Bearer {self.token.token}",
) )
self.assertJSONEqual( self.assertJSONEqual(
res.content.decode(), res.content.decode(),

View file

@ -6,7 +6,7 @@ from jwt import decode
from authentik.core.tests.utils import create_test_cert from authentik.core.tests.utils import create_test_cert
from authentik.crypto.models import CertificateKeyPair 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): class OAuthTestCase(TestCase):
@ -25,19 +25,20 @@ class OAuthTestCase(TestCase):
def setUpClass(cls) -> None: def setUpClass(cls) -> None:
cls.keypair = create_test_cert() cls.keypair = create_test_cert()
super().setUpClass() super().setUpClass()
cls.maxDiff = None
def assert_non_none_or_unset(self, container: dict, key: str): def assert_non_none_or_unset(self, container: dict, key: str):
"""Check that a key, if set, is not none""" """Check that a key, if set, is not none"""
if key in container: if key in container:
self.assertIsNotNone(container[key]) 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""" """Validate that all required fields are set"""
key, alg = provider.jwt_key key, alg = provider.jwt_key
if alg != JWTAlgorithms.HS256: if alg != JWTAlgorithms.HS256:
key = provider.signing_key.public_key key = provider.signing_key.public_key
jwt = decode( jwt = decode(
token.access_token, token.token,
key, key,
algorithms=[alg], algorithms=[alg],
audience=provider.client_id, audience=provider.client_id,

View file

@ -40,6 +40,11 @@ urlpatterns = [
name="end-session", name="end-session",
), ),
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"), path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),
path(
"<slug:application_slug>/",
RedirectView.as_view(pattern_name="authentk_providers_oauth2:provider-info"),
name="provider-root",
),
path( path(
"<slug:application_slug>/.well-known/openid-configuration", "<slug:application_slug>/.well-known/openid-configuration",
ProviderInfoView.as_view(), ProviderInfoView.as_view(),

View file

@ -13,7 +13,7 @@ from structlog.stdlib import get_logger
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.providers.oauth2.errors import BearerTokenError 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() LOGGER = get_logger()
@ -119,7 +119,7 @@ def protected_resource_view(scopes: list[str]):
"""View decorator. The client accesses protected resources by presenting the """View decorator. The client accesses protected resources by presenting the
access token to the resource server. 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`""" This decorator also injects the token into `kwargs`"""
@ -133,9 +133,8 @@ def protected_resource_view(scopes: list[str]):
LOGGER.debug("No token passed") LOGGER.debug("No token passed")
raise BearerTokenError("invalid_token") raise BearerTokenError("invalid_token")
try: token = AccessToken.objects.filter(token=access_token).first()
token: RefreshToken = RefreshToken.objects.get(access_token=access_token) if not token:
except RefreshToken.DoesNotExist:
LOGGER.debug("Token does not exist", access_token=access_token) LOGGER.debug("Token does not exist", access_token=access_token)
raise BearerTokenError("invalid_token") raise BearerTokenError("invalid_token")

View file

@ -1,6 +1,7 @@
"""authentik OAuth2 Authorization views""" """authentik OAuth2 Authorization views"""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import timedelta from datetime import timedelta
from json import dumps
from re import error as RegexError from re import error as RegexError
from re import fullmatch from re import fullmatch
from typing import Optional from typing import Optional
@ -37,6 +38,7 @@ from authentik.providers.oauth2.constants import (
PROMPT_LOGIN, PROMPT_LOGIN,
PROMPT_NONE, PROMPT_NONE,
SCOPE_OPENID, SCOPE_OPENID,
TOKEN_TYPE,
) )
from authentik.providers.oauth2.errors import ( from authentik.providers.oauth2.errors import (
AuthorizeError, AuthorizeError,
@ -44,7 +46,9 @@ from authentik.providers.oauth2.errors import (
OAuth2Error, OAuth2Error,
RedirectUriError, RedirectUriError,
) )
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode, AuthorizationCode,
GrantTypes, GrantTypes,
OAuth2Provider, OAuth2Provider,
@ -264,8 +268,6 @@ class OAuthAuthorizationParams:
code.expires = timezone.now() + timedelta_from_string(self.provider.access_code_validity) code.expires = timezone.now() + timedelta_from_string(self.provider.access_code_validity)
code.scope = self.scope code.scope = self.scope
code.nonce = self.nonce code.nonce = self.nonce
code.is_open_id = SCOPE_OPENID in self.scope
return code return code
@ -517,13 +519,25 @@ class OAuthFulfillmentStage(StageView):
"""Create implicit response's URL Fragment dictionary""" """Create implicit response's URL Fragment dictionary"""
query_fragment = {} 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, user=self.request.user,
scope=self.params.scope, scope=self.params.scope,
request=self.request, expires=access_token_expiry,
expiry=timedelta_from_string(self.provider.access_code_validity), 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. # Check if response_type must include access_token in the response.
if self.params.response_type in [ if self.params.response_type in [
ResponseTypes.ID_TOKEN_TOKEN, ResponseTypes.ID_TOKEN_TOKEN,
@ -531,47 +545,29 @@ class OAuthFulfillmentStage(StageView):
ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN,
ResponseTypes.CODE_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. # Check if response_type must include id_token in the response.
if SCOPE_OPENID in self.params.scope: if self.params.response_type in [
id_token = token.create_id_token( ResponseTypes.ID_TOKEN,
user=self.request.user, ResponseTypes.ID_TOKEN_TOKEN,
request=self.request, ResponseTypes.CODE_ID_TOKEN,
) ResponseTypes.CODE_ID_TOKEN_TOKEN,
id_token.nonce = self.params.nonce ]:
# 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() token.save()
# Code parameter must be present if it's Hybrid Flow. # Code parameter must be present if it's Hybrid Flow.
if self.params.grant_type == GrantTypes.HYBRID: if self.params.grant_type == GrantTypes.HYBRID:
query_fragment["code"] = code.code query_fragment["code"] = code.code
query_fragment["token_type"] = "bearer" # nosec query_fragment["token_type"] = TOKEN_TYPE
query_fragment["expires_in"] = int( 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 "" query_fragment["state"] = self.params.state if self.params.state else ""
return query_fragment return query_fragment

View file

@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenIntrospectionError 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 from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
LOGGER = get_logger() LOGGER = get_logger()
@ -18,7 +18,7 @@ LOGGER = get_logger()
class TokenIntrospectionParams: class TokenIntrospectionParams:
"""Parameters for Token Introspection""" """Parameters for Token Introspection"""
token: RefreshToken token: RefreshToken | AccessToken
provider: OAuth2Provider provider: OAuth2Provider
id_token: IDToken = field(init=False) id_token: IDToken = field(init=False)
@ -41,31 +41,26 @@ class TokenIntrospectionParams:
def from_request(request: HttpRequest) -> "TokenIntrospectionParams": def from_request(request: HttpRequest) -> "TokenIntrospectionParams":
"""Extract required Parameters from HTTP Request""" """Extract required Parameters from HTTP Request"""
raw_token = request.POST.get("token") 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) provider = authenticate_provider(request)
if not provider: if not provider:
raise TokenIntrospectionError raise TokenIntrospectionError
token: RefreshToken = RefreshToken.objects.filter(provider=provider, **token_filter).first() access_token = AccessToken.objects.filter(token=raw_token).first()
if not token: if access_token:
LOGGER.debug("Token does not exist", token=raw_token) return TokenIntrospectionParams(access_token, provider)
raise TokenIntrospectionError() refresh_token = RefreshToken.objects.filter(token=raw_token).first()
if refresh_token:
return TokenIntrospectionParams(token=token, provider=provider) return TokenIntrospectionParams(refresh_token, provider)
LOGGER.debug("Token does not exist", token=raw_token)
raise TokenIntrospectionError()
@method_decorator(csrf_exempt, name="dispatch") @method_decorator(csrf_exempt, name="dispatch")
class TokenIntrospectionView(View): class TokenIntrospectionView(View):
"""Token Introspection """Token Introspection
https://tools.ietf.org/html/rfc7662""" https://datatracker.ietf.org/doc/html/rfc7662"""
token: RefreshToken token: RefreshToken | AccessToken
params: TokenIntrospectionParams params: TokenIntrospectionParams
provider: OAuth2Provider provider: OAuth2Provider
@ -76,9 +71,9 @@ class TokenIntrospectionView(View):
response = {} response = {}
if self.params.id_token: if self.params.id_token:
response.update(self.params.id_token.to_dict()) 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["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) return TokenResponse(response)
except TokenIntrospectionError: except TokenIntrospectionError:
return TokenResponse({"active": False}) return TokenResponse({"active": False})

View file

@ -1,14 +1,15 @@
"""authentik OAuth2 Token views""" """authentik OAuth2 Token views"""
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from dataclasses import InitVar, dataclass from dataclasses import InitVar, dataclass
from datetime import datetime
from hashlib import sha256 from hashlib import sha256
from re import error as RegexError from re import error as RegexError
from re import fullmatch from re import fullmatch
from typing import Any, Optional from typing import Any, Optional
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.timezone import datetime, now
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
@ -37,9 +38,12 @@ from authentik.providers.oauth2.constants import (
GRANT_TYPE_DEVICE_CODE, GRANT_TYPE_DEVICE_CODE,
GRANT_TYPE_PASSWORD, GRANT_TYPE_PASSWORD,
GRANT_TYPE_REFRESH_TOKEN, GRANT_TYPE_REFRESH_TOKEN,
TOKEN_TYPE,
) )
from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode, AuthorizationCode,
ClientTypes, ClientTypes,
DeviceToken, DeviceToken,
@ -198,18 +202,18 @@ class TokenParams:
).from_http(request) ).from_http(request)
raise TokenError("invalid_client") raise TokenError("invalid_client")
try: self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first()
self.authorization_code = AuthorizationCode.objects.get(code=raw_code) if not self.authorization_code:
if self.authorization_code.is_expired:
LOGGER.warning(
"Code is expired",
token=raw_code,
)
raise TokenError("invalid_grant")
except AuthorizationCode.DoesNotExist:
LOGGER.warning("Code does not exist", code=raw_code) LOGGER.warning("Code does not exist", code=raw_code)
raise TokenError("invalid_grant") 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: if self.authorization_code.provider != self.provider or self.authorization_code.is_expired:
LOGGER.warning("Invalid code: invalid client or code has expired") LOGGER.warning("Invalid code: invalid client or code has expired")
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
@ -234,26 +238,25 @@ class TokenParams:
LOGGER.warning("Missing refresh token") LOGGER.warning("Missing refresh token")
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
try: self.refresh_token = RefreshToken.objects.filter(
self.refresh_token = RefreshToken.objects.get( token=raw_token, provider=self.provider
refresh_token=raw_token, provider=self.provider ).first()
) if not self.refresh_token:
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:
LOGGER.warning( LOGGER.warning(
"Refresh token does not exist", "Refresh token does not exist",
token=raw_token, token=raw_token,
) )
raise TokenError("invalid_grant") 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: if self.refresh_token.revoked:
LOGGER.warning("Refresh token is revoked", token=raw_token) LOGGER.warning("Refresh token is revoked", token=raw_token)
Event.new( Event.new(
@ -401,7 +404,7 @@ class TokenParams:
"attributes": { "attributes": {
USER_ATTRIBUTE_GENERATED: True, USER_ATTRIBUTE_GENERATED: True,
}, },
"last_login": now(), "last_login": timezone.now(),
"name": f"Autogenerated user from application {app.name} (client credentials JWT)", "name": f"Autogenerated user from application {app.name} (client credentials JWT)",
"path": source.get_user_path(), "path": source.get_user_path(),
}, },
@ -436,14 +439,10 @@ class TokenView(View):
op="authentik.providers.oauth2.post.parse", op="authentik.providers.oauth2.post.parse",
): ):
client_id, client_secret = extract_client_auth(request) client_id, client_secret = extract_client_auth(request)
try: self.provider = OAuth2Provider.objects.filter(client_id=client_id).first()
self.provider = OAuth2Provider.objects.get(client_id=client_id) if not self.provider:
except OAuth2Provider.DoesNotExist:
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id) LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
raise TokenError("invalid_client") raise TokenError("invalid_client")
if not self.provider:
raise ValueError
self.params = TokenParams.parse(request, self.provider, client_id, client_secret) self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
with Hub.current.start_span( with Hub.current.start_span(
@ -468,122 +467,173 @@ class TokenView(View):
return TokenResponse(error.create_dict(), status=403) return TokenResponse(error.create_dict(), status=403)
def create_code_response(self) -> dict[str, Any]: def create_code_response(self) -> dict[str, Any]:
"""See https://tools.ietf.org/html/rfc6749#section-4.1""" """See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1"""
refresh_token = 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.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, user=self.params.authorization_code.user,
scope=self.params.authorization_code.scope, scope=self.params.authorization_code.scope,
request=self.request, expires=refresh_token_expiry,
expiry=timedelta_from_string(self.provider.token_validity), provider=self.provider,
) )
id_token = IDToken.new(
if self.params.authorization_code.is_open_id: self.provider,
id_token = refresh_token.create_id_token( refresh_token,
user=self.params.authorization_code.user, self.request,
request=self.request, )
) id_token.nonce = self.params.authorization_code.nonce
id_token.nonce = self.params.authorization_code.nonce id_token.at_hash = access_token.at_hash
id_token.at_hash = refresh_token.at_hash refresh_token.id_token = id_token
refresh_token.id_token = id_token
# Store the token.
refresh_token.save() refresh_token.save()
# We don't need to store the code anymore. # Delete old code
self.params.authorization_code.delete() self.params.authorization_code.delete()
return { return {
"access_token": refresh_token.access_token, "access_token": access_token.token,
"refresh_token": refresh_token.refresh_token, "refresh_token": refresh_token.token,
"token_type": "bearer", "token_type": TOKEN_TYPE,
"expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()), "expires_in": int(
"id_token": self.provider.encode(refresh_token.id_token.to_dict()), 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]: 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) unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope)
if unauthorized_scopes: if unauthorized_scopes:
raise TokenError("invalid_scope") raise TokenError("invalid_scope")
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, user=self.params.refresh_token.user,
scope=self.params.scope, expires=access_token_expiry,
request=self.request, # Keep same scopes as previous token
expiry=timedelta_from_string(self.provider.token_validity), 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. refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
if self.params.refresh_token.id_token: refresh_token = RefreshToken(
refresh_token.id_token = refresh_token.create_id_token( user=self.params.refresh_token.user,
user=self.params.refresh_token.user, scope=self.params.refresh_token.scope,
request=self.request, expires=refresh_token_expiry,
) provider=self.provider,
refresh_token.id_token.at_hash = refresh_token.at_hash )
id_token = IDToken.new(
# Store the refresh_token. self.provider,
refresh_token.save() 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 # Mark old token as revoked
self.params.refresh_token.revoked = True self.params.refresh_token.revoked = True
self.params.refresh_token.save() self.params.refresh_token.save()
return { return {
"access_token": refresh_token.access_token, "access_token": access_token.token,
"refresh_token": refresh_token.refresh_token, "refresh_token": refresh_token.token,
"token_type": "bearer", "token_type": TOKEN_TYPE,
"expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()), "expires_in": int(
"id_token": self.provider.encode(refresh_token.id_token.to_dict()), 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]: def create_client_credentials_response(self) -> dict[str, Any]:
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4""" """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, user=self.params.user,
expires=access_token_expiry,
scope=self.params.scope, scope=self.params.scope,
request=self.request,
expiry=timedelta_from_string(self.provider.token_validity),
) )
refresh_token.id_token = refresh_token.create_id_token( access_token.id_token = IDToken.new(
user=self.params.user, self.provider,
request=self.request, access_token,
self.request,
) )
refresh_token.id_token.at_hash = refresh_token.at_hash access_token.save()
# Store the refresh_token.
refresh_token.save()
return { return {
"access_token": refresh_token.access_token, "access_token": access_token.token,
"token_type": "bearer", "token_type": TOKEN_TYPE,
"expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()), "expires_in": int(
"id_token": self.provider.encode(refresh_token.id_token.to_dict()), 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]: def create_device_code_response(self) -> dict[str, Any]:
"""See https://datatracker.ietf.org/doc/html/rfc8628""" """See https://datatracker.ietf.org/doc/html/rfc8628"""
if not self.params.device_code.user: if not self.params.device_code.user:
raise DeviceCodeError("authorization_pending") 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, user=self.params.device_code.user,
scope=self.params.device_code.scope, scope=self.params.device_code.scope,
request=self.request, expires=refresh_token_expiry,
expiry=timedelta_from_string(self.provider.token_validity), provider=self.provider,
) )
refresh_token.id_token = refresh_token.create_id_token( id_token = IDToken.new(
user=self.params.device_code.user, self.provider,
request=self.request, refresh_token,
self.request,
) )
refresh_token.id_token.at_hash = refresh_token.at_hash id_token.at_hash = access_token.at_hash
refresh_token.id_token = id_token
# Store the refresh_token.
refresh_token.save() refresh_token.save()
# Delete device code
self.params.device_code.delete()
return { return {
"access_token": refresh_token.access_token, "access_token": access_token.token,
"token_type": "bearer", "refresh_token": refresh_token.token,
"token_type": TOKEN_TYPE,
"expires_in": int( "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),
} }

View file

@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenRevocationError 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 from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
LOGGER = get_logger() LOGGER = get_logger()
@ -18,31 +18,26 @@ LOGGER = get_logger()
class TokenRevocationParams: class TokenRevocationParams:
"""Parameters for Token Revocation""" """Parameters for Token Revocation"""
token: RefreshToken token: RefreshToken | AccessToken
provider: OAuth2Provider provider: OAuth2Provider
@staticmethod @staticmethod
def from_request(request: HttpRequest) -> "TokenRevocationParams": def from_request(request: HttpRequest) -> "TokenRevocationParams":
"""Extract required Parameters from HTTP Request""" """Extract required Parameters from HTTP Request"""
raw_token = request.POST.get("token") 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) provider = authenticate_provider(request)
if not provider: if not provider:
raise TokenRevocationError("invalid_client") raise TokenRevocationError("invalid_client")
try: access_token = AccessToken.objects.filter(token=raw_token).first()
token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter) if access_token:
except RefreshToken.DoesNotExist: return TokenRevocationParams(access_token, provider)
LOGGER.debug("Token does not exist", token=raw_token) refresh_token = RefreshToken.objects.filter(token=raw_token).first()
raise Http404 if refresh_token:
return TokenRevocationParams(refresh_token, provider)
return TokenRevocationParams(token=token, provider=provider) LOGGER.debug("Token does not exist", token=raw_token)
raise Http404
@method_decorator(csrf_exempt, name="dispatch") @method_decorator(csrf_exempt, name="dispatch")
@ -65,5 +60,6 @@ class TokenRevokeView(View):
except TokenRevocationError as exc: except TokenRevocationError as exc:
return TokenResponse(exc.create_dict(), status=401) return TokenResponse(exc.create_dict(), status=401)
except Http404: 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) return TokenResponse(data={}, status=200)

View file

@ -21,7 +21,12 @@ from authentik.providers.oauth2.constants import (
SCOPE_GITHUB_USER_READ, SCOPE_GITHUB_USER_READ,
SCOPE_OPENID, 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 from authentik.providers.oauth2.utils import TokenResponse, cors_allow, protected_resource_view
LOGGER = get_logger() LOGGER = get_logger()
@ -56,21 +61,22 @@ class UserInfoView(View):
) )
return scope_descriptions 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 """Get a dictionary of claims from scopes that the token
requires and are assigned to the provider.""" requires and are assigned to the provider."""
scopes_from_client = token.scope scopes_from_client = token.scope
final_claims = {} final_claims = {}
for scope in ScopeMapping.objects.filter( 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"): ).order_by("scope_name"):
scope: ScopeMapping
value = None value = None
try: try:
value = scope.evaluate( value = scope.evaluate(
user=token.user, user=token.user,
request=self.request, request=self.request,
provider=token.provider, provider=provider,
token=token, token=token,
) )
except PropertyMappingExpressionException as exc: except PropertyMappingExpressionException as exc:
@ -108,7 +114,7 @@ class UserInfoView(View):
"""Handle GET Requests for UserInfo""" """Handle GET Requests for UserInfo"""
if not self.token: if not self.token:
return HttpResponseBadRequest() return HttpResponseBadRequest()
claims = self.get_claims(self.token) claims = self.get_claims(self.token.provider, self.token)
claims["sub"] = self.token.id_token.sub claims["sub"] = self.token.id_token.sub
if self.token.id_token.nonce: if self.token.id_token.nonce:
claims["nonce"] = self.token.id_token.nonce claims["nonce"] = self.token.id_token.nonce

View file

@ -91,7 +91,8 @@ class ProxyProviderSerializer(ProviderSerializer):
"redirect_uris", "redirect_uris",
"cookie_domain", "cookie_domain",
"jwks_sources", "jwks_sources",
"token_validity", "access_token_validity",
"refresh_token_validity",
"outpost_set", "outpost_set",
] ]
@ -130,7 +131,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
assigned_application_name = ReadOnlyField(source="application.name") assigned_application_name = ReadOnlyField(source="application.name")
oidc_configuration = SerializerMethodField() oidc_configuration = SerializerMethodField()
token_validity = SerializerMethodField() access_token_validity = SerializerMethodField()
scopes_to_request = SerializerMethodField() scopes_to_request = SerializerMethodField()
@extend_schema_field(OpenIDConnectConfigurationSerializer) @extend_schema_field(OpenIDConnectConfigurationSerializer)
@ -138,9 +139,9 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
"""Embed OpenID Connect provider information""" """Embed OpenID Connect provider information"""
return ProviderInfoView(request=self.context["request"]._request).get_info(obj) 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""" """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]: def get_scopes_to_request(self, obj: ProxyProvider) -> list[str]:
"""Get all the scope names the outpost should request, """Get all the scope names the outpost should request,
@ -169,7 +170,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
"basic_auth_user_attribute", "basic_auth_user_attribute",
"mode", "mode",
"cookie_domain", "cookie_domain",
"token_validity", "access_token_validity",
"intercept_header_auth", "intercept_header_auth",
"scopes_to_request", "scopes_to_request",
"assigned_application_slug", "assigned_application_slug",

View file

@ -73,6 +73,7 @@
"authentik_policies_reputation.reputation", "authentik_policies_reputation.reputation",
"authentik_policies_reputation.reputationpolicy", "authentik_policies_reputation.reputationpolicy",
"authentik_providers_ldap.ldapprovider", "authentik_providers_ldap.ldapprovider",
"authentik_providers_oauth2.accesstoken",
"authentik_providers_oauth2.authorizationcode", "authentik_providers_oauth2.authorizationcode",
"authentik_providers_oauth2.oauth2provider", "authentik_providers_oauth2.oauth2provider",
"authentik_providers_oauth2.refreshtoken", "authentik_providers_oauth2.refreshtoken",

View file

@ -29,8 +29,8 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
} }
rs.SetMaxLength(math.MaxInt) rs.SetMaxLength(math.MaxInt)
rs.SetKeyPrefix(RedisKeyPrefix) rs.SetKeyPrefix(RedisKeyPrefix)
if p.TokenValidity.IsSet() { if p.AccessTokenValidity.IsSet() {
t := p.TokenValidity.Get() t := p.AccessTokenValidity.Get()
// Add one to the validity to ensure we don't have a session with indefinite length // Add one to the validity to ensure we don't have a session with indefinite length
rs.SetMaxAge(int(*t) + 1) rs.SetMaxAge(int(*t) + 1)
} else { } 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 // 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) cs.MaxLength(math.MaxInt)
if p.TokenValidity.IsSet() { if p.AccessTokenValidity.IsSet() {
t := p.TokenValidity.Get() t := p.AccessTokenValidity.Get()
// Add one to the validity to ensure we don't have a session with indefinite length // Add one to the validity to ensure we don't have a session with indefinite length
cs.MaxAge(int(*t) + 1) cs.MaxAge(int(*t) + 1)
} else { } else {

View file

@ -8015,6 +8015,165 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' 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/: /oauth2/authorization_codes/:
get: get:
operationId: oauth2_authorization_codes_list operationId: oauth2_authorization_codes_list
@ -8220,7 +8379,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PaginatedRefreshTokenModelList' $ref: '#/components/schemas/PaginatedTokenModelList'
description: '' description: ''
'400': '400':
content: content:
@ -8243,7 +8402,7 @@ paths:
name: id name: id
schema: schema:
type: integer type: integer
description: A unique integer value identifying this OAuth2 Token. description: A unique integer value identifying this OAuth2 Refresh Token.
required: true required: true
tags: tags:
- oauth2 - oauth2
@ -8254,7 +8413,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/RefreshTokenModel' $ref: '#/components/schemas/TokenModel'
description: '' description: ''
'400': '400':
content: content:
@ -8276,7 +8435,7 @@ paths:
name: id name: id
schema: schema:
type: integer type: integer
description: A unique integer value identifying this OAuth2 Token. description: A unique integer value identifying this OAuth2 Refresh Token.
required: true required: true
tags: tags:
- oauth2 - oauth2
@ -8306,7 +8465,7 @@ paths:
name: id name: id
schema: schema:
type: integer type: integer
description: A unique integer value identifying this OAuth2 Token. description: A unique integer value identifying this OAuth2 Refresh Token.
required: true required: true
tags: tags:
- oauth2 - oauth2
@ -14167,6 +14326,10 @@ paths:
name: access_code_validity name: access_code_validity
schema: schema:
type: string type: string
- in: query
name: access_token_validity
schema:
type: string
- in: query - in: query
name: application name: application
schema: schema:
@ -14237,6 +14400,10 @@ paths:
name: redirect_uris name: redirect_uris
schema: schema:
type: string type: string
- in: query
name: refresh_token_validity
schema:
type: string
- name: search - name: search
required: false required: false
in: query in: query
@ -14260,10 +14427,6 @@ paths:
- user_username - user_username
description: Configure what data should be used as unique User Identifier. description: Configure what data should be used as unique User Identifier.
For most cases, the default should be fine. For most cases, the default should be fine.
- in: query
name: token_validity
schema:
type: string
tags: tags:
- providers - providers
security: security:
@ -29311,7 +29474,11 @@ components:
type: string type: string
description: 'Access codes not valid on or after current time + this value description: 'Access codes not valid on or after current time + this value
(Format: hours=1;minutes=2;seconds=3).' (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 type: string
description: 'Tokens not valid on or after current time + this value (Format: description: 'Tokens not valid on or after current time + this value (Format:
hours=1;minutes=2;seconds=3).' hours=1;minutes=2;seconds=3).'
@ -29388,7 +29555,12 @@ components:
minLength: 1 minLength: 1
description: 'Access codes not valid on or after current time + this value description: 'Access codes not valid on or after current time + this value
(Format: hours=1;minutes=2;seconds=3).' (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 type: string
minLength: 1 minLength: 1
description: 'Tokens not valid on or after current time + this value (Format: description: 'Tokens not valid on or after current time + this value (Format:
@ -31765,41 +31937,6 @@ components:
required: required:
- pagination - pagination
- results - 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: PaginatedReputationList:
type: object type: object
properties: properties:
@ -32290,6 +32427,41 @@ components:
required: required:
- pagination - pagination
- results - 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: PaginatedUserConsentList:
type: object type: object
properties: properties:
@ -33948,7 +34120,12 @@ components:
minLength: 1 minLength: 1
description: 'Access codes not valid on or after current time + this value description: 'Access codes not valid on or after current time + this value
(Format: hours=1;minutes=2;seconds=3).' (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 type: string
minLength: 1 minLength: 1
description: 'Tokens not valid on or after current time + this value (Format: 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 title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
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 type: string
minLength: 1 minLength: 1
description: 'Tokens not valid on or after current time + this value (Format: description: 'Tokens not valid on or after current time + this value (Format:
@ -35723,7 +35905,7 @@ components:
Exclusive with internal_host. Exclusive with internal_host.
cookie_domain: cookie_domain:
type: string type: string
token_validity: access_token_validity:
type: number type: number
format: double format: double
nullable: true nullable: true
@ -35746,6 +35928,7 @@ components:
description: Application's display Name. description: Application's display Name.
readOnly: true readOnly: true
required: required:
- access_token_validity
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
- external_host - external_host
@ -35753,7 +35936,6 @@ components:
- oidc_configuration - oidc_configuration
- pk - pk
- scopes_to_request - scopes_to_request
- token_validity
ProxyProvider: ProxyProvider:
type: object type: object
description: ProxyProvider Serializer description: ProxyProvider Serializer
@ -35850,7 +36032,11 @@ components:
title: Any JWT signed by the JWK of the selected source can be used to title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
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 type: string
description: 'Tokens not valid on or after current time + this value (Format: description: 'Tokens not valid on or after current time + this value (Format:
hours=1;minutes=2;seconds=3).' 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 title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
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 type: string
minLength: 1 minLength: 1
description: 'Tokens not valid on or after current time + this value (Format: description: 'Tokens not valid on or after current time + this value (Format:
@ -35972,40 +36163,6 @@ components:
required: required:
- to - to
- type - 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: Reputation:
type: object type: object
description: Reputation Serializer description: Reputation Serializer
@ -37428,6 +37585,40 @@ components:
- identifier - identifier
- pk - pk
- user_obj - 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: TokenRequest:
type: object type: object
description: Token Serializer description: Token Serializer

View file

@ -253,18 +253,34 @@ ${this.instance?.redirectUris}</textarea
<ak-utils-time-delta-help></ak-utils-time-delta-help> <ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${t`Token validity`} label=${t`Access Token validity`}
?required=${true} ?required=${true}
name="tokenValidity" name="accessTokenValidity"
> >
<input <input
type="text" type="text"
value="${first(this.instance?.tokenValidity, "days=30")}" value="${first(this.instance?.accessTokenValidity, "minutes=5")}"
class="pf-c-form-control" class="pf-c-form-control"
required required
/> />
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${t`Configure how long refresh tokens and their id_tokens are valid for.`} ${t`Configure how long access tokens are valid for.`}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Refresh Token validity`}
?required=${true}
name="refreshTokenValidity"
>
<input
type="text"
value="${first(this.instance?.refreshTokenValidity, "days=30")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`Configure how long refresh tokens are valid for.`}
</p> </p>
<ak-utils-time-delta-help></ak-utils-time-delta-help> <ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal> </ak-form-element-horizontal>

View file

@ -342,10 +342,10 @@ export class ProxyProviderFormPage extends ModelForm<ProxyProvider, number> {
</div> </div>
<div class="pf-c-card__footer">${this.renderSettings()}</div> <div class="pf-c-card__footer">${this.renderSettings()}</div>
</div> </div>
<ak-form-element-horizontal label=${t`Token validity`} name="tokenValidity"> <ak-form-element-horizontal label=${t`Token validity`} name="accessTokenValidity">
<input <input
type="text" type="text"
value="${first(this.instance?.tokenValidity, "hours=24")}" value="${first(this.instance?.accessTokenValidity, "hours=24")}"
class="pf-c-form-control" class="pf-c-form-control"
/> />
<p class="pf-c-form__helper-text">${t`Configure how long tokens are valid for.`}</p> <p class="pf-c-form__helper-text">${t`Configure how long tokens are valid for.`}</p>

View file

@ -345,7 +345,7 @@ export class UserViewPage extends AKElement {
</section> </section>
<section <section
slot="page-oauth-refresh" slot="page-oauth-refresh"
data-tab-title="${t`OAuth Refresh Codes`}" data-tab-title="${t`OAuth Refresh Tokens`}"
class="pf-c-page__main-section pf-m-no-padding-mobile" class="pf-c-page__main-section pf-m-no-padding-mobile"
> >
<div class="pf-c-card"> <div class="pf-c-card">

View file

@ -12,10 +12,10 @@ import { customElement, property } from "lit/decorators.js";
import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css"; 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") @customElement("ak-user-oauth-refresh-list")
export class UserOAuthRefreshList extends Table<RefreshTokenModel> { export class UserOAuthRefreshList extends Table<TokenModel> {
expandable = true; expandable = true;
@property({ type: Number }) @property({ type: Number })
@ -25,7 +25,7 @@ export class UserOAuthRefreshList extends Table<RefreshTokenModel> {
return super.styles.concat(PFFlex); return super.styles.concat(PFFlex);
} }
async apiEndpoint(page: number): Promise<PaginatedResponse<RefreshTokenModel>> { async apiEndpoint(page: number): Promise<PaginatedResponse<TokenModel>> {
return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensList({ return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensList({
user: this.userId, user: this.userId,
ordering: "expires", ordering: "expires",
@ -46,7 +46,7 @@ export class UserOAuthRefreshList extends Table<RefreshTokenModel> {
]; ];
} }
renderExpanded(item: RefreshTokenModel): TemplateResult { renderExpanded(item: TokenModel): TemplateResult {
return html` <td role="cell" colspan="4"> return html` <td role="cell" colspan="4">
<div class="pf-c-table__expandable-row-content"> <div class="pf-c-table__expandable-row-content">
<div class="pf-l-flex"> <div class="pf-l-flex">
@ -64,7 +64,7 @@ export class UserOAuthRefreshList extends Table<RefreshTokenModel> {
renderToolbarSelected(): TemplateResult { renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1; const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk return html`<ak-forms-delete-bulk
objectLabel=${t`Refresh Code(s)`} objectLabel=${t`Refresh Tokens(s)`}
.objects=${this.selectedElements} .objects=${this.selectedElements}
.usedBy=${(item: ExpiringBaseGrantModel) => { .usedBy=${(item: ExpiringBaseGrantModel) => {
return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensUsedByList({ return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensUsedByList({
@ -83,7 +83,7 @@ export class UserOAuthRefreshList extends Table<RefreshTokenModel> {
</ak-forms-delete-bulk>`; </ak-forms-delete-bulk>`;
} }
row(item: RefreshTokenModel): TemplateResult[] { row(item: TokenModel): TemplateResult[] {
return [ return [
html`<a href="#/core/providers/${item.provider?.pk}"> ${item.provider?.name} </a>`, html`<a href="#/core/providers/${item.provider?.pk}"> ${item.provider?.name} </a>`,
html`<ak-label color=${item.revoked ? PFColor.Orange : PFColor.Green}> html`<ak-label color=${item.revoked ? PFColor.Orange : PFColor.Green}>

View file

@ -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. 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 ## Upgrading
This release does not introduce any new requirements. This release does not introduce any new requirements.