providers/oauth2: rework OAuth2 Provider (#4652)

* always treat flow as openid flow

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

* improve issuer URL generation

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

* more refactoring

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

* update introspection

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

* more refinement

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

* migrate more

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

* fix more things, update api

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

* regen migrations

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

* fix a bunch of things

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

* start updating tests

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

* fix implicit flow, auto set exp

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

* fix timeozone not used correctly

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

* fix revoke

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

* more timezone shenanigans

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

* fix userinfo tests

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

* update web

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

* fix proxy outpost

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

* fix api tests

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

* fix missing at_hash for implicit flows

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

* fix tests

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

* re-include at_hash in implicit auth flow

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

* use folder context for outpost build

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

---------

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

View file

@ -49,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
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

View file

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

View file

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

View file

@ -50,7 +50,11 @@ from authentik.policies.reputation.api import ReputationPolicyViewSet, Reputatio
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
from authentik.providers.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)

View file

@ -13,7 +13,8 @@ from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.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)

View file

@ -13,7 +13,7 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.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 = [

View file

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

View file

@ -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 = {

View file

@ -0,0 +1,163 @@
"""id_token utils"""
from dataclasses import asdict, dataclass, field
from typing import TYPE_CHECKING, Any, Optional
from django.db import models
from django.http import HttpRequest
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from authentik.events.signals import get_login_event
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.constants import (
ACR_AUTHENTIK_DEFAULT,
AMR_MFA,
AMR_PASSWORD,
AMR_WEBAUTHN,
)
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
if TYPE_CHECKING:
from authentik.providers.oauth2.models import BaseGrantModel, OAuth2Provider
class SubModes(models.TextChoices):
"""Mode after which 'sub' attribute is generateed, for compatibility reasons"""
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
USER_ID = "user_id", _("Based on user ID")
USER_USERNAME = "user_username", _("Based on the username")
USER_EMAIL = (
"user_email",
_("Based on the User's Email. This is recommended over the UPN method."),
)
USER_UPN = (
"user_upn",
_(
"Based on the User's UPN, only works if user has a 'upn' attribute set. "
"Use this method only if you have different UPN and Mail domains."
),
)
@dataclass
# pylint: disable=too-many-instance-attributes
class IDToken:
"""The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be
Authenticated is the ID Token data structure. The ID Token is a security token that contains
Claims about the Authentication of an End-User by an Authorization Server when using a Client,
and potentially other requested Claims. The ID Token is represented as a
JSON Web Token (JWT) [JWT].
https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
# Issuer, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1
iss: Optional[str] = None
# Subject, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.2
sub: Optional[str] = None
# Audience, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3
aud: Optional[str] = None
# Expiration time, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4
exp: Optional[int] = None
# Issued at, https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6
iat: Optional[int] = None
# Time when the authentication occurred,
# https://openid.net/specs/openid-connect-core-1_0.html#IDToken
auth_time: Optional[int] = None
# Authentication Context Class Reference,
# https://openid.net/specs/openid-connect-core-1_0.html#IDToken
acr: Optional[str] = ACR_AUTHENTIK_DEFAULT
# Authentication Methods References,
# https://openid.net/specs/openid-connect-core-1_0.html#IDToken
amr: Optional[list[str]] = None
# Code hash value, http://openid.net/specs/openid-connect-core-1_0.html
c_hash: Optional[str] = None
# Value used to associate a Client session with an ID Token,
# http://openid.net/specs/openid-connect-core-1_0.html
nonce: Optional[str] = None
# Access Token hash value, http://openid.net/specs/openid-connect-core-1_0.html
at_hash: Optional[str] = None
claims: dict[str, Any] = field(default_factory=dict)
@staticmethod
# pylint: disable=too-many-locals
def new(
provider: "OAuth2Provider", token: "BaseGrantModel", request: HttpRequest, **kwargs
) -> "IDToken":
"""Create ID Token"""
id_token = IDToken(provider, token, **kwargs)
id_token.exp = int(token.expires.timestamp())
id_token.iss = provider.get_issuer(request)
id_token.aud = provider.client_id
id_token.claims = {}
if provider.sub_mode == SubModes.HASHED_USER_ID:
id_token.sub = token.user.uid
elif provider.sub_mode == SubModes.USER_ID:
id_token.sub = str(token.user.pk)
elif provider.sub_mode == SubModes.USER_EMAIL:
id_token.sub = token.user.email
elif provider.sub_mode == SubModes.USER_USERNAME:
id_token.sub = token.user.username
elif provider.sub_mode == SubModes.USER_UPN:
id_token.sub = token.user.attributes.get("upn", token.user.uid)
else:
raise ValueError(
f"Provider {provider} has invalid sub_mode selected: {provider.sub_mode}"
)
# Convert datetimes into timestamps.
now = timezone.now()
id_token.iat = int(now.timestamp())
# We use the timestamp of the user's last successful login (EventAction.LOGIN) for auth_time
auth_event = get_login_event(request)
if auth_event:
auth_time = auth_event.created
id_token.auth_time = int(auth_time.timestamp())
# Also check which method was used for authentication
method = auth_event.context.get(PLAN_CONTEXT_METHOD, "")
method_args = auth_event.context.get(PLAN_CONTEXT_METHOD_ARGS, {})
amr = []
if method == "password":
amr.append(AMR_PASSWORD)
if method == "auth_webauthn_pwl":
amr.append(AMR_WEBAUTHN)
if "mfa_devices" in method_args:
if len(amr) > 0:
amr.append(AMR_MFA)
if amr:
id_token.amr = amr
# Include (or not) user standard claims in the id_token.
if provider.include_claims_in_id_token:
from authentik.providers.oauth2.views.userinfo import UserInfoView
user_info = UserInfoView()
user_info.request = request
id_token.claims = user_info.get_claims(token.provider, token)
return id_token
def to_dict(self) -> dict[str, Any]:
"""Convert dataclass to dict, and update with keys from `claims`"""
id_dict = asdict(self)
# All items without a value should be removed instead being set to None/null
# https://openid.net/specs/openid-connect-core-1_0.html#JSONSerialization
for key in list(id_dict.keys()):
if id_dict[key] is None:
id_dict.pop(key)
id_dict.pop("claims")
id_dict.update(self.claims)
return id_dict
def to_access_token(self, provider: "OAuth2Provider") -> str:
"""Encode id_token for use as access token, adding fields"""
final = self.to_dict()
final["azp"] = provider.client_id
final["uid"] = generate_id()
return provider.encode(final)
def to_jwt(self, provider: "OAuth2Provider") -> str:
"""Shortcut to encode id_token to jwt, signed by self.provider"""
return provider.encode(self.to_dict())

View file

@ -0,0 +1,117 @@
# Generated by Django 4.1.6 on 2023-02-09 13:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import authentik.core.models
import authentik.lib.generators
import authentik.lib.utils.time
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("authentik_providers_oauth2", "0013_devicetoken"),
]
operations = [
migrations.AlterModelOptions(
name="refreshtoken",
options={
"verbose_name": "OAuth2 Refresh Token",
"verbose_name_plural": "OAuth2 Refresh Tokens",
},
),
migrations.RenameField(
model_name="oauth2provider",
old_name="token_validity",
new_name="refresh_token_validity",
),
migrations.RemoveField(
model_name="authorizationcode",
name="is_open_id",
),
migrations.RemoveField(
model_name="refreshtoken",
name="access_token",
),
migrations.RemoveField(
model_name="refreshtoken",
name="refresh_token",
),
migrations.AddField(
model_name="oauth2provider",
name="access_token_validity",
field=models.TextField(
default="hours=1",
help_text="Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
migrations.AddField(
model_name="refreshtoken",
name="token",
field=models.TextField(default=authentik.lib.generators.generate_key),
),
migrations.AlterField(
model_name="oauth2provider",
name="sub_mode",
field=models.TextField(
choices=[
("hashed_user_id", "Based on the Hashed User ID"),
("user_id", "Based on user ID"),
("user_username", "Based on the username"),
(
"user_email",
"Based on the User's Email. This is recommended over the UPN method.",
),
(
"user_upn",
"Based on the User's UPN, only works if user has a 'upn' attribute set. Use this method only if you have different UPN and Mail domains.",
),
],
default="hashed_user_id",
help_text="Configure what data should be used as unique User Identifier. For most cases, the default should be fine.",
),
),
migrations.CreateModel(
name="AccessToken",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"expires",
models.DateTimeField(default=authentik.core.models.default_token_duration),
),
("expiring", models.BooleanField(default=True)),
("revoked", models.BooleanField(default=False)),
("_scope", models.TextField(default="", verbose_name="Scopes")),
("token", models.TextField()),
("_id_token", models.TextField()),
(
"provider",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_providers_oauth2.oauth2provider",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"verbose_name": "OAuth2 Access Token",
"verbose_name_plural": "OAuth2 Access Tokens",
},
),
]

View file

@ -2,8 +2,6 @@
import base64
import 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

View file

@ -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,
},
},

View file

@ -9,7 +9,7 @@ from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.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),
},
)

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.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(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

@ -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",

View file

@ -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 {

View file

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

View file

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

View file

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

View file

@ -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">

View file

@ -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}>

View file

@ -13,6 +13,10 @@ slug: "/releases/2023.2"
As with the previous improvements, we've made a lot of minor improvements to the general authentik UX to make your life easier.
- 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.