Files
authentik/authentik/providers/oauth2/models.py
Jens L. c213711211 enterprise/providers/oauth2: dynamic client registration (#24225)
* I think I'm happy with this structure

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

* fixup

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

* add api

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

* fix missing engine mode

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

* add ui

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

* pull in the requests machinery

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

* fixup and gen

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

* gen

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

* re-wire things up

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

* re-add more web stuff

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

* fix unrelated test issue

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

* fix mixin

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

* gen

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

* plumb in flows

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

* update ui

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

* initial web

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

* fix a couple things

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

* fix api tests & schema

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

* better pick request flow

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

* fix perms

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

* change method

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

* fix perms

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

* fix web lint

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

* explicit perms

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

* fix migrations

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

* concrete m2m

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

* fix perms

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

* format

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

* add some default config

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

* fix permission check -> requesting user checks against rule bindings' policies

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

* fix unrelated

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

* fix api client

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

* fix styling

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

* fix more

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

* add banner

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

* attributes mixin prep for object attrs

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

* fix tests

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

* update tests

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

* fix typo

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

* fix unrelated test failure

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

* ensure no self-approval

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

* configurable expiry

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

* add meta info to requestable target

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

* add more events

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

* only unique apps

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

* initial app card impl

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

* add entitlement modal

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

* respect filters

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

* re-migrate

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

* improve ux

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

* fix child bindings?

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

* better ux for rule bindings

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

* fix type check

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

* initial dcr

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

* re-migrate

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

* handle error

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

* format

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

* fix missing through

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

* oops

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

* Clean up checkbox item types.

* Fix grant type translation.

* fix conflicting urls

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

* fix duplicate url encoding

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

* always create app

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

* rename options to function as override

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

* copy pb

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

* update ui

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

* update tests

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

* fix title

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

* copy bindings from app if none set

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

* show policy bindings

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

* fix copy & tests

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

* fix unknown case

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-07-30 13:02:37 +01:00

847 lines
28 KiB
Python

"""OAuth Provider Models"""
import base64
import binascii
import json
from dataclasses import asdict, dataclass
from functools import cached_property
from hashlib import sha256
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse, urlunparse
from cryptography.hazmat.primitives.asymmetric.ec import (
SECP256R1,
SECP384R1,
SECP521R1,
EllipticCurvePrivateKey,
)
from cryptography.hazmat.primitives.asymmetric.ed448 import Ed448PrivateKey
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
from dacite import Config
from dacite.core import from_dict
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import HashIndex
from django.db import models
from django.http import HttpRequest
from django.templatetags.static import static
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from jwcrypto.common import json_encode
from jwcrypto.jwe import JWE
from jwcrypto.jwk import JWK
from jwt import encode
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.brands.models import WebfingerProvider
from authentik.common.oauth.constants import (
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_CLIENT_CREDENTIALS,
GRANT_TYPE_DEVICE_CODE,
GRANT_TYPE_HYBRID,
GRANT_TYPE_IMPLICIT,
GRANT_TYPE_PASSWORD,
GRANT_TYPE_REFRESH_TOKEN,
GRANT_TYPE_TOKEN_EXCHANGE,
SubModes,
)
from authentik.core.models import (
AuthenticatedSession,
PropertyMapping,
Provider,
User,
)
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
from authentik.lib.models import (
DomainlessURLValidator,
ExpiringModel,
InternallyManagedMixin,
SerializerModel,
SimpleThroughModel,
)
from authentik.lib.utils.time import timedelta_string_validator
from authentik.policies.models import PolicyBindingModel
from authentik.sources.oauth.models import OAuthSource
if TYPE_CHECKING:
from authentik.providers.oauth2.id_token import IDToken
LOGGER = get_logger()
def generate_client_secret() -> str:
"""Generate client secret with adequate length"""
return generate_id(128)
class ClientType(models.TextChoices):
"""Confidential clients are capable of maintaining the confidentiality
of their credentials. Public clients are incapable."""
CONFIDENTIAL = "confidential", _("Confidential")
PUBLIC = "public", _("Public")
class GrantType(models.TextChoices):
"""OAuth2 Grant types we support"""
AUTHORIZATION_CODE = GRANT_TYPE_AUTHORIZATION_CODE
IMPLICIT = GRANT_TYPE_IMPLICIT
HYBRID = GRANT_TYPE_HYBRID
REFRESH_TOKEN = GRANT_TYPE_REFRESH_TOKEN
CLIENT_CREDENTIALS = GRANT_TYPE_CLIENT_CREDENTIALS
PASSWORD = GRANT_TYPE_PASSWORD
DEVICE_CODE = GRANT_TYPE_DEVICE_CODE
TOKEN_EXCHANGE = GRANT_TYPE_TOKEN_EXCHANGE
# Fallback for decoding previous sessions from 2026.2 to 2026.5
# https://github.com/goauthentik/authentik/issues/22588
# TODO: Remove after 2026.8
GrantTypes = GrantType
class ResponseMode(models.TextChoices):
"""https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#OAuth.Post"""
QUERY = "query"
FRAGMENT = "fragment"
FORM_POST = "form_post"
class IssuerMode(models.TextChoices):
"""Configure how the `iss` field is created."""
GLOBAL = "global", _("Same identifier is used for all providers")
PER_PROVIDER = (
"per_provider",
_("Each provider has a different issuer, based on the application slug."),
)
class RedirectURIMatchingMode(models.TextChoices):
STRICT = "strict", _("Strict URL comparison")
REGEX = "regex", _("Regular Expression URL matching")
class RedirectURIType(models.TextChoices):
AUTHORIZATION = "authorization", _("Authorization")
LOGOUT = "logout", _("Logout")
class OAuth2LogoutMethod(models.TextChoices):
"""OAuth2/OIDC Logout methods"""
BACKCHANNEL = "backchannel", _("Back-channel")
FRONTCHANNEL = "frontchannel", _("Front-channel")
@dataclass
class RedirectURI:
"""A single redirect URI entry"""
matching_mode: RedirectURIMatchingMode
url: str
redirect_uri_type: RedirectURIType = RedirectURIType.AUTHORIZATION
class ResponseTypes(models.TextChoices):
"""Response Type required by the client."""
CODE = "code", _("code (Authorization Code Flow)")
ID_TOKEN = "id_token", _("id_token (Implicit Flow)")
ID_TOKEN_TOKEN = "id_token token", _("id_token token (Implicit Flow)")
CODE_TOKEN = "code token", _("code token (Hybrid Flow)")
CODE_ID_TOKEN = "code id_token", _("code id_token (Hybrid Flow)")
CODE_ID_TOKEN_TOKEN = "code id_token token", _("code id_token token (Hybrid Flow)")
class JWTAlgorithms(models.TextChoices):
"""Algorithm used to sign the JWT Token"""
HS256 = "HS256", _("HS256 (Symmetric Encryption)")
RS256 = "RS256", _("RS256 (Asymmetric Encryption)")
ES256 = "ES256", _("ES256 (Asymmetric Encryption)")
ES384 = "ES384", _("ES384 (Asymmetric Encryption)")
ES512 = "ES512", _("ES512 (Asymmetric Encryption)")
EDDSA = "EdDSA", _("EdDSA (Asymmetric Encryption)")
@classmethod
def from_private_key(cls, private_key: PrivateKeyTypes | None) -> str:
if isinstance(private_key, RSAPrivateKey):
return cls.RS256
if isinstance(private_key, EllipticCurvePrivateKey):
curve = private_key.curve
if isinstance(curve, SECP256R1):
return cls.ES256
if isinstance(curve, SECP384R1):
return cls.ES384
if isinstance(curve, SECP521R1):
return cls.ES512
if isinstance(private_key, Ed25519PrivateKey | Ed448PrivateKey):
return cls.EDDSA
raise ValueError(f"Invalid private key type: {type(private_key)}")
class ScopeMapping(PropertyMapping):
"""Map an OAuth Scope to users properties"""
scope_name = models.TextField(help_text=_("Scope used by the client"))
description = models.TextField(
blank=True,
help_text=_(
"Description shown to the user when consenting. "
"If left empty, the user won't be informed."
),
)
@property
def component(self) -> str:
return "ak-property-mapping-provider-scope-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.providers.oauth2.api.scopes import ScopeMappingSerializer
return ScopeMappingSerializer
def __str__(self):
return f"Scope Mapping {self.name} ({self.scope_name})"
class Meta:
verbose_name = _("Scope Mapping")
verbose_name_plural = _("Scope Mappings")
class OAuth2Provider(WebfingerProvider, Provider):
"""OAuth2 Provider for generic OAuth and OpenID Connect Applications."""
client_type = models.CharField(
max_length=30,
choices=ClientType.choices,
default=ClientType.CONFIDENTIAL,
verbose_name=_("Client Type"),
help_text=_(
"Confidential clients are capable of maintaining the confidentiality "
"of their credentials. Public clients are incapable"
),
)
grant_types = ArrayField(models.TextField(choices=GrantType.choices), default=list)
client_id = models.CharField(
max_length=255,
unique=True,
verbose_name=_("Client ID"),
default=generate_id,
)
client_secret = models.CharField(
max_length=255,
blank=True,
verbose_name=_("Client Secret"),
default=generate_client_secret,
)
_redirect_uris = models.JSONField(
default=list,
verbose_name=_("Redirect URIs"),
)
logout_uri = models.TextField(
validators=[DomainlessURLValidator(schemes=("http", "https"))],
verbose_name=_("Logout URI"),
blank=True,
)
logout_method = models.TextField(
choices=OAuth2LogoutMethod.choices,
default=OAuth2LogoutMethod.BACKCHANNEL,
verbose_name=_("Logout Method"),
help_text=_(
"Backchannel logs out with server to server calls. "
"Frontchannel uses iframes in your browser"
),
)
include_claims_in_id_token = models.BooleanField(
default=True,
verbose_name=_("Include claims in id_token"),
help_text=_(
"Include User claims from scopes in the id_token, for applications "
"that don't access the userinfo endpoint."
),
)
access_code_validity = models.TextField(
default="minutes=1",
validators=[timedelta_string_validator],
help_text=_(
"Access codes not valid on or after current time + this value "
"(Format: hours=1;minutes=2;seconds=3)."
),
)
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=_(
"Tokens not valid on or after current time + this value "
"(Format: hours=1;minutes=2;seconds=3)."
),
)
refresh_token_threshold = models.TextField(
default="seconds=0",
validators=[timedelta_string_validator],
help_text=_(
"When refreshing a token, if the refresh token is valid for less than "
"this duration, it will be renewed. "
"When set to seconds=0, token will always be renewed. "
"(Format: hours=1;minutes=2;seconds=3)."
),
)
sub_mode = models.TextField(
choices=SubModes.choices,
default=SubModes.HASHED_USER_ID,
help_text=_(
"Configure what data should be used as unique User Identifier. For most cases, "
"the default should be fine."
),
)
issuer_mode = models.TextField(
choices=IssuerMode.choices,
default=IssuerMode.PER_PROVIDER,
help_text=_("Configure how the issuer field of the ID Token should be filled."),
)
signing_key = models.ForeignKey(
CertificateKeyPair,
verbose_name=_("Signing Key"),
on_delete=models.SET_NULL,
null=True,
help_text=_("Key used to sign the tokens."),
related_name="oauth2provider_signing_key_set",
)
encryption_key = models.ForeignKey(
CertificateKeyPair,
verbose_name=_("Encryption Key"),
on_delete=models.SET_NULL,
null=True,
help_text=_(
"Key used to encrypt the tokens. When set, "
"tokens will be encrypted and returned as JWEs."
),
related_name="oauth2provider_encryption_key_set",
)
jwt_federation_sources = models.ManyToManyField(
OAuthSource,
verbose_name=_(
"Any JWT signed by the JWK of the selected source can be used to authenticate."
),
related_name="oauth2_providers",
default=None,
blank=True,
through="OAuth2ProviderJWTFederationSource",
)
jwt_federation_providers = models.ManyToManyField(
"OAuth2Provider", blank=True, default=None, through="OAuth2ProviderJWTFederationProvider"
)
@cached_property
def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]:
"""Get either the configured certificate or the client secret"""
if not self.signing_key:
# No Certificate at all, assume HS256
return self.client_secret, JWTAlgorithms.HS256
key: CertificateKeyPair = self.signing_key
private_key = key.private_key
return private_key, JWTAlgorithms.from_private_key(private_key)
def get_issuer(self, request: HttpRequest) -> str | None:
"""Get issuer, based on request"""
if self.issuer_mode == IssuerMode.GLOBAL:
return request.build_absolute_uri(reverse("authentik_core:root-redirect"))
try:
url = reverse(
"authentik_providers_oauth2:provider-root",
kwargs={
"application_slug": self.application.slug,
},
)
return request.build_absolute_uri(url)
except Provider.application.RelatedObjectDoesNotExist:
return None
@property
def redirect_uris(self) -> list[RedirectURI]:
uris = []
for entry in self._redirect_uris:
uris.append(
from_dict(
RedirectURI,
entry,
config=Config(
type_hooks={
RedirectURIMatchingMode: RedirectURIMatchingMode,
RedirectURIType: RedirectURIType,
}
),
)
)
return uris
@redirect_uris.setter
def redirect_uris(self, value: list[RedirectURI]):
cleansed = []
for entry in value:
cleansed.append(asdict(entry))
self._redirect_uris = cleansed
@property
def authorization_redirect_uris(self) -> list[RedirectURI]:
return [
uri
for uri in self.redirect_uris
if uri.redirect_uri_type == RedirectURIType.AUTHORIZATION
]
@property
def post_logout_redirect_uris(self) -> list[RedirectURI]:
return [
uri for uri in self.redirect_uris if uri.redirect_uri_type == RedirectURIType.LOGOUT
]
@property
def launch_url(self) -> str | None:
"""Guess launch_url based on first redirect_uri"""
redirects = self.authorization_redirect_uris
if len(redirects) < 1:
return None
main_url = redirects[0].url
try:
launch_url = urlparse(main_url)._replace(path="")
return urlunparse(launch_url)
except ValueError as exc:
LOGGER.warning("Failed to format launch url", exc=exc)
return None
@property
def icon_url(self) -> str | None:
return static("authentik/sources/openidconnect.svg")
@property
def component(self) -> str:
return "ak-provider-oauth2-form"
@property
def serializer(self) -> type[Serializer]:
from authentik.providers.oauth2.api.providers import OAuth2ProviderSerializer
return OAuth2ProviderSerializer
def __str__(self):
return f"OAuth2 Provider {self.name}"
def encode(self, payload: dict[str, Any], jwt_type: str | None = None) -> str:
"""Represent the ID Token as a JSON Web Token (JWT).
:param payload The payload to encode into the JWT
:param jwt_type The type of the JWT. This will be put in the JWT header using the `typ`
parameter. See RFC7515 Section 4.1.9. If not set fallback to the default of `JWT`.
"""
headers = {}
if self.signing_key:
headers["kid"] = self.signing_key.kid
if jwt_type is not None:
headers["typ"] = jwt_type
key, alg = self.jwt_key
encoded = encode(payload, key, algorithm=alg, headers=headers)
if self.encryption_key:
return self.encrypt(encoded)
return encoded
def encrypt(self, raw: str) -> str:
"""Encrypt JWT"""
key = JWK.from_pem(self.encryption_key.certificate_data.encode())
jwe = JWE(
raw,
json_encode(
{
"alg": "RSA-OAEP-256",
"enc": "A256CBC-HS512",
"typ": "JWE",
"kid": self.encryption_key.kid,
}
),
)
jwe.add_recipient(key)
return jwe.serialize(compact=True)
def webfinger(self, resource: str, request: HttpRequest):
return {
"subject": resource,
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": request.build_absolute_uri(
reverse(
"authentik_providers_oauth2:provider-root",
kwargs={
"application_slug": self.application.slug,
},
)
),
},
],
}
class Meta:
verbose_name = _("OAuth2/OpenID Provider")
verbose_name_plural = _("OAuth2/OpenID Providers")
class OAuth2ProviderJWTFederationSource(SimpleThroughModel):
oauth2_provider = models.ForeignKey(
OAuth2Provider, on_delete=models.CASCADE, db_column="oauth2provider_id"
)
oauth_source = models.ForeignKey(
OAuthSource, on_delete=models.CASCADE, db_column="oauthsource_id"
)
class Meta:
db_table = "authentik_providers_oauth2_oauth2provider_jwt_federation_so2b48"
unique_together = (("oauth2_provider", "oauth_source"),)
verbose_name = _("OAuth2 Provider JWT Federation Source")
verbose_name_plural = _("OAuth2 Provider JWT Federation Sources")
def __str__(self):
return (
f"OAuth2ProviderJWTFederationSource for OAuth2Provider {self.oauth2_provider_id} "
f"and OauthSource {self.oauth_source_id}."
)
class OAuth2ProviderJWTFederationProvider(SimpleThroughModel):
oauth2_provider = models.ForeignKey(
OAuth2Provider,
on_delete=models.CASCADE,
related_name="jwt_federation_provider_m2m_objects",
db_column="from_oauth2provider_id",
)
jwt_federation_provider = models.ForeignKey(
OAuth2Provider,
on_delete=models.CASCADE,
related_name="oauth2_provider_m2m_objects",
db_column="to_oauth2provider_id",
)
class Meta:
db_table = "authentik_providers_oauth2_oauth2provider_jwt_federation_pr9002"
unique_together = (("oauth2_provider", "jwt_federation_provider"),)
verbose_name = _("OAuth2 Provider JWT Federation Provider")
verbose_name_plural = _("OAuth2 Provider JWT Federation Providers")
def __str__(self):
return (
f"OAuth2ProviderJWTFederationProvider for OAuth2Provider {self.oauth2_provider_id} "
f"and JWTFederationProvider {self.jwt_federation_provider_id}."
)
class BaseGrantModel(models.Model):
"""Base Model for all grants"""
provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE)
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
revoked = models.BooleanField(default=False)
_scope = models.TextField(default="", verbose_name=_("Scopes"))
auth_time = models.DateTimeField(verbose_name="Authentication time")
session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.CASCADE, default=None
)
class Meta:
abstract = True
@property
def scope(self) -> list[str]:
"""Return scopes as list of strings"""
return self._scope.split()
@scope.setter
def scope(self, value):
self._scope = " ".join(value)
class AuthorizationCode(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
"""OAuth2 Authorization Code"""
code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
nonce = models.TextField(null=True, default=None, verbose_name=_("Nonce"))
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")
)
dpop_jkt = models.CharField(
max_length=255, null=True, default=None, verbose_name=_("DPoP JWK Thumbprint")
)
class Meta:
verbose_name = _("Authorization Code")
verbose_name_plural = _("Authorization Codes")
indexes = ExpiringModel.Meta.indexes
def __str__(self):
return f"Authorization code for {self.provider_id} for user {self.user_id}"
@property
def serializer(self) -> Serializer:
from authentik.providers.oauth2.api.tokens import ExpiringBaseGrantModelSerializer
return ExpiringBaseGrantModelSerializer
@property
def c_hash(self):
"""https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
hashed_code = sha256(self.code.encode("ascii")).hexdigest().encode("ascii")
return (
base64.urlsafe_b64encode(binascii.unhexlify(hashed_code[: len(hashed_code) // 2]))
.rstrip(b"=")
.decode("ascii")
)
class AccessToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
"""OAuth2 access token, non-opaque using a JWT as identifier"""
token = models.TextField()
_id_token = models.TextField()
class Meta:
indexes = ExpiringModel.Meta.indexes + [
HashIndex(fields=["token"]),
]
verbose_name = _("OAuth2 Access Token")
verbose_name_plural = _("OAuth2 Access Tokens")
def __str__(self):
return f"Access Token for {self.provider_id} for user {self.user_id}"
@property
def id_token(self) -> IDToken:
"""Load ID Token from json"""
from authentik.providers.oauth2.id_token import IDToken
raw_token = json.loads(self._id_token)
return from_dict(IDToken, raw_token)
@id_token.setter
def id_token(self, value: IDToken):
self.token = value.to_access_token(self.provider, self)
self._id_token = json.dumps(asdict(value))
@property
def at_hash(self):
"""Get hashed access_token"""
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])
)
.rstrip(b"=")
.decode("ascii")
)
@property
def serializer(self) -> Serializer:
from authentik.providers.oauth2.api.tokens import TokenModelSerializer
return TokenModelSerializer
class RefreshToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
"""OAuth2 Refresh Token, opaque"""
token = models.TextField(default=generate_client_secret)
_id_token = models.TextField(verbose_name=_("ID Token"))
dpop_jkt = models.CharField(
max_length=255, null=True, default=None, verbose_name=_("DPoP JWK Thumbprint")
)
# Shadow the `session` field from `BaseGrantModel` as we want refresh tokens to persist even
# when the session is terminated.
session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
)
class Meta:
indexes = ExpiringModel.Meta.indexes + [
HashIndex(fields=["token"]),
]
verbose_name = _("OAuth2 Refresh Token")
verbose_name_plural = _("OAuth2 Refresh Tokens")
def __str__(self):
return f"Refresh Token for {self.provider_id} for user {self.user_id}"
@property
def id_token(self) -> IDToken:
"""Load ID Token from json"""
from authentik.providers.oauth2.id_token import 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))
@property
def serializer(self) -> Serializer:
from authentik.providers.oauth2.api.tokens import TokenModelSerializer
return TokenModelSerializer
class DeviceToken(InternallyManagedMixin, ExpiringModel):
"""Temporary device token for OAuth device flow"""
user = models.ForeignKey(
"authentik_core.User", default=None, on_delete=models.CASCADE, null=True
)
provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE)
device_code = models.TextField(default=generate_key)
user_code = models.TextField(default=generate_code_fixed_length)
_scope = models.TextField(default="", verbose_name=_("Scopes"))
dpop_jkt = models.CharField(
max_length=255, null=True, default=None, verbose_name=_("DPoP JWK Thumbprint")
)
session = models.ForeignKey(
AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None
)
@property
def scope(self) -> list[str]:
"""Return scopes as list of strings"""
return self._scope.split()
@scope.setter
def scope(self, value):
self._scope = " ".join(value)
class Meta:
verbose_name = _("Device Token")
verbose_name_plural = _("Device Tokens")
indexes = ExpiringModel.Meta.indexes
def __str__(self):
return f"Device Token for {self.provider_id}"
class OAuth2DynamicClientRegistration(SerializerModel, PolicyBindingModel):
"""Configuration for Dynamic Client Registration (RFC 7591) on an OAuth2Provider."""
provider = models.OneToOneField(
OAuth2Provider,
on_delete=models.CASCADE,
related_name="dcr_configuration",
verbose_name=_("Provider"),
)
default_application_group = models.TextField(
blank=True,
default="",
verbose_name=_("Default application group"),
help_text=_("Group to assign to automatically created applications."),
)
override_authorization_flow = models.ForeignKey(
"authentik_flows.Flow",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Override authorization flow"),
help_text=_("Authorization flow applied to dynamically registered clients."),
related_name="dcr_override_authorization_flow",
)
override_invalidation_flow = models.ForeignKey(
"authentik_flows.Flow",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Override invalidation flow"),
related_name="dcr_override_invalidation_flow",
)
override_property_mappings = models.ManyToManyField(
ScopeMapping,
blank=True,
verbose_name=_("Override property mappings"),
help_text=_("Scope mappings applied to dynamically registered clients."),
through="DynamicClientRegistrationPropertyMapping",
)
access_token_validity = models.TextField(
default="hours=1",
validators=[timedelta_string_validator],
verbose_name=_("Access token validity"),
help_text=_(
"Maximum access token validity for registered clients "
"(Format: hours=1;minutes=2;seconds=3)."
),
)
refresh_token_validity = models.TextField(
default="days=30",
validators=[timedelta_string_validator],
verbose_name=_("Refresh token validity"),
help_text=_(
"Maximum refresh token validity for registered clients "
"(Format: hours=1;minutes=2;seconds=3)."
),
)
allowed_grant_types = ArrayField(
models.TextField(choices=GrantType.choices),
default=list,
blank=True,
verbose_name=_("Allowed grant types"),
help_text=_("If empty, all grant types are allowed."),
)
@property
def serializer(self) -> type[Serializer]:
from authentik.enterprise.providers.oauth2.api import (
OAuth2DynamicClientRegistrationSerializer,
)
return OAuth2DynamicClientRegistrationSerializer
def __str__(self):
return f"DCR Configuration for {self.provider_id}"
class Meta:
verbose_name = _("OAuth2 Dynamic Client Registration")
verbose_name_plural = _("OAuth2 Dynamic Client Registrations")
class DynamicClientRegistrationPropertyMapping(SimpleThroughModel):
property_mapping = models.ForeignKey(ScopeMapping, on_delete=models.CASCADE)
dynamic_client_registration = models.ForeignKey(
OAuth2DynamicClientRegistration, on_delete=models.CASCADE
)
class Meta:
unique_together = (("property_mapping", "dynamic_client_registration"),)
verbose_name = _("Dynamic Client Registration Property Mapping")
verbose_name_plural = _("Dynamic Client Registration Property Mappings")
def __str__(self):
return (
"DynamicClientRegistrationPropertyMapping for DCR "
f"{self.dynamic_client_registration_id} and PropertyMapping "
f"{self.property_mapping_id}."
)