providers/oauth2: Add provider federation between OAuth2 Providers (#12083)

* rename + add field

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

* initial implementation

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

* fix

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

* fix

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

* refactor

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

* rework source cc tests

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

* add tests

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

* update docs

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

* re-migrate

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

* fix a

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

* Apply suggestions from code review

Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Jens L. <jens@beryju.org>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
This commit is contained in:
Jens L.
2024-12-03 11:57:10 +02:00
committed by GitHub
parent 4aeb7c8a84
commit 19488b7b9e
17 changed files with 510 additions and 64 deletions

View File

@ -65,7 +65,12 @@ from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
RefreshToken,
)
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role from authentik.rbac.models import Role
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
@ -125,6 +130,7 @@ def excluded_models() -> list[type[Model]]:
MicrosoftEntraProviderGroup, MicrosoftEntraProviderGroup,
EndpointDevice, EndpointDevice,
EndpointDeviceConnection, EndpointDeviceConnection,
DeviceToken,
) )

View File

@ -73,7 +73,8 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"sub_mode", "sub_mode",
"property_mappings", "property_mappings",
"issuer_mode", "issuer_mode",
"jwks_sources", "jwt_federation_sources",
"jwt_federation_providers",
] ]
extra_kwargs = ProviderSerializer.Meta.extra_kwargs extra_kwargs = ProviderSerializer.Meta.extra_kwargs

View File

@ -0,0 +1,25 @@
# Generated by Django 5.0.9 on 2024-11-22 14:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0024_remove_oauth2provider_redirect_uris_and_more"),
]
operations = [
migrations.RenameField(
model_name="oauth2provider",
old_name="jwks_sources",
new_name="jwt_federation_sources",
),
migrations.AddField(
model_name="oauth2provider",
name="jwt_federation_providers",
field=models.ManyToManyField(
blank=True, default=None, to="authentik_providers_oauth2.oauth2provider"
),
),
]

View File

