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:
parent
1f88330133
commit
af43330fd6
|
@ -49,7 +49,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo mark
|
||||
build:
|
||||
build-container:
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
- ci-outpost-mark
|
||||
|
@ -94,7 +94,8 @@ jobs:
|
|||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||
platforms: ${{ matrix.arch }}
|
||||
build-outpost-binary:
|
||||
context: .
|
||||
build-binary:
|
||||
timeout-minutes: 120
|
||||
needs:
|
||||
- ci-outpost-mark
|
||||
|
|
|
@ -42,7 +42,7 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
|
|||
|
||||
def auth_user_lookup(raw_header: bytes) -> Optional[User]:
|
||||
"""raw_header in the Format of `Bearer ....`"""
|
||||
from authentik.providers.oauth2.models import RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken
|
||||
|
||||
auth_credentials = validate_auth(raw_header)
|
||||
if not auth_credentials:
|
||||
|
@ -55,8 +55,8 @@ def auth_user_lookup(raw_header: bytes) -> Optional[User]:
|
|||
CTX_AUTH_VIA.set("api_token")
|
||||
return key_token.user
|
||||
# then try to auth via JWT
|
||||
jwt_token = RefreshToken.filter_not_expired(
|
||||
refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
jwt_token = AccessToken.filter_not_expired(
|
||||
token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
).first()
|
||||
if jwt_token:
|
||||
# Double-check scopes, since they are saved in a single string
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
"""Test API Authentication"""
|
||||
import json
|
||||
from base64 import b64encode
|
||||
|
||||
from django.conf import settings
|
||||
|
@ -11,7 +12,7 @@ from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
|||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||
|
||||
|
||||
class TestAPIAuth(TestCase):
|
||||
|
@ -63,24 +64,26 @@ class TestAPIAuth(TestCase):
|
|||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
refresh = RefreshToken.objects.create(
|
||||
refresh = AccessToken.objects.create(
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
_scope=SCOPE_AUTHENTIK_API,
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||
|
||||
def test_jwt_missing_scope(self):
|
||||
"""Test valid JWT"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
refresh = RefreshToken.objects.create(
|
||||
refresh = AccessToken.objects.create(
|
||||
user=create_test_admin_user(),
|
||||
provider=provider,
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
_scope="",
|
||||
_id_token=json.dumps({}),
|
||||
)
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.token}".encode()), refresh.user)
|
||||
|
|
|
@ -50,7 +50,11 @@ from authentik.policies.reputation.api import ReputationPolicyViewSet, Reputatio
|
|||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
||||
from authentik.providers.oauth2.api.providers import OAuth2ProviderViewSet
|
||||
from authentik.providers.oauth2.api.scopes import ScopeMappingViewSet
|
||||
from authentik.providers.oauth2.api.tokens import AuthorizationCodeViewSet, RefreshTokenViewSet
|
||||
from authentik.providers.oauth2.api.tokens import (
|
||||
AccessTokenViewSet,
|
||||
AuthorizationCodeViewSet,
|
||||
RefreshTokenViewSet,
|
||||
)
|
||||
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
|
||||
from authentik.providers.saml.api.property_mapping import SAMLPropertyMappingViewSet
|
||||
from authentik.providers.saml.api.providers import SAMLProviderViewSet
|
||||
|
@ -162,6 +166,7 @@ router.register("providers/saml", SAMLProviderViewSet)
|
|||
|
||||
router.register("oauth2/authorization_codes", AuthorizationCodeViewSet)
|
||||
router.register("oauth2/refresh_tokens", RefreshTokenViewSet)
|
||||
router.register("oauth2/access_tokens", AccessTokenViewSet)
|
||||
|
||||
router.register("propertymappings/all", PropertyMappingViewSet)
|
||||
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
|
||||
|
|
|
@ -13,7 +13,8 @@ from authentik.core.api.providers import ProviderSerializer
|
|||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import PassiveSerializer, PropertyMappingPreviewSerializer
|
||||
from authentik.core.models import Provider
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken, ScopeMapping
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, ScopeMapping
|
||||
|
||||
|
||||
class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
|
@ -27,7 +28,8 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
|||
"client_id",
|
||||
"client_secret",
|
||||
"access_code_validity",
|
||||
"token_validity",
|
||||
"access_token_validity",
|
||||
"refresh_token_validity",
|
||||
"include_claims_in_id_token",
|
||||
"signing_key",
|
||||
"redirect_uris",
|
||||
|
@ -64,7 +66,8 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
|||
"client_type",
|
||||
"client_id",
|
||||
"access_code_validity",
|
||||
"token_validity",
|
||||
"access_token_validity",
|
||||
"refresh_token_validity",
|
||||
"include_claims_in_id_token",
|
||||
"signing_key",
|
||||
"redirect_uris",
|
||||
|
@ -141,13 +144,17 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
|||
def preview_user(self, request: Request, pk: int) -> Response:
|
||||
"""Preview user data for provider"""
|
||||
provider: OAuth2Provider = self.get_object()
|
||||
temp_token = RefreshToken()
|
||||
temp_token.scope = ScopeMapping.objects.filter(provider=provider).values_list(
|
||||
scope_names = ScopeMapping.objects.filter(provider=provider).values_list(
|
||||
"scope_name", flat=True
|
||||
)
|
||||
temp_token.provider = provider
|
||||
temp_token.user = request.user
|
||||
serializer = PropertyMappingPreviewSerializer(
|
||||
instance={"preview": temp_token.create_id_token(request.user, request).to_dict()}
|
||||
temp_token = IDToken.new(
|
||||
provider,
|
||||
AccessToken(
|
||||
user=request.user,
|
||||
provider=provider,
|
||||
_scope=" ".join(scope_names),
|
||||
),
|
||||
request,
|
||||
)
|
||||
serializer = PropertyMappingPreviewSerializer(instance={"preview": temp_token.to_dict()})
|
||||
return Response(serializer.data)
|
||||
|
|
|
@ -13,7 +13,7 @@ from authentik.core.api.used_by import UsedByMixin
|
|||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import MetaNameSerializer
|
||||
from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer
|
||||
from authentik.providers.oauth2.models import AuthorizationCode, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||
|
||||
|
||||
class ExpiringBaseGrantModelSerializer(ModelSerializer, MetaNameSerializer):
|
||||
|
@ -29,7 +29,7 @@ class ExpiringBaseGrantModelSerializer(ModelSerializer, MetaNameSerializer):
|
|||
depth = 2
|
||||
|
||||
|
||||
class RefreshTokenModelSerializer(ExpiringBaseGrantModelSerializer):
|
||||
class TokenModelSerializer(ExpiringBaseGrantModelSerializer):
|
||||
"""Serializer for BaseGrantModel and RefreshToken"""
|
||||
|
||||
id_token = SerializerMethodField()
|
||||
|
@ -89,7 +89,33 @@ class RefreshTokenViewSet(
|
|||
"""RefreshToken Viewset"""
|
||||
|
||||
queryset = RefreshToken.objects.all()
|
||||
serializer_class = RefreshTokenModelSerializer
|
||||
serializer_class = TokenModelSerializer
|
||||
filterset_fields = ["user", "provider"]
|
||||
ordering = ["provider", "expires"]
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
SearchFilter,
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user if self.request else get_anonymous_user()
|
||||
if user.is_superuser:
|
||||
return super().get_queryset()
|
||||
return super().get_queryset().filter(user=user.pk)
|
||||
|
||||
|
||||
class AccessTokenViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
UsedByMixin,
|
||||
mixins.ListModelMixin,
|
||||
GenericViewSet,
|
||||
):
|
||||
"""AccessToken Viewset"""
|
||||
|
||||
queryset = AccessToken.objects.all()
|
||||
serializer_class = TokenModelSerializer
|
||||
filterset_fields = ["user", "provider"]
|
||||
ordering = ["provider", "expires"]
|
||||
filter_backends = [
|
||||
|
|
|
@ -19,6 +19,8 @@ SCOPE_OPENID = "openid"
|
|||
SCOPE_OPENID_PROFILE = "profile"
|
||||
SCOPE_OPENID_EMAIL = "email"
|
||||
|
||||
TOKEN_TYPE = "Bearer" # nosec
|
||||
|
||||
SCOPE_AUTHENTIK_API = "goauthentik.io/api"
|
||||
|
||||
# Read/write full user (including email)
|
||||
|
|
|
@ -93,7 +93,7 @@ class TokenIntrospectionError(OAuth2Error):
|
|||
"""
|
||||
Specific to the introspection endpoint. This error will be converted
|
||||
to an "active: false" response, as per the spec.
|
||||
See https://tools.ietf.org/html/rfc7662
|
||||
See https://datatracker.ietf.org/doc/html/rfc7662
|
||||
"""
|
||||
|
||||
|
||||
|
@ -102,7 +102,7 @@ class AuthorizeError(OAuth2Error):
|
|||
|
||||
errors = {
|
||||
# OAuth2 errors.
|
||||
# https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
# https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
|
||||
"invalid_request": "The request is otherwise malformed",
|
||||
"unauthorized_client": (
|
||||
"The client is not authorized to request an authorization code using this method"
|
||||
|
@ -185,7 +185,7 @@ class AuthorizeError(OAuth2Error):
|
|||
class TokenError(OAuth2Error):
|
||||
"""
|
||||
OAuth2 token endpoint errors.
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
||||
"""
|
||||
|
||||
errors = {
|
||||
|
@ -220,7 +220,7 @@ class TokenError(OAuth2Error):
|
|||
class TokenRevocationError(OAuth2Error):
|
||||
"""
|
||||
Specific to the revocation endpoint.
|
||||
See https://tools.ietf.org/html/rfc7662
|
||||
See https://datatracker.ietf.org/doc/html/rfc7662
|
||||
"""
|
||||
|
||||
errors = TokenError.errors | {
|
||||
|
@ -266,7 +266,7 @@ class DeviceCodeError(OAuth2Error):
|
|||
class BearerTokenError(OAuth2Error):
|
||||
"""
|
||||
OAuth2 errors.
|
||||
https://tools.ietf.org/html/rfc6750#section-3.1
|
||||
https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
|
||||
"""
|
||||
|
||||
errors = {
|
||||
|
|
|
@ -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())
|
|
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
|
@ -2,8 +2,6 @@
|
|||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from functools import cached_property
|
||||
from hashlib import sha256
|
||||
from typing import Any, Optional
|
||||
|
@ -15,26 +13,18 @@ from cryptography.hazmat.primitives.asymmetric.types import PRIVATE_KEY_TYPES
|
|||
from dacite.core import from_dict
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from jwt import encode
|
||||
from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.signals import get_login_event
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
|
||||
from authentik.providers.oauth2.constants import (
|
||||
ACR_AUTHENTIK_DEFAULT,
|
||||
AMR_MFA,
|
||||
AMR_PASSWORD,
|
||||
AMR_WEBAUTHN,
|
||||
)
|
||||
from authentik.providers.oauth2.id_token import IDToken, SubModes
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
|
||||
class ClientTypes(models.TextChoices):
|
||||
|
@ -61,25 +51,6 @@ class ResponseMode(models.TextChoices):
|
|||
FORM_POST = "form_post"
|
||||
|
||||
|
||||
class SubModes(models.TextChoices):
|
||||
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
|
||||
|
||||
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
|
||||
USER_ID = "user_id", _("Based on user ID")
|
||||
USER_USERNAME = "user_username", _("Based on the username")
|
||||
USER_EMAIL = (
|
||||
"user_email",
|
||||
_("Based on the User's Email. This is recommended over the UPN method."),
|
||||
)
|
||||
USER_UPN = (
|
||||
"user_upn",
|
||||
_(
|
||||
"Based on the User's UPN, only works if user has a 'upn' attribute set. "
|
||||
"Use this method only if you have different UPN and Mail domains."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class IssuerMode(models.TextChoices):
|
||||
"""Configure how the `iss` field is created."""
|
||||
|
||||
|
@ -187,7 +158,15 @@ class OAuth2Provider(Provider):
|
|||
"(Format: hours=1;minutes=2;seconds=3)."
|
||||
),
|
||||
)
|
||||
token_validity = models.TextField(
|
||||
access_token_validity = models.TextField(
|
||||
default="hours=1",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
"Tokens not valid on or after current time + this value "
|
||||
"(Format: hours=1;minutes=2;seconds=3)."
|
||||
),
|
||||
)
|
||||
refresh_token_validity = models.TextField(
|
||||
default="days=30",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
|
@ -230,24 +209,6 @@ class OAuth2Provider(Provider):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
def create_refresh_token(
|
||||
self,
|
||||
user: User,
|
||||
scope: list[str],
|
||||
request: HttpRequest,
|
||||
expiry: timedelta,
|
||||
) -> "RefreshToken":
|
||||
"""Create and populate a RefreshToken object."""
|
||||
token = RefreshToken(
|
||||
user=user,
|
||||
provider=self,
|
||||
refresh_token=base64.urlsafe_b64encode(generate_key().encode()).decode(),
|
||||
expires=timezone.now() + expiry,
|
||||
scope=scope,
|
||||
)
|
||||
token.access_token = token.create_access_token(user, request)
|
||||
return token
|
||||
|
||||
@cached_property
|
||||
def jwt_key(self) -> tuple[str | PRIVATE_KEY_TYPES, str]:
|
||||
"""Get either the configured certificate or the client secret"""
|
||||
|
@ -267,11 +228,15 @@ class OAuth2Provider(Provider):
|
|||
if self.issuer_mode == IssuerMode.GLOBAL:
|
||||
return request.build_absolute_uri("/")
|
||||
try:
|
||||
mountpoint = AuthentikProviderOAuth2Config.mountpoints[
|
||||
"authentik.providers.oauth2.urls"
|
||||
]
|
||||
# pylint: disable=no-member
|
||||
return request.build_absolute_uri(f"/{mountpoint}{self.application.slug}/")
|
||||
url = reverse(
|
||||
"authentik_providers_oauth2:provider-root",
|
||||
kwargs={
|
||||
# pylint: disable=no-member
|
||||
"application_slug": self.application.slug,
|
||||
},
|
||||
)
|
||||
return request.build_absolute_uri(url)
|
||||
# pylint: disable=no-member
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
|
@ -303,8 +268,6 @@ class OAuth2Provider(Provider):
|
|||
if self.signing_key:
|
||||
headers["kid"] = self.signing_key.kid
|
||||
key, alg = self.jwt_key
|
||||
# If the provider does not have an RSA Key assigned, it was switched to Symmetric
|
||||
self.refresh_from_db()
|
||||
return encode(payload, key, algorithm=alg, headers=headers)
|
||||
|
||||
class Meta:
|
||||
|
@ -338,7 +301,6 @@ class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
|
|||
|
||||
code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
|
||||
nonce = models.TextField(null=True, default=None, verbose_name=_("Nonce"))
|
||||
is_open_id = models.BooleanField(default=False, verbose_name=_("Is Authentication?"))
|
||||
code_challenge = models.CharField(max_length=255, null=True, verbose_name=_("Code Challenge"))
|
||||
code_challenge_method = models.CharField(
|
||||
max_length=255, null=True, verbose_name=_("Code Challenge Method")
|
||||
|
@ -368,88 +330,27 @@ class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
|
|||
return f"Authorization code for {self.provider} for user {self.user}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class IDToken:
|
||||
"""The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be
|
||||
Authenticated is the ID Token data structure. The ID Token is a security token that contains
|
||||
Claims about the Authentication of an End-User by an Authorization Server when using a Client,
|
||||
and potentially other requested Claims. The ID Token is represented as a
|
||||
JSON Web Token (JWT) [JWT].
|
||||
class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 access token, non-opaque using a JWT as identifier"""
|
||||
|
||||
https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||
|
||||
# All these fields need to optional so we can save an empty IDToken for non-OpenID flows.
|
||||
iss: Optional[str] = None
|
||||
sub: Optional[str] = None
|
||||
aud: Optional[str] = None
|
||||
exp: Optional[int] = None
|
||||
iat: Optional[int] = None
|
||||
auth_time: Optional[int] = None
|
||||
acr: Optional[str] = ACR_AUTHENTIK_DEFAULT
|
||||
amr: Optional[list[str]] = None
|
||||
|
||||
c_hash: Optional[str] = None
|
||||
nonce: Optional[str] = None
|
||||
at_hash: Optional[str] = None
|
||||
|
||||
claims: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert dataclass to dict, and update with keys from `claims`"""
|
||||
id_dict = asdict(self)
|
||||
# The following claims should be omitted if they aren't set instead of being
|
||||
# set to null
|
||||
if not self.at_hash:
|
||||
id_dict.pop("at_hash")
|
||||
if not self.nonce:
|
||||
id_dict.pop("nonce")
|
||||
if not self.c_hash:
|
||||
id_dict.pop("c_hash")
|
||||
if not self.amr:
|
||||
id_dict.pop("amr")
|
||||
if not self.auth_time:
|
||||
id_dict.pop("auth_time")
|
||||
id_dict.pop("claims")
|
||||
id_dict.update(self.claims)
|
||||
return id_dict
|
||||
|
||||
|
||||
class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Refresh Token"""
|
||||
|
||||
access_token = models.TextField(verbose_name=_("Access Token"))
|
||||
refresh_token = models.CharField(max_length=255, unique=True, verbose_name=_("Refresh Token"))
|
||||
_id_token = models.TextField(verbose_name=_("ID Token"))
|
||||
|
||||
@property
|
||||
def serializer(self) -> Serializer:
|
||||
from authentik.providers.oauth2.api.tokens import ExpiringBaseGrantModelSerializer
|
||||
|
||||
return ExpiringBaseGrantModelSerializer
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("OAuth2 Token")
|
||||
verbose_name_plural = _("OAuth2 Tokens")
|
||||
token = models.TextField()
|
||||
_id_token = models.TextField()
|
||||
|
||||
@property
|
||||
def id_token(self) -> IDToken:
|
||||
"""Load ID Token from json"""
|
||||
if self._id_token:
|
||||
raw_token = json.loads(self._id_token)
|
||||
return from_dict(IDToken, raw_token)
|
||||
return IDToken()
|
||||
raw_token = json.loads(self._id_token)
|
||||
return from_dict(IDToken, raw_token)
|
||||
|
||||
@id_token.setter
|
||||
def id_token(self, value: IDToken):
|
||||
self._id_token = json.dumps(asdict(value))
|
||||
|
||||
def __str__(self):
|
||||
return f"Refresh Token for {self.provider} for user {self.user}"
|
||||
self.token = value.to_access_token(self.provider)
|
||||
self._id_token = json.dumps(value.to_dict())
|
||||
|
||||
@property
|
||||
def at_hash(self):
|
||||
"""Get hashed access_token"""
|
||||
hashed_access_token = sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii")
|
||||
hashed_access_token = sha256(self.token.encode("ascii")).hexdigest().encode("ascii")
|
||||
return (
|
||||
base64.urlsafe_b64encode(
|
||||
binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2])
|
||||
|
@ -458,78 +359,52 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
|||
.decode("ascii")
|
||||
)
|
||||
|
||||
def create_access_token(self, user: User, request: HttpRequest) -> str:
|
||||
"""Create access token with a similar format as Okta, Keycloak, ADFS"""
|
||||
token = self.create_id_token(user, request).to_dict()
|
||||
token["cid"] = self.provider.client_id
|
||||
token["uid"] = generate_key()
|
||||
return self.provider.encode(token)
|
||||
@property
|
||||
def serializer(self) -> Serializer:
|
||||
from authentik.providers.oauth2.api.tokens import TokenModelSerializer
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def create_id_token(self, user: User, request: HttpRequest) -> IDToken:
|
||||
"""Creates the id_token.
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||
sub = ""
|
||||
if self.provider.sub_mode == SubModes.HASHED_USER_ID:
|
||||
sub = user.uid
|
||||
elif self.provider.sub_mode == SubModes.USER_ID:
|
||||
sub = str(user.pk)
|
||||
elif self.provider.sub_mode == SubModes.USER_EMAIL:
|
||||
sub = user.email
|
||||
elif self.provider.sub_mode == SubModes.USER_USERNAME:
|
||||
sub = user.username
|
||||
elif self.provider.sub_mode == SubModes.USER_UPN:
|
||||
sub = user.attributes.get("upn", user.uid)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Provider {self.provider} has invalid sub_mode selected: {self.provider.sub_mode}"
|
||||
)
|
||||
# Convert datetimes into timestamps.
|
||||
now = datetime.now()
|
||||
iat_time = int(now.timestamp())
|
||||
exp_time = int(self.expires.timestamp())
|
||||
return TokenModelSerializer
|
||||
|
||||
token = IDToken(
|
||||
iss=self.provider.get_issuer(request),
|
||||
sub=sub,
|
||||
aud=self.provider.client_id,
|
||||
exp=exp_time,
|
||||
iat=iat_time,
|
||||
)
|
||||
class Meta:
|
||||
verbose_name = _("OAuth2 Access Token")
|
||||
verbose_name_plural = _("OAuth2 Access Tokens")
|
||||
|
||||
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
|
||||
auth_event = get_login_event(request)
|
||||
if auth_event:
|
||||
auth_time = auth_event.created
|
||||
token.auth_time = int(auth_time.timestamp())
|
||||
# Also check which method was used for authentication
|
||||
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
|
||||
method_args = auth_event.context.get(PLAN_CONTEXT_METHOD_ARGS, {})
|
||||
amr = []
|
||||
if method == "password":
|
||||
amr.append(AMR_PASSWORD)
|
||||
if method == "auth_webauthn_pwl":
|
||||
amr.append(AMR_WEBAUTHN)
|
||||
if "mfa_devices" in method_args:
|
||||
if len(amr) > 0:
|
||||
amr.append(AMR_MFA)
|
||||
if amr:
|
||||
token.amr = amr
|
||||
def __str__(self):
|
||||
return f"Access Token for {self.provider} for user {self.user}"
|
||||
|
||||
# Include (or not) user standard claims in the id_token.
|
||||
if self.provider.include_claims_in_id_token:
|
||||
from authentik.providers.oauth2.views.userinfo import UserInfoView
|
||||
|
||||
user_info = UserInfoView()
|
||||
user_info.request = request
|
||||
claims = user_info.get_claims(self)
|
||||
token.claims = claims
|
||||
class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Refresh Token, opaque"""
|
||||
|
||||
return token
|
||||
token = models.TextField(default=generate_key)
|
||||
_id_token = models.TextField(verbose_name=_("ID Token"))
|
||||
|
||||
@property
|
||||
def id_token(self) -> IDToken:
|
||||
"""Load ID Token from json"""
|
||||
raw_token = json.loads(self._id_token)
|
||||
return from_dict(IDToken, raw_token)
|
||||
|
||||
@id_token.setter
|
||||
def id_token(self, value: IDToken):
|
||||
self._id_token = json.dumps(value.to_dict())
|
||||
|
||||
@property
|
||||
def serializer(self) -> Serializer:
|
||||
from authentik.providers.oauth2.api.tokens import TokenModelSerializer
|
||||
|
||||
return TokenModelSerializer
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("OAuth2 Refresh Token")
|
||||
verbose_name_plural = _("OAuth2 Refresh Tokens")
|
||||
|
||||
def __str__(self):
|
||||
return f"Refresh Token for {self.provider} for user {self.user}"
|
||||
|
||||
|
||||
class DeviceToken(ExpiringModel):
|
||||
"""Device token for OAuth device flow"""
|
||||
"""Temporary device token for OAuth device flow"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
"authentik_core.User", default=None, on_delete=models.CASCADE, null=True
|
||||
|
|
|
@ -11,12 +11,13 @@ from authentik.events.models import Event, EventAction
|
|||
from authentik.flows.challenge import ChallengeTypes
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.constants import TOKEN_TYPE
|
||||
from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams
|
||||
|
@ -293,21 +294,20 @@ class TestAuthorize(OAuthTestCase):
|
|||
def test_full_implicit(self):
|
||||
"""Test full authorization"""
|
||||
flow = create_test_flow()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=self.keypair,
|
||||
access_code_validity="seconds=100",
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
with patch(
|
||||
"authentik.providers.oauth2.models.get_login_event",
|
||||
"authentik.providers.oauth2.id_token.get_login_event",
|
||||
MagicMock(
|
||||
return_value=Event(
|
||||
action=EventAction.LOGIN,
|
||||
|
@ -330,16 +330,17 @@ class TestAuthorize(OAuthTestCase):
|
|||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
token: RefreshToken = RefreshToken.objects.filter(user=user).first()
|
||||
expires = timedelta_from_string(provider.access_code_validity).total_seconds()
|
||||
token: AccessToken = AccessToken.objects.filter(user=user).first()
|
||||
expires = timedelta_from_string(provider.access_token_validity).total_seconds()
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
"component": "xak-flow-redirect",
|
||||
"type": ChallengeTypes.REDIRECT.value,
|
||||
"to": (
|
||||
f"http://localhost#access_token={token.access_token}"
|
||||
f"&id_token={provider.encode(token.id_token.to_dict())}&token_type=bearer"
|
||||
f"http://localhost#access_token={token.token}"
|
||||
f"&id_token={provider.encode(token.id_token.to_dict())}"
|
||||
f"&token_type={TOKEN_TYPE}"
|
||||
f"&expires_in={int(expires)}&state={state}"
|
||||
),
|
||||
},
|
||||
|
@ -382,7 +383,7 @@ class TestAuthorize(OAuthTestCase):
|
|||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
token: RefreshToken = RefreshToken.objects.filter(user=user).first()
|
||||
token: AccessToken = AccessToken.objects.filter(user=user).first()
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
|
@ -391,10 +392,10 @@ class TestAuthorize(OAuthTestCase):
|
|||
"url": "http://localhost",
|
||||
"title": f"Redirecting to {app.name}...",
|
||||
"attrs": {
|
||||
"access_token": token.access_token,
|
||||
"access_token": token.token,
|
||||
"id_token": provider.encode(token.id_token.to_dict()),
|
||||
"token_type": "bearer",
|
||||
"expires_in": "60",
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": "3600",
|
||||
"state": state,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -9,7 +9,7 @@ from authentik.core.models import Application
|
|||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
|
@ -31,11 +31,16 @@ class TesOAuth2Introspection(OAuthTestCase):
|
|||
)
|
||||
self.app.save()
|
||||
self.user = create_test_admin_user()
|
||||
self.token: RefreshToken = RefreshToken.objects.create(
|
||||
self.auth = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
).decode()
|
||||
|
||||
def test_introspect_refresh(self):
|
||||
"""Test introspect"""
|
||||
token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
access_token=generate_id(),
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
|
@ -43,30 +48,52 @@ class TesOAuth2Introspection(OAuthTestCase):
|
|||
)
|
||||
),
|
||||
)
|
||||
self.auth = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
).decode()
|
||||
|
||||
def test_introspect(self):
|
||||
"""Test introspect"""
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-introspection"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"},
|
||||
data={"token": token.token},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
{
|
||||
"acr": ACR_AUTHENTIK_DEFAULT,
|
||||
"aud": None,
|
||||
"sub": "bar",
|
||||
"exp": None,
|
||||
"iat": None,
|
||||
"iss": "foo",
|
||||
"active": True,
|
||||
"client_id": self.provider.client_id,
|
||||
"scope": " ".join(self.token.scope),
|
||||
"scope": " ".join(token.scope),
|
||||
},
|
||||
)
|
||||
|
||||
def test_introspect_access(self):
|
||||
"""Test introspect"""
|
||||
token: AccessToken = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
IDToken("foo", "bar"),
|
||||
)
|
||||
),
|
||||
)
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-introspection"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={"token": token.token},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
{
|
||||
"acr": ACR_AUTHENTIK_DEFAULT,
|
||||
"sub": "bar",
|
||||
"iss": "foo",
|
||||
"active": True,
|
||||
"client_id": self.provider.client_id,
|
||||
"scope": " ".join(token.scope),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ from django.urls import reverse
|
|||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
|
@ -30,11 +30,16 @@ class TesOAuth2Revoke(OAuthTestCase):
|
|||
)
|
||||
self.app.save()
|
||||
self.user = create_test_admin_user()
|
||||
self.token: RefreshToken = RefreshToken.objects.create(
|
||||
self.auth = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
).decode()
|
||||
|
||||
def test_revoke_refresh(self):
|
||||
"""Test revoke"""
|
||||
token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
access_token=generate_id(),
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
|
@ -42,16 +47,34 @@ class TesOAuth2Revoke(OAuthTestCase):
|
|||
)
|
||||
),
|
||||
)
|
||||
self.auth = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
).decode()
|
||||
|
||||
def test_revoke(self):
|
||||
"""Test revoke"""
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-revoke"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"},
|
||||
data={
|
||||
"token": token.token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_revoke_access(self):
|
||||
"""Test revoke"""
|
||||
token: AccessToken = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
token=generate_id(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
IDToken("foo", "bar"),
|
||||
)
|
||||
),
|
||||
)
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-revoke"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={
|
||||
"token": token.token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
|
@ -60,7 +83,9 @@ class TesOAuth2Revoke(OAuthTestCase):
|
|||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-revoke"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={"token": self.token.refresh_token + "foo", "token_type_hint": "refresh_token"},
|
||||
data={
|
||||
"token": generate_id(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
|
@ -69,6 +94,8 @@ class TesOAuth2Revoke(OAuthTestCase):
|
|||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-revoke"),
|
||||
HTTP_AUTHORIZATION="Basic fqewr",
|
||||
data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"},
|
||||
data={
|
||||
"token": generate_id(),
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
"""Test token view"""
|
||||
from base64 import b64encode
|
||||
from json import dumps
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
|
@ -11,9 +12,15 @@ from authentik.lib.generators import generate_id, generate_key
|
|||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import AuthorizationCode, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.token import TokenParams
|
||||
|
||||
|
@ -91,13 +98,13 @@ class TestToken(OAuthTestCase):
|
|||
token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
user=user,
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
)
|
||||
request = self.factory.post(
|
||||
"/",
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||
"refresh_token": token.refresh_token,
|
||||
"refresh_token": token.token,
|
||||
"redirect_uri": "http://local.invalid",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
|
@ -120,9 +127,7 @@ class TestToken(OAuthTestCase):
|
|||
self.app.save()
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
code = AuthorizationCode.objects.create(
|
||||
code="foobar", provider=provider, user=user, is_open_id=True
|
||||
)
|
||||
code = AuthorizationCode.objects.create(code="foobar", provider=provider, user=user)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
|
@ -132,20 +137,21 @@ class TestToken(OAuthTestCase):
|
|||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
new_token: RefreshToken = RefreshToken.objects.filter(user=user).first()
|
||||
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
|
||||
refresh: RefreshToken = RefreshToken.objects.filter(user=user, provider=provider).first()
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
"access_token": new_token.access_token,
|
||||
"refresh_token": new_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 2592000,
|
||||
"access_token": access.token,
|
||||
"refresh_token": refresh.token,
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": 3600,
|
||||
"id_token": provider.encode(
|
||||
new_token.id_token.to_dict(),
|
||||
refresh.id_token.to_dict(),
|
||||
),
|
||||
},
|
||||
)
|
||||
self.validate_jwt(new_token, provider)
|
||||
self.validate_jwt(access, provider)
|
||||
|
||||
def test_refresh_token_view(self):
|
||||
"""test request param"""
|
||||
|
@ -165,36 +171,38 @@ class TestToken(OAuthTestCase):
|
|||
token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
user=user,
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
_id_token=dumps({}),
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||
"refresh_token": token.refresh_token,
|
||||
"refresh_token": token.token,
|
||||
"redirect_uri": "http://local.invalid",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
HTTP_ORIGIN="http://local.invalid",
|
||||
)
|
||||
new_token: RefreshToken = (
|
||||
RefreshToken.objects.filter(user=user).exclude(pk=token.pk).first()
|
||||
)
|
||||
self.assertEqual(response["Access-Control-Allow-Credentials"], "true")
|
||||
self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid")
|
||||
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
|
||||
refresh: RefreshToken = RefreshToken.objects.filter(
|
||||
user=user, provider=provider, revoked=False
|
||||
).first()
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
"access_token": new_token.access_token,
|
||||
"refresh_token": new_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 2592000,
|
||||
"access_token": access.token,
|
||||
"refresh_token": refresh.token,
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": 3600,
|
||||
"id_token": provider.encode(
|
||||
new_token.id_token.to_dict(),
|
||||
refresh.id_token.to_dict(),
|
||||
),
|
||||
},
|
||||
)
|
||||
self.validate_jwt(new_token, provider)
|
||||
self.validate_jwt(access, provider)
|
||||
|
||||
def test_refresh_token_view_invalid_origin(self):
|
||||
"""test request param"""
|
||||
|
@ -211,32 +219,34 @@ class TestToken(OAuthTestCase):
|
|||
token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
user=user,
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
_id_token=dumps({}),
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||
"refresh_token": token.refresh_token,
|
||||
"refresh_token": token.token,
|
||||
"redirect_uri": "http://local.invalid",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
HTTP_ORIGIN="http://another.invalid",
|
||||
)
|
||||
new_token: RefreshToken = (
|
||||
RefreshToken.objects.filter(user=user).exclude(pk=token.pk).first()
|
||||
)
|
||||
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
|
||||
refresh: RefreshToken = RefreshToken.objects.filter(
|
||||
user=user, provider=provider, revoked=False
|
||||
).first()
|
||||
self.assertNotIn("Access-Control-Allow-Credentials", response)
|
||||
self.assertNotIn("Access-Control-Allow-Origin", response)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
"access_token": new_token.access_token,
|
||||
"refresh_token": new_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": 2592000,
|
||||
"access_token": access.token,
|
||||
"refresh_token": refresh.token,
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": 3600,
|
||||
"id_token": provider.encode(
|
||||
new_token.id_token.to_dict(),
|
||||
refresh.id_token.to_dict(),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
@ -259,14 +269,15 @@ class TestToken(OAuthTestCase):
|
|||
token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=provider,
|
||||
user=user,
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
_id_token=dumps({}),
|
||||
)
|
||||
# Create initial refresh token
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||
"refresh_token": token.refresh_token,
|
||||
"refresh_token": token.token,
|
||||
"redirect_uri": "http://testserver",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
|
@ -280,7 +291,7 @@ class TestToken(OAuthTestCase):
|
|||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||
"refresh_token": new_token.refresh_token,
|
||||
"refresh_token": new_token.token,
|
||||
"redirect_uri": "http://local.invalid",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
|
@ -291,7 +302,7 @@ class TestToken(OAuthTestCase):
|
|||
reverse("authentik_providers_oauth2:token"),
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
|
||||
"refresh_token": new_token.refresh_token,
|
||||
"refresh_token": new_token.token,
|
||||
"redirect_uri": "http://local.invalid",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
|
|
|
@ -15,6 +15,7 @@ from authentik.providers.oauth2.constants import (
|
|||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
|
@ -142,7 +143,7 @@ class TestTokenClientCredentials(OAuthTestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], "bearer")
|
||||
self.assertEqual(body["token_type"], TOKEN_TYPE)
|
||||
_, alg = self.provider.jwt_key
|
||||
jwt = decode(
|
||||
body["access_token"],
|
||||
|
|
|
@ -16,6 +16,7 @@ from authentik.providers.oauth2.constants import (
|
|||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
@ -209,7 +210,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], "bearer")
|
||||
self.assertEqual(body["token_type"], TOKEN_TYPE)
|
||||
_, alg = self.provider.jwt_key
|
||||
jwt = decode(
|
||||
body["access_token"],
|
||||
|
|
|
@ -9,7 +9,7 @@ from authentik.core.models import Application
|
|||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken, ScopeMapping
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
|
@ -33,11 +33,10 @@ class TestUserinfo(OAuthTestCase):
|
|||
self.app.provider = self.provider
|
||||
self.app.save()
|
||||
self.user = create_test_admin_user()
|
||||
self.token: RefreshToken = RefreshToken.objects.create(
|
||||
self.token: AccessToken = AccessToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
access_token=generate_id(),
|
||||
refresh_token=generate_id(),
|
||||
token=generate_id(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
|
@ -50,7 +49,7 @@ class TestUserinfo(OAuthTestCase):
|
|||
"""test user info with all normal scopes"""
|
||||
res = self.client.get(
|
||||
reverse("authentik_providers_oauth2:userinfo"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token.token}",
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
|
@ -73,7 +72,7 @@ class TestUserinfo(OAuthTestCase):
|
|||
|
||||
res = self.client.get(
|
||||
reverse("authentik_providers_oauth2:userinfo"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token.access_token}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token.token}",
|
||||
)
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
|
|
|
@ -6,7 +6,7 @@ from jwt import decode
|
|||
|
||||
from authentik.core.tests.utils import create_test_cert
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
|
||||
|
||||
|
||||
class OAuthTestCase(TestCase):
|
||||
|
@ -25,19 +25,20 @@ class OAuthTestCase(TestCase):
|
|||
def setUpClass(cls) -> None:
|
||||
cls.keypair = create_test_cert()
|
||||
super().setUpClass()
|
||||
cls.maxDiff = None
|
||||
|
||||
def assert_non_none_or_unset(self, container: dict, key: str):
|
||||
"""Check that a key, if set, is not none"""
|
||||
if key in container:
|
||||
self.assertIsNotNone(container[key])
|
||||
|
||||
def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider) -> dict[str, Any]:
|
||||
def validate_jwt(self, token: AccessToken, provider: OAuth2Provider) -> dict[str, Any]:
|
||||
"""Validate that all required fields are set"""
|
||||
key, alg = provider.jwt_key
|
||||
if alg != JWTAlgorithms.HS256:
|
||||
key = provider.signing_key.public_key
|
||||
jwt = decode(
|
||||
token.access_token,
|
||||
token.token,
|
||||
key,
|
||||
algorithms=[alg],
|
||||
audience=provider.client_id,
|
||||
|
|
|
@ -40,6 +40,11 @@ urlpatterns = [
|
|||
name="end-session",
|
||||
),
|
||||
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(
|
||||
"<slug:application_slug>/.well-known/openid-configuration",
|
||||
ProviderInfoView.as_view(),
|
||||
|
|
|
@ -13,7 +13,7 @@ from structlog.stdlib import get_logger
|
|||
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.oauth2.errors import BearerTokenError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -119,7 +119,7 @@ def protected_resource_view(scopes: list[str]):
|
|||
"""View decorator. The client accesses protected resources by presenting the
|
||||
access token to the resource server.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-7
|
||||
https://datatracker.ietf.org/doc/html/rfc6749#section-7
|
||||
|
||||
This decorator also injects the token into `kwargs`"""
|
||||
|
||||
|
@ -133,9 +133,8 @@ def protected_resource_view(scopes: list[str]):
|
|||
LOGGER.debug("No token passed")
|
||||
raise BearerTokenError("invalid_token")
|
||||
|
||||
try:
|
||||
token: RefreshToken = RefreshToken.objects.get(access_token=access_token)
|
||||
except RefreshToken.DoesNotExist:
|
||||
token = AccessToken.objects.filter(token=access_token).first()
|
||||
if not token:
|
||||
LOGGER.debug("Token does not exist", access_token=access_token)
|
||||
raise BearerTokenError("invalid_token")
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
"""authentik OAuth2 Authorization views"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from json import dumps
|
||||
from re import error as RegexError
|
||||
from re import fullmatch
|
||||
from typing import Optional
|
||||
|
@ -37,6 +38,7 @@ from authentik.providers.oauth2.constants import (
|
|||
PROMPT_LOGIN,
|
||||
PROMPT_NONE,
|
||||
SCOPE_OPENID,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import (
|
||||
AuthorizeError,
|
||||
|
@ -44,7 +46,9 @@ from authentik.providers.oauth2.errors import (
|
|||
OAuth2Error,
|
||||
RedirectUriError,
|
||||
)
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
|
@ -264,8 +268,6 @@ class OAuthAuthorizationParams:
|
|||
code.expires = timezone.now() + timedelta_from_string(self.provider.access_code_validity)
|
||||
code.scope = self.scope
|
||||
code.nonce = self.nonce
|
||||
code.is_open_id = SCOPE_OPENID in self.scope
|
||||
|
||||
return code
|
||||
|
||||
|
||||
|
@ -517,13 +519,25 @@ class OAuthFulfillmentStage(StageView):
|
|||
"""Create implicit response's URL Fragment dictionary"""
|
||||
query_fragment = {}
|
||||
|
||||
token = self.provider.create_refresh_token(
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
token = AccessToken(
|
||||
user=self.request.user,
|
||||
scope=self.params.scope,
|
||||
request=self.request,
|
||||
expiry=timedelta_from_string(self.provider.access_code_validity),
|
||||
expires=access_token_expiry,
|
||||
provider=self.provider,
|
||||
)
|
||||
|
||||
id_token = IDToken.new(self.provider, token, self.request)
|
||||
id_token.nonce = self.params.nonce
|
||||
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
id_token.c_hash = code.c_hash
|
||||
token.id_token = id_token
|
||||
|
||||
# Check if response_type must include access_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
|
@ -531,47 +545,29 @@ class OAuthFulfillmentStage(StageView):
|
|||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
]:
|
||||
query_fragment["access_token"] = token.access_token
|
||||
query_fragment["access_token"] = token.token
|
||||
|
||||
# We don't need id_token if it's an OAuth2 request.
|
||||
if SCOPE_OPENID in self.params.scope:
|
||||
id_token = token.create_id_token(
|
||||
user=self.request.user,
|
||||
request=self.request,
|
||||
)
|
||||
id_token.nonce = self.params.nonce
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
# Get at_hash of the current token and update the id_token
|
||||
id_token.at_hash = token.at_hash
|
||||
query_fragment["id_token"] = self.provider.encode(id_token.to_dict())
|
||||
token._id_token = dumps(id_token.to_dict())
|
||||
|
||||
# Include at_hash when access_token is being returned.
|
||||
if "access_token" in query_fragment:
|
||||
id_token.at_hash = token.at_hash
|
||||
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
id_token.c_hash = code.c_hash
|
||||
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
query_fragment["id_token"] = self.provider.encode(id_token.to_dict())
|
||||
token.id_token = id_token
|
||||
|
||||
# Store the token.
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = "bearer" # nosec
|
||||
query_fragment["token_type"] = TOKEN_TYPE
|
||||
query_fragment["expires_in"] = int(
|
||||
timedelta_from_string(self.provider.access_code_validity).total_seconds()
|
||||
timedelta_from_string(self.provider.access_token_validity).total_seconds()
|
||||
)
|
||||
query_fragment["state"] = self.params.state if self.params.state else ""
|
||||
|
||||
return query_fragment
|
||||
|
|
|
@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import TokenIntrospectionError
|
||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -18,7 +18,7 @@ LOGGER = get_logger()
|
|||
class TokenIntrospectionParams:
|
||||
"""Parameters for Token Introspection"""
|
||||
|
||||
token: RefreshToken
|
||||
token: RefreshToken | AccessToken
|
||||
provider: OAuth2Provider
|
||||
|
||||
id_token: IDToken = field(init=False)
|
||||
|
@ -41,31 +41,26 @@ class TokenIntrospectionParams:
|
|||
def from_request(request: HttpRequest) -> "TokenIntrospectionParams":
|
||||
"""Extract required Parameters from HTTP Request"""
|
||||
raw_token = request.POST.get("token")
|
||||
token_type_hint = request.POST.get("token_type_hint", "access_token")
|
||||
token_filter = {token_type_hint: raw_token}
|
||||
|
||||
if token_type_hint not in ["access_token", "refresh_token"]:
|
||||
LOGGER.debug("token_type_hint has invalid value", value=token_type_hint)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
provider = authenticate_provider(request)
|
||||
if not provider:
|
||||
raise TokenIntrospectionError
|
||||
|
||||
token: RefreshToken = RefreshToken.objects.filter(provider=provider, **token_filter).first()
|
||||
if not token:
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
return TokenIntrospectionParams(token=token, provider=provider)
|
||||
access_token = AccessToken.objects.filter(token=raw_token).first()
|
||||
if access_token:
|
||||
return TokenIntrospectionParams(access_token, provider)
|
||||
refresh_token = RefreshToken.objects.filter(token=raw_token).first()
|
||||
if refresh_token:
|
||||
return TokenIntrospectionParams(refresh_token, provider)
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class TokenIntrospectionView(View):
|
||||
"""Token Introspection
|
||||
https://tools.ietf.org/html/rfc7662"""
|
||||
https://datatracker.ietf.org/doc/html/rfc7662"""
|
||||
|
||||
token: RefreshToken
|
||||
token: RefreshToken | AccessToken
|
||||
params: TokenIntrospectionParams
|
||||
provider: OAuth2Provider
|
||||
|
||||
|
@ -76,9 +71,9 @@ class TokenIntrospectionView(View):
|
|||
response = {}
|
||||
if self.params.id_token:
|
||||
response.update(self.params.id_token.to_dict())
|
||||
response["active"] = True
|
||||
response["active"] = not self.params.token.is_expired and not self.params.token.revoked
|
||||
response["scope"] = " ".join(self.params.token.scope)
|
||||
response["client_id"] = self.params.token.provider.client_id
|
||||
response["client_id"] = self.params.provider.client_id
|
||||
return TokenResponse(response)
|
||||
except TokenIntrospectionError:
|
||||
return TokenResponse({"active": False})
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
"""authentik OAuth2 Token views"""
|
||||
from base64 import urlsafe_b64encode
|
||||
from dataclasses import InitVar, dataclass
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from re import error as RegexError
|
||||
from re import fullmatch
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.timezone import datetime, now
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
|
@ -37,9 +38,12 @@ from authentik.providers.oauth2.constants import (
|
|||
GRANT_TYPE_DEVICE_CODE,
|
||||
GRANT_TYPE_PASSWORD,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
TOKEN_TYPE,
|
||||
)
|
||||
from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
ClientTypes,
|
||||
DeviceToken,
|
||||
|
@ -198,18 +202,18 @@ class TokenParams:
|
|||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
try:
|
||||
self.authorization_code = AuthorizationCode.objects.get(code=raw_code)
|
||||
if self.authorization_code.is_expired:
|
||||
LOGGER.warning(
|
||||
"Code is expired",
|
||||
token=raw_code,
|
||||
)
|
||||
raise TokenError("invalid_grant")
|
||||
except AuthorizationCode.DoesNotExist:
|
||||
self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first()
|
||||
if not self.authorization_code:
|
||||
LOGGER.warning("Code does not exist", code=raw_code)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if self.authorization_code.is_expired:
|
||||
LOGGER.warning(
|
||||
"Code is expired",
|
||||
token=raw_code,
|
||||
)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if self.authorization_code.provider != self.provider or self.authorization_code.is_expired:
|
||||
LOGGER.warning("Invalid code: invalid client or code has expired")
|
||||
raise TokenError("invalid_grant")
|
||||
|
@ -234,26 +238,25 @@ class TokenParams:
|
|||
LOGGER.warning("Missing refresh token")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
try:
|
||||
self.refresh_token = RefreshToken.objects.get(
|
||||
refresh_token=raw_token, provider=self.provider
|
||||
)
|
||||
if self.refresh_token.is_expired:
|
||||
LOGGER.warning(
|
||||
"Refresh token is expired",
|
||||
token=raw_token,
|
||||
)
|
||||
raise TokenError("invalid_grant")
|
||||
# https://tools.ietf.org/html/rfc6749#section-6
|
||||
# Fallback to original token's scopes when none are given
|
||||
if not self.scope:
|
||||
self.scope = self.refresh_token.scope
|
||||
except RefreshToken.DoesNotExist:
|
||||
self.refresh_token = RefreshToken.objects.filter(
|
||||
token=raw_token, provider=self.provider
|
||||
).first()
|
||||
if not self.refresh_token:
|
||||
LOGGER.warning(
|
||||
"Refresh token does not exist",
|
||||
token=raw_token,
|
||||
)
|
||||
raise TokenError("invalid_grant")
|
||||
if self.refresh_token.is_expired:
|
||||
LOGGER.warning(
|
||||
"Refresh token is expired",
|
||||
token=raw_token,
|
||||
)
|
||||
raise TokenError("invalid_grant")
|
||||
# https://datatracker.ietf.org/doc/html/rfc6749#section-6
|
||||
# Fallback to original token's scopes when none are given
|
||||
if not self.scope:
|
||||
self.scope = self.refresh_token.scope
|
||||
if self.refresh_token.revoked:
|
||||
LOGGER.warning("Refresh token is revoked", token=raw_token)
|
||||
Event.new(
|
||||
|
@ -401,7 +404,7 @@ class TokenParams:
|
|||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
},
|
||||
"last_login": now(),
|
||||
"last_login": timezone.now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials JWT)",
|
||||
"path": source.get_user_path(),
|
||||
},
|
||||
|
@ -436,14 +439,10 @@ class TokenView(View):
|
|||
op="authentik.providers.oauth2.post.parse",
|
||||
):
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
try:
|
||||
self.provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
self.provider = OAuth2Provider.objects.filter(client_id=client_id).first()
|
||||
if not self.provider:
|
||||
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
if not self.provider:
|
||||
raise ValueError
|
||||
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
||||
|
||||
with Hub.current.start_span(
|
||||
|
@ -468,122 +467,173 @@ class TokenView(View):
|
|||
return TokenResponse(error.create_dict(), status=403)
|
||||
|
||||
def create_code_response(self) -> dict[str, Any]:
|
||||
"""See https://tools.ietf.org/html/rfc6749#section-4.1"""
|
||||
refresh_token = self.provider.create_refresh_token(
|
||||
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1"""
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
access_token = AccessToken(
|
||||
provider=self.provider,
|
||||
user=self.params.authorization_code.user,
|
||||
expires=access_token_expiry,
|
||||
# Keep same scopes as previous token
|
||||
scope=self.params.authorization_code.scope,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
access_token,
|
||||
self.request,
|
||||
)
|
||||
access_token.save()
|
||||
|
||||
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
|
||||
refresh_token = RefreshToken(
|
||||
user=self.params.authorization_code.user,
|
||||
scope=self.params.authorization_code.scope,
|
||||
request=self.request,
|
||||
expiry=timedelta_from_string(self.provider.token_validity),
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
)
|
||||
|
||||
if self.params.authorization_code.is_open_id:
|
||||
id_token = refresh_token.create_id_token(
|
||||
user=self.params.authorization_code.user,
|
||||
request=self.request,
|
||||
)
|
||||
id_token.nonce = self.params.authorization_code.nonce
|
||||
id_token.at_hash = refresh_token.at_hash
|
||||
refresh_token.id_token = id_token
|
||||
|
||||
# Store the token.
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
refresh_token,
|
||||
self.request,
|
||||
)
|
||||
id_token.nonce = self.params.authorization_code.nonce
|
||||
id_token.at_hash = access_token.at_hash
|
||||
refresh_token.id_token = id_token
|
||||
refresh_token.save()
|
||||
|
||||
# We don't need to store the code anymore.
|
||||
# Delete old code
|
||||
self.params.authorization_code.delete()
|
||||
|
||||
return {
|
||||
"access_token": refresh_token.access_token,
|
||||
"refresh_token": refresh_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()),
|
||||
"id_token": self.provider.encode(refresh_token.id_token.to_dict()),
|
||||
"access_token": access_token.token,
|
||||
"refresh_token": refresh_token.token,
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": int(
|
||||
timedelta_from_string(self.provider.access_token_validity).total_seconds()
|
||||
),
|
||||
"id_token": id_token.to_jwt(self.provider),
|
||||
}
|
||||
|
||||
def create_refresh_response(self) -> dict[str, Any]:
|
||||
"""See https://tools.ietf.org/html/rfc6749#section-6"""
|
||||
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-6"""
|
||||
unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope)
|
||||
if unauthorized_scopes:
|
||||
raise TokenError("invalid_scope")
|
||||
|
||||
refresh_token: RefreshToken = self.provider.create_refresh_token(
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
access_token = AccessToken(
|
||||
provider=self.provider,
|
||||
user=self.params.refresh_token.user,
|
||||
scope=self.params.scope,
|
||||
request=self.request,
|
||||
expiry=timedelta_from_string(self.provider.token_validity),
|
||||
expires=access_token_expiry,
|
||||
# Keep same scopes as previous token
|
||||
scope=self.params.refresh_token.scope,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
access_token,
|
||||
self.request,
|
||||
)
|
||||
access_token.save()
|
||||
|
||||
# If the Token has an id_token it's an Authentication request.
|
||||
if self.params.refresh_token.id_token:
|
||||
refresh_token.id_token = refresh_token.create_id_token(
|
||||
user=self.params.refresh_token.user,
|
||||
request=self.request,
|
||||
)
|
||||
refresh_token.id_token.at_hash = refresh_token.at_hash
|
||||
|
||||
# Store the refresh_token.
|
||||
refresh_token.save()
|
||||
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
|
||||
refresh_token = RefreshToken(
|
||||
user=self.params.refresh_token.user,
|
||||
scope=self.params.refresh_token.scope,
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
)
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
refresh_token,
|
||||
self.request,
|
||||
)
|
||||
id_token.nonce = self.params.refresh_token.id_token.nonce
|
||||
id_token.at_hash = access_token.at_hash
|
||||
refresh_token.id_token = id_token
|
||||
refresh_token.save()
|
||||
|
||||
# Mark old token as revoked
|
||||
self.params.refresh_token.revoked = True
|
||||
self.params.refresh_token.save()
|
||||
|
||||
return {
|
||||
"access_token": refresh_token.access_token,
|
||||
"refresh_token": refresh_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()),
|
||||
"id_token": self.provider.encode(refresh_token.id_token.to_dict()),
|
||||
"access_token": access_token.token,
|
||||
"refresh_token": refresh_token.token,
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": int(
|
||||
timedelta_from_string(self.provider.access_token_validity).total_seconds()
|
||||
),
|
||||
"id_token": id_token.to_jwt(self.provider),
|
||||
}
|
||||
|
||||
def create_client_credentials_response(self) -> dict[str, Any]:
|
||||
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4"""
|
||||
refresh_token: RefreshToken = self.provider.create_refresh_token(
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
access_token = AccessToken(
|
||||
provider=self.provider,
|
||||
user=self.params.user,
|
||||
expires=access_token_expiry,
|
||||
scope=self.params.scope,
|
||||
request=self.request,
|
||||
expiry=timedelta_from_string(self.provider.token_validity),
|
||||
)
|
||||
refresh_token.id_token = refresh_token.create_id_token(
|
||||
user=self.params.user,
|
||||
request=self.request,
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
access_token,
|
||||
self.request,
|
||||
)
|
||||
refresh_token.id_token.at_hash = refresh_token.at_hash
|
||||
|
||||
# Store the refresh_token.
|
||||
refresh_token.save()
|
||||
|
||||
access_token.save()
|
||||
return {
|
||||
"access_token": refresh_token.access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": int(timedelta_from_string(self.provider.token_validity).total_seconds()),
|
||||
"id_token": self.provider.encode(refresh_token.id_token.to_dict()),
|
||||
"access_token": access_token.token,
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": int(
|
||||
timedelta_from_string(self.provider.access_token_validity).total_seconds()
|
||||
),
|
||||
"id_token": access_token.id_token.to_jwt(self.provider),
|
||||
}
|
||||
|
||||
def create_device_code_response(self) -> dict[str, Any]:
|
||||
"""See https://datatracker.ietf.org/doc/html/rfc8628"""
|
||||
if not self.params.device_code.user:
|
||||
raise DeviceCodeError("authorization_pending")
|
||||
now = timezone.now()
|
||||
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
|
||||
access_token = AccessToken(
|
||||
provider=self.provider,
|
||||
user=self.params.device_code.user,
|
||||
expires=access_token_expiry,
|
||||
scope=self.params.device_code.scope,
|
||||
)
|
||||
access_token.id_token = IDToken.new(
|
||||
self.provider,
|
||||
access_token,
|
||||
self.request,
|
||||
)
|
||||
access_token.save()
|
||||
|
||||
refresh_token: RefreshToken = self.provider.create_refresh_token(
|
||||
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity)
|
||||
refresh_token = RefreshToken(
|
||||
user=self.params.device_code.user,
|
||||
scope=self.params.device_code.scope,
|
||||
request=self.request,
|
||||
expiry=timedelta_from_string(self.provider.token_validity),
|
||||
expires=refresh_token_expiry,
|
||||
provider=self.provider,
|
||||
)
|
||||
refresh_token.id_token = refresh_token.create_id_token(
|
||||
user=self.params.device_code.user,
|
||||
request=self.request,
|
||||
id_token = IDToken.new(
|
||||
self.provider,
|
||||
refresh_token,
|
||||
self.request,
|
||||
)
|
||||
refresh_token.id_token.at_hash = refresh_token.at_hash
|
||||
|
||||
# Store the refresh_token.
|
||||
id_token.at_hash = access_token.at_hash
|
||||
refresh_token.id_token = id_token
|
||||
refresh_token.save()
|
||||
|
||||
# Delete device code
|
||||
self.params.device_code.delete()
|
||||
return {
|
||||
"access_token": refresh_token.access_token,
|
||||
"token_type": "bearer",
|
||||
"access_token": access_token.token,
|
||||
"refresh_token": refresh_token.token,
|
||||
"token_type": TOKEN_TYPE,
|
||||
"expires_in": int(
|
||||
timedelta_from_string(refresh_token.provider.token_validity).total_seconds()
|
||||
timedelta_from_string(self.provider.access_token_validity).total_seconds()
|
||||
),
|
||||
"id_token": self.provider.encode(refresh_token.id_token.to_dict()),
|
||||
"id_token": id_token.to_jwt(self.provider),
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import TokenRevocationError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -18,31 +18,26 @@ LOGGER = get_logger()
|
|||
class TokenRevocationParams:
|
||||
"""Parameters for Token Revocation"""
|
||||
|
||||
token: RefreshToken
|
||||
token: RefreshToken | AccessToken
|
||||
provider: OAuth2Provider
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "TokenRevocationParams":
|
||||
"""Extract required Parameters from HTTP Request"""
|
||||
raw_token = request.POST.get("token")
|
||||
token_type_hint = request.POST.get("token_type_hint", "access_token")
|
||||
token_filter = {token_type_hint: raw_token}
|
||||
|
||||
if token_type_hint not in ["access_token", "refresh_token"]:
|
||||
LOGGER.debug("token_type_hint has invalid value", value=token_type_hint)
|
||||
raise TokenRevocationError("unsupported_token_type")
|
||||
|
||||
provider = authenticate_provider(request)
|
||||
if not provider:
|
||||
raise TokenRevocationError("invalid_client")
|
||||
|
||||
try:
|
||||
token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter)
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
raise Http404
|
||||
|
||||
return TokenRevocationParams(token=token, provider=provider)
|
||||
access_token = AccessToken.objects.filter(token=raw_token).first()
|
||||
if access_token:
|
||||
return TokenRevocationParams(access_token, provider)
|
||||
refresh_token = RefreshToken.objects.filter(token=raw_token).first()
|
||||
if refresh_token:
|
||||
return TokenRevocationParams(refresh_token, provider)
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
raise Http404
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
|
@ -65,5 +60,6 @@ class TokenRevokeView(View):
|
|||
except TokenRevocationError as exc:
|
||||
return TokenResponse(exc.create_dict(), status=401)
|
||||
except Http404:
|
||||
# Token not found should return a HTTP 200 according to the specs
|
||||
# Token not found should return a HTTP 200
|
||||
# https://datatracker.ietf.org/doc/html/rfc7009#section-2.2
|
||||
return TokenResponse(data={}, status=200)
|
||||
|
|
|
@ -21,7 +21,12 @@ from authentik.providers.oauth2.constants import (
|
|||
SCOPE_GITHUB_USER_READ,
|
||||
SCOPE_OPENID,
|
||||
)
|
||||
from authentik.providers.oauth2.models import RefreshToken, ScopeMapping
|
||||
from authentik.providers.oauth2.models import (
|
||||
BaseGrantModel,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, protected_resource_view
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -56,21 +61,22 @@ class UserInfoView(View):
|
|||
)
|
||||
return scope_descriptions
|
||||
|
||||
def get_claims(self, token: RefreshToken) -> dict[str, Any]:
|
||||
def get_claims(self, provider: OAuth2Provider, token: BaseGrantModel) -> dict[str, Any]:
|
||||
"""Get a dictionary of claims from scopes that the token
|
||||
requires and are assigned to the provider."""
|
||||
|
||||
scopes_from_client = token.scope
|
||||
final_claims = {}
|
||||
for scope in ScopeMapping.objects.filter(
|
||||
provider=token.provider, scope_name__in=scopes_from_client
|
||||
provider=provider, scope_name__in=scopes_from_client
|
||||
).order_by("scope_name"):
|
||||
scope: ScopeMapping
|
||||
value = None
|
||||
try:
|
||||
value = scope.evaluate(
|
||||
user=token.user,
|
||||
request=self.request,
|
||||
provider=token.provider,
|
||||
provider=provider,
|
||||
token=token,
|
||||
)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
|
@ -108,7 +114,7 @@ class UserInfoView(View):
|
|||
"""Handle GET Requests for UserInfo"""
|
||||
if not self.token:
|
||||
return HttpResponseBadRequest()
|
||||
claims = self.get_claims(self.token)
|
||||
claims = self.get_claims(self.token.provider, self.token)
|
||||
claims["sub"] = self.token.id_token.sub
|
||||
if self.token.id_token.nonce:
|
||||
claims["nonce"] = self.token.id_token.nonce
|
||||
|
|
|
@ -91,7 +91,8 @@ class ProxyProviderSerializer(ProviderSerializer):
|
|||
"redirect_uris",
|
||||
"cookie_domain",
|
||||
"jwks_sources",
|
||||
"token_validity",
|
||||
"access_token_validity",
|
||||
"refresh_token_validity",
|
||||
"outpost_set",
|
||||
]
|
||||
|
||||
|
@ -130,7 +131,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
|||
assigned_application_name = ReadOnlyField(source="application.name")
|
||||
|
||||
oidc_configuration = SerializerMethodField()
|
||||
token_validity = SerializerMethodField()
|
||||
access_token_validity = SerializerMethodField()
|
||||
scopes_to_request = SerializerMethodField()
|
||||
|
||||
@extend_schema_field(OpenIDConnectConfigurationSerializer)
|
||||
|
@ -138,9 +139,9 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
|||
"""Embed OpenID Connect provider information"""
|
||||
return ProviderInfoView(request=self.context["request"]._request).get_info(obj)
|
||||
|
||||
def get_token_validity(self, obj: ProxyProvider) -> Optional[float]:
|
||||
def get_access_token_validity(self, obj: ProxyProvider) -> Optional[float]:
|
||||
"""Get token validity as second count"""
|
||||
return timedelta_from_string(obj.token_validity).total_seconds()
|
||||
return timedelta_from_string(obj.access_token_validity).total_seconds()
|
||||
|
||||
def get_scopes_to_request(self, obj: ProxyProvider) -> list[str]:
|
||||
"""Get all the scope names the outpost should request,
|
||||
|
@ -169,7 +170,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
|||
"basic_auth_user_attribute",
|
||||
"mode",
|
||||
"cookie_domain",
|
||||
"token_validity",
|
||||
"access_token_validity",
|
||||
"intercept_header_auth",
|
||||
"scopes_to_request",
|
||||
"assigned_application_slug",
|
||||
|
|
|
@ -73,6 +73,7 @@
|
|||
"authentik_policies_reputation.reputation",
|
||||
"authentik_policies_reputation.reputationpolicy",
|
||||
"authentik_providers_ldap.ldapprovider",
|
||||
"authentik_providers_oauth2.accesstoken",
|
||||
"authentik_providers_oauth2.authorizationcode",
|
||||
"authentik_providers_oauth2.oauth2provider",
|
||||
"authentik_providers_oauth2.refreshtoken",
|
||||
|
|
|
@ -29,8 +29,8 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
|
|||
}
|
||||
rs.SetMaxLength(math.MaxInt)
|
||||
rs.SetKeyPrefix(RedisKeyPrefix)
|
||||
if p.TokenValidity.IsSet() {
|
||||
t := p.TokenValidity.Get()
|
||||
if p.AccessTokenValidity.IsSet() {
|
||||
t := p.AccessTokenValidity.Get()
|
||||
// Add one to the validity to ensure we don't have a session with indefinite length
|
||||
rs.SetMaxAge(int(*t) + 1)
|
||||
} else {
|
||||
|
@ -49,8 +49,8 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
|
|||
|
||||
// Note, when using the FilesystemStore only the session.ID is written to a browser cookie, so this is explicit for the storage on disk
|
||||
cs.MaxLength(math.MaxInt)
|
||||
if p.TokenValidity.IsSet() {
|
||||
t := p.TokenValidity.Get()
|
||||
if p.AccessTokenValidity.IsSet() {
|
||||
t := p.AccessTokenValidity.Get()
|
||||
// Add one to the validity to ensure we don't have a session with indefinite length
|
||||
cs.MaxAge(int(*t) + 1)
|
||||
} else {
|
||||
|
|
363
schema.yml
363
schema.yml
|
@ -8015,6 +8015,165 @@ paths:
|
|||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
description: ''
|
||||
/oauth2/access_tokens/:
|
||||
get:
|
||||
operationId: oauth2_access_tokens_list
|
||||
description: AccessToken Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
required: false
|
||||
in: query
|
||||
description: Which field to use when ordering the results.
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
required: false
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
schema:
|
||||
type: integer
|
||||
- name: page_size
|
||||
required: false
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: provider
|
||||
schema:
|
||||
type: integer
|
||||
- name: search
|
||||
required: false
|
||||
in: query
|
||||
description: A search term.
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: user
|
||||
schema:
|
||||
type: integer
|
||||
tags:
|
||||
- oauth2
|
||||
security:
|
||||
- authentik: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedTokenModelList'
|
||||
description: ''
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
description: ''
|
||||
'403':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
description: ''
|
||||
/oauth2/access_tokens/{id}/:
|
||||
get:
|
||||
operationId: oauth2_access_tokens_retrieve
|
||||
description: AccessToken Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: integer
|
||||
description: A unique integer value identifying this OAuth2 Access Token.
|
||||
required: true
|
||||
tags:
|
||||
- oauth2
|
||||
security:
|
||||
- authentik: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenModel'
|
||||
description: ''
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
description: ''
|
||||
'403':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
description: ''
|
||||
delete:
|
||||
operationId: oauth2_access_tokens_destroy
|
||||
description: AccessToken Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: integer
|
||||
description: A unique integer value identifying this OAuth2 Access Token.
|
||||
required: true
|
||||
tags:
|
||||
- oauth2
|
||||
security:
|
||||
- authentik: []
|
||||
responses:
|
||||
'204':
|
||||
description: No response body
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
description: ''
|
||||
'403':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
description: ''
|
||||
/oauth2/access_tokens/{id}/used_by/:
|
||||
get:
|
||||
operationId: oauth2_access_tokens_used_by_list
|
||||
description: Get a list of all objects that use this object
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: integer
|
||||
description: A unique integer value identifying this OAuth2 Access Token.
|
||||
required: true
|
||||
tags:
|
||||
- oauth2
|
||||
security:
|
||||
- authentik: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UsedBy'
|
||||
description: ''
|
||||
'400':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
description: ''
|
||||
'403':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
description: ''
|
||||
/oauth2/authorization_codes/:
|
||||
get:
|
||||
operationId: oauth2_authorization_codes_list
|
||||
|
@ -8220,7 +8379,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedRefreshTokenModelList'
|
||||
$ref: '#/components/schemas/PaginatedTokenModelList'
|
||||
description: ''
|
||||
'400':
|
||||
content:
|
||||
|
@ -8243,7 +8402,7 @@ paths:
|
|||
name: id
|
||||
schema:
|
||||
type: integer
|
||||
description: A unique integer value identifying this OAuth2 Token.
|
||||
description: A unique integer value identifying this OAuth2 Refresh Token.
|
||||
required: true
|
||||
tags:
|
||||
- oauth2
|
||||
|
@ -8254,7 +8413,7 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RefreshTokenModel'
|
||||
$ref: '#/components/schemas/TokenModel'
|
||||
description: ''
|
||||
'400':
|
||||
content:
|
||||
|
@ -8276,7 +8435,7 @@ paths:
|
|||
name: id
|
||||
schema:
|
||||
type: integer
|
||||
description: A unique integer value identifying this OAuth2 Token.
|
||||
description: A unique integer value identifying this OAuth2 Refresh Token.
|
||||
required: true
|
||||
tags:
|
||||
- oauth2
|
||||
|
@ -8306,7 +8465,7 @@ paths:
|
|||
name: id
|
||||
schema:
|
||||
type: integer
|
||||
description: A unique integer value identifying this OAuth2 Token.
|
||||
description: A unique integer value identifying this OAuth2 Refresh Token.
|
||||
required: true
|
||||
tags:
|
||||
- oauth2
|
||||
|
@ -14167,6 +14326,10 @@ paths:
|
|||
name: access_code_validity
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: access_token_validity
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: application
|
||||
schema:
|
||||
|
@ -14237,6 +14400,10 @@ paths:
|
|||
name: redirect_uris
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: refresh_token_validity
|
||||
schema:
|
||||
type: string
|
||||
- name: search
|
||||
required: false
|
||||
in: query
|
||||
|
@ -14260,10 +14427,6 @@ paths:
|
|||
- user_username
|
||||
description: Configure what data should be used as unique User Identifier.
|
||||
For most cases, the default should be fine.
|
||||
- in: query
|
||||
name: token_validity
|
||||
schema:
|
||||
type: string
|
||||
tags:
|
||||
- providers
|
||||
security:
|
||||
|
@ -29311,7 +29474,11 @@ components:
|
|||
type: string
|
||||
description: 'Access codes not valid on or after current time + this value
|
||||
(Format: hours=1;minutes=2;seconds=3).'
|
||||
token_validity:
|
||||
access_token_validity:
|
||||
type: string
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
hours=1;minutes=2;seconds=3).'
|
||||
refresh_token_validity:
|
||||
type: string
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
hours=1;minutes=2;seconds=3).'
|
||||
|
@ -29388,7 +29555,12 @@ components:
|
|||
minLength: 1
|
||||
description: 'Access codes not valid on or after current time + this value
|
||||
(Format: hours=1;minutes=2;seconds=3).'
|
||||
token_validity:
|
||||
access_token_validity:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
hours=1;minutes=2;seconds=3).'
|
||||
refresh_token_validity:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
|
@ -31765,41 +31937,6 @@ components:
|
|||
required:
|
||||
- pagination
|
||||
- results
|
||||
PaginatedRefreshTokenModelList:
|
||||
type: object
|
||||
properties:
|
||||
pagination:
|
||||
type: object
|
||||
properties:
|
||||
next:
|
||||
type: number
|
||||
previous:
|
||||
type: number
|
||||
count:
|
||||
type: number
|
||||
current:
|
||||
type: number
|
||||
total_pages:
|
||||
type: number
|
||||
start_index:
|
||||
type: number
|
||||
end_index:
|
||||
type: number
|
||||
required:
|
||||
- next
|
||||
- previous
|
||||
- count
|
||||
- current
|
||||
- total_pages
|
||||
- start_index
|
||||
- end_index
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RefreshTokenModel'
|
||||
required:
|
||||
- pagination
|
||||
- results
|
||||
PaginatedReputationList:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -32290,6 +32427,41 @@ components:
|
|||
required:
|
||||
- pagination
|
||||
- results
|
||||
PaginatedTokenModelList:
|
||||
type: object
|
||||
properties:
|
||||
pagination:
|
||||
type: object
|
||||
properties:
|
||||
next:
|
||||
type: number
|
||||
previous:
|
||||
type: number
|
||||
count:
|
||||
type: number
|
||||
current:
|
||||
type: number
|
||||
total_pages:
|
||||
type: number
|
||||
start_index:
|
||||
type: number
|
||||
end_index:
|
||||
type: number
|
||||
required:
|
||||
- next
|
||||
- previous
|
||||
- count
|
||||
- current
|
||||
- total_pages
|
||||
- start_index
|
||||
- end_index
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TokenModel'
|
||||
required:
|
||||
- pagination
|
||||
- results
|
||||
PaginatedUserConsentList:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -33948,7 +34120,12 @@ components:
|
|||
minLength: 1
|
||||
description: 'Access codes not valid on or after current time + this value
|
||||
(Format: hours=1;minutes=2;seconds=3).'
|
||||
token_validity:
|
||||
access_token_validity:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
hours=1;minutes=2;seconds=3).'
|
||||
refresh_token_validity:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
|
@ -34413,7 +34590,12 @@ components:
|
|||
title: Any JWT signed by the JWK of the selected source can be used to
|
||||
authenticate.
|
||||
title: Any JWT signed by the JWK of the selected source can be used to authenticate.
|
||||
token_validity:
|
||||
access_token_validity:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
hours=1;minutes=2;seconds=3).'
|
||||
refresh_token_validity:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
|
@ -35723,7 +35905,7 @@ components:
|
|||
Exclusive with internal_host.
|
||||
cookie_domain:
|
||||
type: string
|
||||
token_validity:
|
||||
access_token_validity:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
|
@ -35746,6 +35928,7 @@ components:
|
|||
description: Application's display Name.
|
||||
readOnly: true
|
||||
required:
|
||||
- access_token_validity
|
||||
- assigned_application_name
|
||||
- assigned_application_slug
|
||||
- external_host
|
||||
|
@ -35753,7 +35936,6 @@ components:
|
|||
- oidc_configuration
|
||||
- pk
|
||||
- scopes_to_request
|
||||
- token_validity
|
||||
ProxyProvider:
|
||||
type: object
|
||||
description: ProxyProvider Serializer
|
||||
|
@ -35850,7 +36032,11 @@ components:
|
|||
title: Any JWT signed by the JWK of the selected source can be used to
|
||||
authenticate.
|
||||
title: Any JWT signed by the JWK of the selected source can be used to authenticate.
|
||||
token_validity:
|
||||
access_token_validity:
|
||||
type: string
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
hours=1;minutes=2;seconds=3).'
|
||||
refresh_token_validity:
|
||||
type: string
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
hours=1;minutes=2;seconds=3).'
|
||||
|
@ -35941,7 +36127,12 @@ components:
|
|||
title: Any JWT signed by the JWK of the selected source can be used to
|
||||
authenticate.
|
||||
title: Any JWT signed by the JWK of the selected source can be used to authenticate.
|
||||
token_validity:
|
||||
access_token_validity:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
hours=1;minutes=2;seconds=3).'
|
||||
refresh_token_validity:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: 'Tokens not valid on or after current time + this value (Format:
|
||||
|
@ -35972,40 +36163,6 @@ components:
|
|||
required:
|
||||
- to
|
||||
- type
|
||||
RefreshTokenModel:
|
||||
type: object
|
||||
description: Serializer for BaseGrantModel and RefreshToken
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
readOnly: true
|
||||
title: ID
|
||||
provider:
|
||||
$ref: '#/components/schemas/OAuth2Provider'
|
||||
user:
|
||||
$ref: '#/components/schemas/User'
|
||||
is_expired:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
expires:
|
||||
type: string
|
||||
format: date-time
|
||||
scope:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
id_token:
|
||||
type: string
|
||||
readOnly: true
|
||||
revoked:
|
||||
type: boolean
|
||||
required:
|
||||
- id_token
|
||||
- is_expired
|
||||
- pk
|
||||
- provider
|
||||
- scope
|
||||
- user
|
||||
Reputation:
|
||||
type: object
|
||||
description: Reputation Serializer
|
||||
|
@ -37428,6 +37585,40 @@ components:
|
|||
- identifier
|
||||
- pk
|
||||
- user_obj
|
||||
TokenModel:
|
||||
type: object
|
||||
description: Serializer for BaseGrantModel and RefreshToken
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
readOnly: true
|
||||
title: ID
|
||||
provider:
|
||||
$ref: '#/components/schemas/OAuth2Provider'
|
||||
user:
|
||||
$ref: '#/components/schemas/User'
|
||||
is_expired:
|
||||
type: boolean
|
||||
readOnly: true
|
||||
expires:
|
||||
type: string
|
||||
format: date-time
|
||||
scope:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
id_token:
|
||||
type: string
|
||||
readOnly: true
|
||||
revoked:
|
||||
type: boolean
|
||||
required:
|
||||
- id_token
|
||||
- is_expired
|
||||
- pk
|
||||
- provider
|
||||
- scope
|
||||
- user
|
||||
TokenRequest:
|
||||
type: object
|
||||
description: Token Serializer
|
||||
|
|
|
@ -253,18 +253,34 @@ ${this.instance?.redirectUris}</textarea
|
|||
<ak-utils-time-delta-help></ak-utils-time-delta-help>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${t`Token validity`}
|
||||
label=${t`Access Token validity`}
|
||||
?required=${true}
|
||||
name="tokenValidity"
|
||||
name="accessTokenValidity"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value="${first(this.instance?.tokenValidity, "days=30")}"
|
||||
value="${first(this.instance?.accessTokenValidity, "minutes=5")}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
<ak-utils-time-delta-help></ak-utils-time-delta-help>
|
||||
</ak-form-element-horizontal>
|
||||
|
|
|
@ -342,10 +342,10 @@ export class ProxyProviderFormPage extends ModelForm<ProxyProvider, number> {
|
|||
</div>
|
||||
<div class="pf-c-card__footer">${this.renderSettings()}</div>
|
||||
</div>
|
||||
<ak-form-element-horizontal label=${t`Token validity`} name="tokenValidity">
|
||||
<ak-form-element-horizontal label=${t`Token validity`} name="accessTokenValidity">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(this.instance?.tokenValidity, "hours=24")}"
|
||||
value="${first(this.instance?.accessTokenValidity, "hours=24")}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">${t`Configure how long tokens are valid for.`}</p>
|
||||
|
|
|
@ -345,7 +345,7 @@ export class UserViewPage extends AKElement {
|
|||
</section>
|
||||
<section
|
||||
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"
|
||||
>
|
||||
<div class="pf-c-card">
|
||||
|
|
|
@ -12,10 +12,10 @@ import { customElement, property } from "lit/decorators.js";
|
|||
|
||||
import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css";
|
||||
|
||||
import { ExpiringBaseGrantModel, Oauth2Api, RefreshTokenModel } from "@goauthentik/api";
|
||||
import { ExpiringBaseGrantModel, Oauth2Api, TokenModel } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-user-oauth-refresh-list")
|
||||
export class UserOAuthRefreshList extends Table<RefreshTokenModel> {
|
||||
export class UserOAuthRefreshList extends Table<TokenModel> {
|
||||
expandable = true;
|
||||
|
||||
@property({ type: Number })
|
||||
|
@ -25,7 +25,7 @@ export class UserOAuthRefreshList extends Table<RefreshTokenModel> {
|
|||
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({
|
||||
user: this.userId,
|
||||
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">
|
||||
<div class="pf-c-table__expandable-row-content">
|
||||
<div class="pf-l-flex">
|
||||
|
@ -64,7 +64,7 @@ export class UserOAuthRefreshList extends Table<RefreshTokenModel> {
|
|||
renderToolbarSelected(): TemplateResult {
|
||||
const disabled = this.selectedElements.length < 1;
|
||||
return html`<ak-forms-delete-bulk
|
||||
objectLabel=${t`Refresh Code(s)`}
|
||||
objectLabel=${t`Refresh Tokens(s)`}
|
||||
.objects=${this.selectedElements}
|
||||
.usedBy=${(item: ExpiringBaseGrantModel) => {
|
||||
return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensUsedByList({
|
||||
|
@ -83,7 +83,7 @@ export class UserOAuthRefreshList extends Table<RefreshTokenModel> {
|
|||
</ak-forms-delete-bulk>`;
|
||||
}
|
||||
|
||||
row(item: RefreshTokenModel): TemplateResult[] {
|
||||
row(item: TokenModel): TemplateResult[] {
|
||||
return [
|
||||
html`<a href="#/core/providers/${item.provider?.pk}"> ${item.provider?.name} </a>`,
|
||||
html`<ak-label color=${item.revoked ? PFColor.Orange : PFColor.Green}>
|
||||
|
|
|
@ -13,6 +13,10 @@ slug: "/releases/2023.2"
|
|||
|
||||
As with the previous improvements, we've made a lot of minor improvements to the general authentik UX to make your life easier.
|
||||
|
||||
- OAuth2 Provider improvements
|
||||
|
||||
The OAuth2 provider has been reworked to be closer to OAuth specifications and better support refresh tokens and offline access. Additionally the expiry for access tokens and refresh tokens can be adjusted separately now.
|
||||
|
||||
## Upgrading
|
||||
|
||||
This release does not introduce any new requirements.
|
||||
|
|
Reference in New Issue