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:
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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]:
|
||||||
|
228
authentik/providers/oauth2/tests/test_token_cc_jwt_provider.py
Normal file
228
authentik/providers/oauth2/tests/test_token_cc_jwt_provider.py
Normal 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)
|
@ -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),
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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,
|
||||||
**{
|
**{
|
||||||
|
@ -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",
|
||||||
|
@ -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,
|
||||||
|
36
schema.yml
36
schema.yml
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>`;
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
Reference in New Issue
Block a user