@ -244,7 +244,7 @@ class OAuth2Provider(WebfingerProvider, Provider):
related_name="oauth2provider_encryption_key_set", related_name="oauth2provider_encryption_key_set",
) )
jwks_sources = models.ManyToManyField( jwt_federation_sources = models.ManyToManyField(
OAuthSource, OAuthSource,
verbose_name=_( verbose_name=_(
"Any JWT signed by the JWK of the selected source can be used to authenticate." "Any JWT signed by the JWK of the selected source can be used to authenticate."
@ -253,6 +253,7 @@ class OAuth2Provider(WebfingerProvider, Provider):
default=None, default=None,
blank=True, blank=True,
) )
jwt_federation_providers = models.ManyToManyField("OAuth2Provider", blank=True, default=None)
@cached_property @cached_property
def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]:

View File

@ -0,0 +1,228 @@
"""Test token view"""
from datetime import datetime, timedelta
from json import loads
from django.test import RequestFactory
from django.urls import reverse
from django.utils.timezone import now
from jwt import decode
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, Group
from authentik.core.tests.utils import create_test_cert, create_test_flow, create_test_user
from authentik.lib.generators import generate_id
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.constants import (
GRANT_TYPE_CLIENT_CREDENTIALS,
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
TOKEN_TYPE,
)
from authentik.providers.oauth2.models import (
AccessToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
ScopeMapping,
)
from authentik.providers.oauth2.tests.utils import OAuthTestCase
class TestTokenClientCredentialsJWTProvider(OAuthTestCase):
"""Test token (client_credentials, with JWT) view"""
@apply_blueprint("system/providers-oauth2.yaml")
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.other_cert = create_test_cert()
self.cert = create_test_cert()
self.other_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
signing_key=self.other_cert,
)
self.other_provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(
name=generate_id(), slug=generate_id(), provider=self.other_provider
)
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
name="test",
authorization_flow=create_test_flow(),
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.cert,
)
self.provider.jwt_federation_providers.add(self.other_provider)
self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
def test_invalid_type(self):
"""test invalid type"""
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
"client_id": self.provider.client_id,
"client_assertion_type": "foo",
"client_assertion": "foo.bar",
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_jwt(self):
"""test invalid JWT"""
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": "foo.bar",
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_signature(self):
"""test invalid JWT"""
token = self.provider.encode(
{
"sub": "foo",
"exp": datetime.now() + timedelta(hours=2),
}
)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token + "foo",
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_expired(self):
"""test invalid JWT"""
token = self.provider.encode(
{
"sub": "foo",
"exp": datetime.now() - timedelta(hours=2),
}
)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token,
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_no_app(self):
"""test invalid JWT"""
self.app.provider = None
self.app.save()
token = self.provider.encode(
{
"sub": "foo",
"exp": datetime.now() + timedelta(hours=2),
}
)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token,
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_invalid_access_denied(self):
"""test invalid JWT"""
group = Group.objects.create(name="foo")
PolicyBinding.objects.create(
group=group,
target=self.app,
order=0,
)
token = self.provider.encode(
{
"sub": "foo",
"exp": datetime.now() + timedelta(hours=2),
}
)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token,
},
)
self.assertEqual(response.status_code, 400)
body = loads(response.content.decode())
self.assertEqual(body["error"], "invalid_grant")
def test_successful(self):
"""test successful"""
user = create_test_user()
token = self.other_provider.encode(
{
"sub": "foo",
"exp": datetime.now() + timedelta(hours=2),
}
)
AccessToken.objects.create(
provider=self.other_provider,
token=token,
user=user,
auth_time=now(),
)
response = self.client.post(
reverse("authentik_providers_oauth2:token"),
{
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
"client_id": self.provider.client_id,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": token,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(body["token_type"], TOKEN_TYPE)
_, alg = self.provider.jwt_key
jwt = decode(
body["access_token"],
key=self.provider.signing_key.public_key,
algorithms=[alg],
audience=self.provider.client_id,
)
self.assertEqual(jwt["given_name"], user.name)
self.assertEqual(jwt["preferred_username"], user.username)

View File

@ -37,9 +37,16 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.factory = RequestFactory() self.factory = RequestFactory()
self.other_cert = create_test_cert()
# Provider used as a helper to sign JWTs with the same key as the OAuth source has
self.helper_provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
signing_key=self.other_cert,
)
self.cert = create_test_cert() self.cert = create_test_cert()
jwk = JWKSView().get_jwk_for_key(self.cert, "sig") jwk = JWKSView().get_jwk_for_key(self.other_cert, "sig")
self.source: OAuthSource = OAuthSource.objects.create( self.source: OAuthSource = OAuthSource.objects.create(
name=generate_id(), name=generate_id(),
slug=generate_id(), slug=generate_id(),
@ -62,7 +69,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")], redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")],
signing_key=self.cert, signing_key=self.cert,
) )
self.provider.jwks_sources.add(self.source) self.provider.jwt_federation_sources.add(self.source)
self.provider.property_mappings.set(ScopeMapping.objects.all()) self.provider.property_mappings.set(ScopeMapping.objects.all())
self.app = Application.objects.create(name="test", slug="test", provider=self.provider) self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
@ -100,7 +107,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
def test_invalid_signature(self): def test_invalid_signature(self):
"""test invalid JWT""" """test invalid JWT"""
token = self.provider.encode( token = self.helper_provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() + timedelta(hours=2), "exp": datetime.now() + timedelta(hours=2),
@ -122,7 +129,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
def test_invalid_expired(self): def test_invalid_expired(self):
"""test invalid JWT""" """test invalid JWT"""
token = self.provider.encode( token = self.helper_provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() - timedelta(hours=2), "exp": datetime.now() - timedelta(hours=2),
@ -146,7 +153,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
"""test invalid JWT""" """test invalid JWT"""
self.app.provider = None self.app.provider = None
self.app.save() self.app.save()
token = self.provider.encode( token = self.helper_provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() + timedelta(hours=2), "exp": datetime.now() + timedelta(hours=2),
@ -174,7 +181,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
target=self.app, target=self.app,
order=0, order=0,
) )
token = self.provider.encode( token = self.helper_provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() + timedelta(hours=2), "exp": datetime.now() + timedelta(hours=2),
@ -196,7 +203,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase):
def test_successful(self): def test_successful(self):
"""test successful""" """test successful"""
token = self.provider.encode( token = self.helper_provider.encode(
{ {
"sub": "foo", "sub": "foo",
"exp": datetime.now() + timedelta(hours=2), "exp": datetime.now() + timedelta(hours=2),

View File

@ -137,7 +137,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
class OAuthDeviceCodeStage(ChallengeStageView): class OAuthDeviceCodeStage(ChallengeStageView):
"""Flow challenge for users to enter device codes""" """Flow challenge for users to enter device code"""
response_class = OAuthDeviceCodeChallengeResponse response_class = OAuthDeviceCodeChallengeResponse

View File

@ -362,23 +362,9 @@ class TokenParams:
}, },
).from_http(request, user=user) ).from_http(request, user=user)
def __post_init_client_credentials_jwt(self, request: HttpRequest): def __validate_jwt_from_source(
assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "") self, assertion: str
if assertion_type != CLIENT_ASSERTION_TYPE_JWT: ) -> tuple[dict, OAuthSource] | tuple[None, None]:
LOGGER.warning("Invalid assertion type", assertion_type=assertion_type)
raise TokenError("invalid_grant")
client_secret = request.POST.get("client_secret", None)
assertion = request.POST.get(CLIENT_ASSERTION, client_secret)
if not assertion:
LOGGER.warning("Missing client assertion")
raise TokenError("invalid_grant")
token = None
source: OAuthSource | None = None
parsed_key: PyJWK | None = None
# Fully decode the JWT without verifying the signature, so we can get access to # Fully decode the JWT without verifying the signature, so we can get access to
# the header. # the header.
# Get the Key ID from the header, and use that to optimise our source query to only find # Get the Key ID from the header, and use that to optimise our source query to only find
@ -394,7 +380,8 @@ class TokenParams:
raise TokenError("invalid_grant") from None raise TokenError("invalid_grant") from None
expected_kid = decode_unvalidated["header"]["kid"] expected_kid = decode_unvalidated["header"]["kid"]
fallback_alg = decode_unvalidated["header"]["alg"] fallback_alg = decode_unvalidated["header"]["alg"]
for source in self.provider.jwks_sources.filter( token = source = None
for source in self.provider.jwt_federation_sources.filter(
oidc_jwks__keys__contains=[{"kid": expected_kid}] oidc_jwks__keys__contains=[{"kid": expected_kid}]
): ):
LOGGER.debug("verifying JWT with source", source=source.slug) LOGGER.debug("verifying JWT with source", source=source.slug)
@ -404,10 +391,10 @@ class TokenParams:
continue continue
LOGGER.debug("verifying JWT with key", source=source.slug, key=key.get("kid")) LOGGER.debug("verifying JWT with key", source=source.slug, key=key.get("kid"))
try: try:
parsed_key = PyJWK.from_dict(key) parsed_key = PyJWK.from_dict(key).key
token = decode( token = decode(
assertion, assertion,
parsed_key.key, parsed_key,
algorithms=[key.get("alg")] if "alg" in key else [fallback_alg], algorithms=[key.get("alg")] if "alg" in key else [fallback_alg],
options={ options={
"verify_aud": False, "verify_aud": False,
@ -417,13 +404,61 @@ class TokenParams:
# and not a public key # and not a public key
except (PyJWTError, ValueError, TypeError, AttributeError) as exc: except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("failed to verify JWT", exc=exc, source=source.slug) LOGGER.warning("failed to verify JWT", exc=exc, source=source.slug)
if token:
LOGGER.info("successfully verified JWT with source", source=source.slug)
return token, source
def __validate_jwt_from_provider(
self, assertion: str
) -> tuple[dict, OAuth2Provider] | tuple[None, None]:
token = provider = _key = None
federated_token = AccessToken.objects.filter(
token=assertion, provider__in=self.provider.jwt_federation_providers.all()
).first()
if federated_token:
_key, _alg = federated_token.provider.jwt_key
try:
token = decode(
assertion,
_key.public_key(),
algorithms=[_alg],
options={
"verify_aud": False,
},
)
provider = federated_token.provider
self.user = federated_token.user
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning(
"failed to verify JWT", exc=exc, provider=federated_token.provider.name
)
if token:
LOGGER.info("successfully verified JWT with provider", provider=provider.name)
return token, provider
def __post_init_client_credentials_jwt(self, request: HttpRequest):
assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "")
if assertion_type != CLIENT_ASSERTION_TYPE_JWT:
LOGGER.warning("Invalid assertion type", assertion_type=assertion_type)
raise TokenError("invalid_grant")
client_secret = request.POST.get("client_secret", None)
assertion = request.POST.get(CLIENT_ASSERTION, client_secret)
if not assertion:
LOGGER.warning("Missing client assertion")
raise TokenError("invalid_grant")
source = provider = None
token, source = self.__validate_jwt_from_source(assertion)
if not token:
token, provider = self.__validate_jwt_from_provider(assertion)
if not token: if not token:
LOGGER.warning("No token could be verified") LOGGER.warning("No token could be verified")
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
LOGGER.info("successfully verified JWT with source", source=source.slug)
if "exp" in token: if "exp" in token:
exp = datetime.fromtimestamp(token["exp"]) exp = datetime.fromtimestamp(token["exp"])
# Non-timezone aware check since we assume `exp` is in UTC # Non-timezone aware check since we assume `exp` is in UTC
@ -437,15 +472,16 @@ class TokenParams:
raise TokenError("invalid_grant") raise TokenError("invalid_grant")
self.__check_policy_access(app, request, oauth_jwt=token) self.__check_policy_access(app, request, oauth_jwt=token)
self.__create_user_from_jwt(token, app, source) if not provider:
self.__create_user_from_jwt(token, app, source)
method_args = { method_args = {
"jwt": token, "jwt": token,
} }
if source: if source:
method_args["source"] = source method_args["source"] = source
if parsed_key: if provider:
method_args["jwk_id"] = parsed_key.key_id method_args["provider"] = provider
Event.new( Event.new(
action=EventAction.LOGIN, action=EventAction.LOGIN,
**{ **{

View File

@ -94,7 +94,8 @@ class ProxyProviderSerializer(ProviderSerializer):
"intercept_header_auth", "intercept_header_auth",
"redirect_uris", "redirect_uris",
"cookie_domain", "cookie_domain",
"jwks_sources", "jwt_federation_sources",
"jwt_federation_providers",
"access_token_validity", "access_token_validity",
"refresh_token_validity", "refresh_token_validity",
"outpost_set", "outpost_set",

View File

@ -5617,13 +5617,20 @@
"title": "Issuer mode", "title": "Issuer mode",
"description": "Configure how the issuer field of the ID Token should be filled." "description": "Configure how the issuer field of the ID Token should be filled."
}, },
"jwks_sources": { "jwt_federation_sources": {
"type": "array", "type": "array",
"items": { "items": {
"type": "integer", "type": "integer",
"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."
}, },
"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."
},
"jwt_federation_providers": {
"type": "array",
"items": {
"type": "integer"
},
"title": "Jwt federation providers"
} }
}, },
"required": [] "required": []
@ -5746,7 +5753,7 @@
"type": "string", "type": "string",
"title": "Cookie domain" "title": "Cookie domain"
}, },
"jwks_sources": { "jwt_federation_sources": {
"type": "array", "type": "array",
"items": { "items": {
"type": "integer", "type": "integer",
@ -5754,6 +5761,13 @@
}, },
"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."
}, },
"jwt_federation_providers": {
"type": "array",
"items": {
"type": "integer"
},
"title": "Jwt federation providers"
},
"access_token_validity": { "access_token_validity": {
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,

View File

@ -44785,7 +44785,7 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/IssuerModeEnum' - $ref: '#/components/schemas/IssuerModeEnum'
description: Configure how the issuer field of the ID Token should be filled. description: Configure how the issuer field of the ID Token should be filled.
jwks_sources: jwt_federation_sources:
type: array type: array
items: items:
type: string type: string
@ -44793,6 +44793,10 @@ components:
title: Any JWT signed by the JWK of the selected source can be used to title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate.
jwt_federation_providers:
type: array
items:
type: integer
required: required:
- assigned_application_name - assigned_application_name
- assigned_application_slug - assigned_application_slug
@ -44888,7 +44892,7 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/IssuerModeEnum' - $ref: '#/components/schemas/IssuerModeEnum'
description: Configure how the issuer field of the ID Token should be filled. description: Configure how the issuer field of the ID Token should be filled.
jwks_sources: jwt_federation_sources:
type: array type: array
items: items:
type: string type: string
@ -44896,6 +44900,10 @@ components:
title: Any JWT signed by the JWK of the selected source can be used to title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate.
jwt_federation_providers:
type: array
items:
type: integer
required: required:
- authorization_flow - authorization_flow
- invalidation_flow - invalidation_flow
@ -48911,7 +48919,7 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/IssuerModeEnum' - $ref: '#/components/schemas/IssuerModeEnum'
description: Configure how the issuer field of the ID Token should be filled. description: Configure how the issuer field of the ID Token should be filled.
jwks_sources: jwt_federation_sources:
type: array type: array
items: items:
type: string type: string
@ -48919,6 +48927,10 @@ components:
title: Any JWT signed by the JWK of the selected source can be used to title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate.
jwt_federation_providers:
type: array
items:
type: integer
PatchedOAuthSourcePropertyMappingRequest: PatchedOAuthSourcePropertyMappingRequest:
type: object type: object
description: OAuthSourcePropertyMapping Serializer description: OAuthSourcePropertyMapping Serializer
@ -49434,7 +49446,7 @@ components:
header and authenticate requests based on its value. header and authenticate requests based on its value.
cookie_domain: cookie_domain:
type: string type: string
jwks_sources: jwt_federation_sources:
type: array type: array
items: items:
type: string type: string
@ -49442,6 +49454,10 @@ components:
title: Any JWT signed by the JWK of the selected source can be used to title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate.
jwt_federation_providers:
type: array
items:
type: integer
access_token_validity: access_token_validity:
type: string type: string
minLength: 1 minLength: 1
@ -51504,7 +51520,7 @@ components:
readOnly: true readOnly: true
cookie_domain: cookie_domain:
type: string type: string
jwks_sources: jwt_federation_sources:
type: array type: array
items: items:
type: string type: string
@ -51512,6 +51528,10 @@ components:
title: Any JWT signed by the JWK of the selected source can be used to title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate.
jwt_federation_providers:
type: array
items:
type: integer
access_token_validity: access_token_validity:
type: string type: string
description: 'Tokens not valid on or after current time + this value (Format: description: 'Tokens not valid on or after current time + this value (Format:
@ -51612,7 +51632,7 @@ components:
header and authenticate requests based on its value. header and authenticate requests based on its value.
cookie_domain: cookie_domain:
type: string type: string
jwks_sources: jwt_federation_sources:
type: array type: array
items: items:
type: string type: string
@ -51620,6 +51640,10 @@ components:
title: Any JWT signed by the JWK of the selected source can be used to title: Any JWT signed by the JWK of the selected source can be used to
authenticate. authenticate.
title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate.
jwt_federation_providers:
type: array
items:
type: integer
access_token_validity: access_token_validity:
type: string type: string
minLength: 1 minLength: 1

View File

@ -307,7 +307,7 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel {
> >
<ak-dual-select-dynamic-selected <ak-dual-select-dynamic-selected
.provider=${oauth2SourcesProvider} .provider=${oauth2SourcesProvider}
.selector=${oauth2SourcesSelector(provider?.jwksSources)} .selector=${oauth2SourcesSelector(provider?.jwtFederationSources)}
available-label=${msg("Available Sources")} available-label=${msg("Available Sources")}
selected-label=${msg("Selected Sources")} selected-label=${msg("Selected Sources")}
></ak-dual-select-dynamic-selected> ></ak-dual-select-dynamic-selected>

View File

@ -248,7 +248,9 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
> >
<ak-dual-select-dynamic-selected <ak-dual-select-dynamic-selected
.provider=${oauth2SourcesProvider} .provider=${oauth2SourcesProvider}
.selector=${oauth2SourcesSelector(this.instance?.jwksSources)} .selector=${oauth2SourcesSelector(
this.instance?.jwtFederationSources,
)}
available-label=${msg("Available Sources")} available-label=${msg("Available Sources")}
selected-label=${msg("Selected Sources")} selected-label=${msg("Selected Sources")}
></ak-dual-select-dynamic-selected> ></ak-dual-select-dynamic-selected>

View File

@ -13,6 +13,7 @@ import "@goauthentik/components/ak-textarea-input";
import "@goauthentik/elements/ak-array-input.js"; import "@goauthentik/elements/ak-array-input.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/forms/Radio";
@ -116,6 +117,45 @@ export const redirectUriHelp = html`${redirectUriHelpMessages.map(
(m) => html`<p class="pf-c-form__helper-text">${m}</p>`, (m) => html`<p class="pf-c-form__helper-text">${m}</p>`,
)}`; )}`;
const providerToSelect = (provider: OAuth2Provider) => [provider.pk, provider.name];
export async function oauth2ProvidersProvider(page = 1, search = "") {
const oauthProviders = await new ProvidersApi(DEFAULT_CONFIG).providersOauth2List({
ordering: "name",
pageSize: 20,
search: search.trim(),
page,
});
return {
pagination: oauthProviders.pagination,
options: oauthProviders.results.map((provider) => providerToSelect(provider)),
};
}
export function oauth2ProviderSelector(instanceProviders: number[] | undefined) {
if (!instanceProviders) {
return async (mappings: DualSelectPair<OAuth2Provider>[]) =>
mappings.filter(
([_0, _1, _2, source]: DualSelectPair<OAuth2Provider>) => source !== undefined,
);
}
return async () => {
const oauthSources = new ProvidersApi(DEFAULT_CONFIG);
const mappings = await Promise.allSettled(
instanceProviders.map((instanceId) =>
oauthSources.providersOauth2Retrieve({ id: instanceId }),
),
);
return mappings
.filter((s) => s.status === "fulfilled")
.map((s) => s.value)
.map(providerToSelect);
};
}
/** /**
* Form page for OAuth2 Authentication Method * Form page for OAuth2 Authentication Method
* *
@ -381,12 +421,12 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
<span slot="header">${msg("Machine-to-Machine authentication settings")}</span> <span slot="header">${msg("Machine-to-Machine authentication settings")}</span>
<div slot="body" class="pf-c-form"> <div slot="body" class="pf-c-form">
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Trusted OIDC Sources")} label=${msg("Federated OIDC Sources")}
name="jwksSources" name="jwtFederationSources"
> >
<ak-dual-select-dynamic-selected <ak-dual-select-dynamic-selected
.provider=${oauth2SourcesProvider} .provider=${oauth2SourcesProvider}
.selector=${oauth2SourcesSelector(provider?.jwksSources)} .selector=${oauth2SourcesSelector(provider?.jwtFederationSources)}
available-label=${msg("Available Sources")} available-label=${msg("Available Sources")}
selected-label=${msg("Selected Sources")} selected-label=${msg("Selected Sources")}
></ak-dual-select-dynamic-selected> ></ak-dual-select-dynamic-selected>
@ -396,6 +436,22 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
)} )}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Federated OIDC Providers")}
name="jwtFederationProviders"
>
<ak-dual-select-dynamic-selected
.provider=${oauth2ProvidersProvider}
.selector=${oauth2ProviderSelector(provider?.jwtFederationProviders)}
available-label=${msg("Available Providers")}
selected-label=${msg("Selected Providers")}
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg(
"JWTs signed by the selected providers can be used to authenticate to this provider.",
)}
</p>
</ak-form-element-horizontal>
</div> </div>
</ak-form-group>`; </ak-form-group>`;
} }

View File

@ -1,6 +1,10 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
import {
oauth2ProviderSelector,
oauth2ProvidersProvider,
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
import { import {
oauth2SourcesProvider, oauth2SourcesProvider,
oauth2SourcesSelector, oauth2SourcesSelector,
@ -385,11 +389,11 @@ ${this.instance?.skipPathRegex}</textarea
${this.showHttpBasic ? this.renderHttpBasic() : html``} ${this.showHttpBasic ? this.renderHttpBasic() : html``}
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Trusted OIDC Sources")} label=${msg("Trusted OIDC Sources")}
name="jwksSources" name="jwtFederationSources"
> >
<ak-dual-select-dynamic-selected <ak-dual-select-dynamic-selected
.provider=${oauth2SourcesProvider} .provider=${oauth2SourcesProvider}
.selector=${oauth2SourcesSelector(this.instance?.jwksSources)} .selector=${oauth2SourcesSelector(this.instance?.jwtFederationSources)}
available-label=${msg("Available Sources")} available-label=${msg("Available Sources")}
selected-label=${msg("Selected Sources")} selected-label=${msg("Selected Sources")}
></ak-dual-select-dynamic-selected> ></ak-dual-select-dynamic-selected>
@ -399,6 +403,24 @@ ${this.instance?.skipPathRegex}</textarea
)} )}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Federated OIDC Providers")}
name="jwtFederationProviders"
>
<ak-dual-select-dynamic-selected
.provider=${oauth2ProvidersProvider}
.selector=${oauth2ProviderSelector(
this.instance?.jwtFederationProviders,
)}
available-label=${msg("Available Providers")}
selected-label=${msg("Selected Providers")}
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg(
"JWTs signed by the selected providers can be used to authenticate to this provider.",
)}
</p>
</ak-form-element-horizontal>
</div> </div>
</ak-form-group> </ak-form-group>

View File

@ -170,7 +170,7 @@ Example:
// JWT information when `auth_method` `jwt` was used // JWT information when `auth_method` `jwt` was used
"jwt": {}, "jwt": {},
"source": null, "source": null,
"jwk_id": "" "provider": null
} }
``` ```

View File

@ -30,17 +30,13 @@ In addition to that, with authentik 2024.4 it is also possible to pass the confi
### JWT-authentication ### JWT-authentication
Starting with authentik 2022.4, you can authenticate and get a token using an existing JWT. #### Externally issued JWTs <span class="badge badge--version">authentik 2022.4+</span>
(For readability we will refer to the JWT issued by the external issuer/platform as input JWT, and the resulting JWT from authentik as the output JWT) You can authenticate and get a token using an existing JWT. For readability we will refer to the JWT issued by the external issuer/platform as input JWT, and the resulting JWT from authentik as the output JWT.
To configure this, the certificate used to sign the input JWT must be created in authentik. The certificate is enough, a private key is not required. Afterwards, configure the certificate in the OAuth2 provider settings under _Verification certificates_. To configure this, define a JWKS URL/raw JWKS data in OAuth Sources. If a JWKS URL is specified, authentik will fetch the data and store it in the source, and then select the source in the OAuth2 Provider that will be authenticated against.
:::info With this configuration, any JWT issued by the configured sources' certificates can be used to authenticate:
Starting with authentik 2022.6, you can define a JWKS URL/raw JWKS data in OAuth Sources, and use those to verify the key instead of having to manually create a certificate in authentik for them. This method is still supported but will be removed in a later version.
:::
With this configure, any JWT issued by the configured certificates can be used to authenticate:
```http ```http
POST /application/o/token/ HTTP/1.1 POST /application/o/token/ HTTP/1.1
@ -53,11 +49,38 @@ client_assertion=$inputJWT&
client_id=application_client_id client_id=application_client_id
``` ```
Alternatively, you can set the `client_secret` parameter to the `$inputJWT`, for applications which can set the password from a file but not other parameters. Alternatively, you can set the `client_secret` parameter to `$inputJWT`, for applications that can set the password from a file but not other parameters.
Input JWTs are checked to be signed by any of the selected _Verification certificates_, and their `exp` attribute must not be now or in the past. Input JWTs are checked to verify that they are signed by any of the selected _Federated OIDC Sources_, and that their `exp` attribute is not set as now or in the past.
To do additional checks, you can use _[Expression policies](../../../customize/policies/expression.mdx)_: To dynamically limit access based on the claims of the tokens, you can use _[Expression policies](../../../customize/policies/expression.mdx)_:
```python
return request.context["oauth_jwt"]["iss"] == "https://my.issuer"
```
#### authentik-issued JWTs <span class="badge badge--version">authentik 2024.12+</span>
To allow federation between providers, modify the provider settings of the application (whose token will be used for authentication) to select the provider of the application to which you want to federate.
With this configure, any JWT issued by the configured providers can be used to authenticate:
```
POST /application/o/token/ HTTP/1.1
Host: authentik.company
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&
client_assertion=$inputJWT&
client_id=application_client_id
```
Alternatively, you can set the `client_secret` parameter to the `$inputJWT`, for applications which can set the password from a file but not other parameters.
Input JWTs must be valid access tokens issued by any of the configured _Federated OIDC Providers_, they must not have been revoked and must not have expired.
To dynamically limit access based on the claims of the tokens, you can use _[Expression policies](../../../customize/policies/expression.mdx)_:
```python ```python
return request.context["oauth_jwt"]["iss"] == "https://my.issuer" return request.context["oauth_jwt"]["iss"] == "https://my.issuer"