From 19488b7b9e1adeb0b33886846655ecf3af1a9ac2 Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Tue, 3 Dec 2024 11:57:10 +0200 Subject: [PATCH] providers/oauth2: Add provider federation between OAuth2 Providers (#12083) * rename + add field Signed-off-by: Jens Langhammer * initial implementation Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * refactor Signed-off-by: Jens Langhammer * rework source cc tests Signed-off-by: Jens Langhammer * add tests Signed-off-by: Jens Langhammer * update docs Signed-off-by: Jens Langhammer * re-migrate Signed-off-by: Jens Langhammer * fix a Signed-off-by: Jens Langhammer * Apply suggestions from code review Co-authored-by: Tana M Berry Signed-off-by: Jens L. --------- Signed-off-by: Jens Langhammer Signed-off-by: Jens L. Co-authored-by: Tana M Berry --- authentik/blueprints/v1/importer.py | 8 +- authentik/providers/oauth2/api/providers.py | 3 +- ...rovider_jwt_federation_sources_and_more.py | 25 ++ authentik/providers/oauth2/models.py | 3 +- .../tests/test_token_cc_jwt_provider.py | 228 ++++++++++++++++++ .../oauth2/tests/test_token_cc_jwt_source.py | 21 +- .../providers/oauth2/views/device_init.py | 2 +- authentik/providers/oauth2/views/token.py | 86 +++++-- authentik/providers/proxy/api.py | 3 +- blueprints/schema.json | 18 +- schema.yml | 36 ++- ...lication-wizard-authentication-by-oauth.ts | 2 +- .../proxy/AuthenticationByProxyPage.ts | 4 +- .../providers/oauth2/OAuth2ProviderForm.ts | 62 ++++- .../providers/proxy/ProxyProviderForm.ts | 26 +- .../flows-stages/flow/context/index.md | 2 +- .../providers/oauth2/client_credentials.md | 45 +++- 17 files changed, 510 insertions(+), 64 deletions(-) create mode 100644 authentik/providers/oauth2/migrations/0025_rename_jwks_sources_oauth2provider_jwt_federation_sources_and_more.py create mode 100644 authentik/providers/oauth2/tests/test_token_cc_jwt_provider.py diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index ff272b5c62..67a829dd88 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -65,7 +65,12 @@ from authentik.lib.utils.reflection import get_apps from authentik.outposts.models import OutpostServiceConnection from authentik.policies.models import Policy, PolicyBindingModel 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.rbac.models import Role from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser @@ -125,6 +130,7 @@ def excluded_models() -> list[type[Model]]: MicrosoftEntraProviderGroup, EndpointDevice, EndpointDeviceConnection, + DeviceToken, ) diff --git a/authentik/providers/oauth2/api/providers.py b/authentik/providers/oauth2/api/providers.py index 83d1cba285..daf87cac35 100644 --- a/authentik/providers/oauth2/api/providers.py +++ b/authentik/providers/oauth2/api/providers.py @@ -73,7 +73,8 @@ class OAuth2ProviderSerializer(ProviderSerializer): "sub_mode", "property_mappings", "issuer_mode", - "jwks_sources", + "jwt_federation_sources", + "jwt_federation_providers", ] extra_kwargs = ProviderSerializer.Meta.extra_kwargs diff --git a/authentik/providers/oauth2/migrations/0025_rename_jwks_sources_oauth2provider_jwt_federation_sources_and_more.py b/authentik/providers/oauth2/migrations/0025_rename_jwks_sources_oauth2provider_jwt_federation_sources_and_more.py new file mode 100644 index 0000000000..1f82929189 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0025_rename_jwks_sources_oauth2provider_jwt_federation_sources_and_more.py @@ -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" + ), + ), + ] diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index 7e9ee3276f..f6e5e7bb73 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -244,7 +244,7 @@ class OAuth2Provider(WebfingerProvider, Provider): related_name="oauth2provider_encryption_key_set", ) - jwks_sources = models.ManyToManyField( + jwt_federation_sources = models.ManyToManyField( OAuthSource, verbose_name=_( "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, blank=True, ) + jwt_federation_providers = models.ManyToManyField("OAuth2Provider", blank=True, default=None) @cached_property def jwt_key(self) -> tuple[str | PrivateKeyTypes, str]: diff --git a/authentik/providers/oauth2/tests/test_token_cc_jwt_provider.py b/authentik/providers/oauth2/tests/test_token_cc_jwt_provider.py new file mode 100644 index 0000000000..abe1b5c757 --- /dev/null +++ b/authentik/providers/oauth2/tests/test_token_cc_jwt_provider.py @@ -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) diff --git a/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py b/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py index d52a2ed020..5de0bd7ebc 100644 --- a/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py +++ b/authentik/providers/oauth2/tests/test_token_cc_jwt_source.py @@ -37,9 +37,16 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): def setUp(self) -> None: super().setUp() 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() - 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( name=generate_id(), slug=generate_id(), @@ -62,7 +69,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver")], 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.app = Application.objects.create(name="test", slug="test", provider=self.provider) @@ -100,7 +107,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): def test_invalid_signature(self): """test invalid JWT""" - token = self.provider.encode( + token = self.helper_provider.encode( { "sub": "foo", "exp": datetime.now() + timedelta(hours=2), @@ -122,7 +129,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): def test_invalid_expired(self): """test invalid JWT""" - token = self.provider.encode( + token = self.helper_provider.encode( { "sub": "foo", "exp": datetime.now() - timedelta(hours=2), @@ -146,7 +153,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): """test invalid JWT""" self.app.provider = None self.app.save() - token = self.provider.encode( + token = self.helper_provider.encode( { "sub": "foo", "exp": datetime.now() + timedelta(hours=2), @@ -174,7 +181,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): target=self.app, order=0, ) - token = self.provider.encode( + token = self.helper_provider.encode( { "sub": "foo", "exp": datetime.now() + timedelta(hours=2), @@ -196,7 +203,7 @@ class TestTokenClientCredentialsJWTSource(OAuthTestCase): def test_successful(self): """test successful""" - token = self.provider.encode( + token = self.helper_provider.encode( { "sub": "foo", "exp": datetime.now() + timedelta(hours=2), diff --git a/authentik/providers/oauth2/views/device_init.py b/authentik/providers/oauth2/views/device_init.py index 85b32d8051..0fc114bcd4 100644 --- a/authentik/providers/oauth2/views/device_init.py +++ b/authentik/providers/oauth2/views/device_init.py @@ -137,7 +137,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse): class OAuthDeviceCodeStage(ChallengeStageView): - """Flow challenge for users to enter device codes""" + """Flow challenge for users to enter device code""" response_class = OAuthDeviceCodeChallengeResponse diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 4ce13a6bca..9ee25dd555 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -362,23 +362,9 @@ class TokenParams: }, ).from_http(request, user=user) - 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") - - token = None - - source: OAuthSource | None = None - parsed_key: PyJWK | None = None - + def __validate_jwt_from_source( + self, assertion: str + ) -> tuple[dict, OAuthSource] | tuple[None, None]: # Fully decode the JWT without verifying the signature, so we can get access to # the header. # 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 expected_kid = decode_unvalidated["header"]["kid"] 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}] ): LOGGER.debug("verifying JWT with source", source=source.slug) @@ -404,10 +391,10 @@ class TokenParams: continue LOGGER.debug("verifying JWT with key", source=source.slug, key=key.get("kid")) try: - parsed_key = PyJWK.from_dict(key) + parsed_key = PyJWK.from_dict(key).key token = decode( assertion, - parsed_key.key, + parsed_key, algorithms=[key.get("alg")] if "alg" in key else [fallback_alg], options={ "verify_aud": False, @@ -417,13 +404,61 @@ class TokenParams: # and not a public key except (PyJWTError, ValueError, TypeError, AttributeError) as exc: 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: LOGGER.warning("No token could be verified") raise TokenError("invalid_grant") - LOGGER.info("successfully verified JWT with source", source=source.slug) - if "exp" in token: exp = datetime.fromtimestamp(token["exp"]) # Non-timezone aware check since we assume `exp` is in UTC @@ -437,15 +472,16 @@ class TokenParams: raise TokenError("invalid_grant") 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 = { "jwt": token, } if source: method_args["source"] = source - if parsed_key: - method_args["jwk_id"] = parsed_key.key_id + if provider: + method_args["provider"] = provider Event.new( action=EventAction.LOGIN, **{ diff --git a/authentik/providers/proxy/api.py b/authentik/providers/proxy/api.py index 2d02096bb8..d228180ffe 100644 --- a/authentik/providers/proxy/api.py +++ b/authentik/providers/proxy/api.py @@ -94,7 +94,8 @@ class ProxyProviderSerializer(ProviderSerializer): "intercept_header_auth", "redirect_uris", "cookie_domain", - "jwks_sources", + "jwt_federation_sources", + "jwt_federation_providers", "access_token_validity", "refresh_token_validity", "outpost_set", diff --git a/blueprints/schema.json b/blueprints/schema.json index 51e5dc871d..7ff0b11d13 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -5617,13 +5617,20 @@ "title": "Issuer mode", "description": "Configure how the issuer field of the ID Token should be filled." }, - "jwks_sources": { + "jwt_federation_sources": { "type": "array", "items": { "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." + }, + "jwt_federation_providers": { + "type": "array", + "items": { + "type": "integer" + }, + "title": "Jwt federation providers" } }, "required": [] @@ -5746,7 +5753,7 @@ "type": "string", "title": "Cookie domain" }, - "jwks_sources": { + "jwt_federation_sources": { "type": "array", "items": { "type": "integer", @@ -5754,6 +5761,13 @@ }, "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": { "type": "string", "minLength": 1, diff --git a/schema.yml b/schema.yml index ee2ad79700..40d5ba2bd7 100644 --- a/schema.yml +++ b/schema.yml @@ -44785,7 +44785,7 @@ components: allOf: - $ref: '#/components/schemas/IssuerModeEnum' description: Configure how the issuer field of the ID Token should be filled. - jwks_sources: + jwt_federation_sources: type: array items: type: string @@ -44793,6 +44793,10 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. + jwt_federation_providers: + type: array + items: + type: integer required: - assigned_application_name - assigned_application_slug @@ -44888,7 +44892,7 @@ components: allOf: - $ref: '#/components/schemas/IssuerModeEnum' description: Configure how the issuer field of the ID Token should be filled. - jwks_sources: + jwt_federation_sources: type: array items: type: string @@ -44896,6 +44900,10 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. + jwt_federation_providers: + type: array + items: + type: integer required: - authorization_flow - invalidation_flow @@ -48911,7 +48919,7 @@ components: allOf: - $ref: '#/components/schemas/IssuerModeEnum' description: Configure how the issuer field of the ID Token should be filled. - jwks_sources: + jwt_federation_sources: type: array items: type: string @@ -48919,6 +48927,10 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. + jwt_federation_providers: + type: array + items: + type: integer PatchedOAuthSourcePropertyMappingRequest: type: object description: OAuthSourcePropertyMapping Serializer @@ -49434,7 +49446,7 @@ components: header and authenticate requests based on its value. cookie_domain: type: string - jwks_sources: + jwt_federation_sources: type: array items: type: string @@ -49442,6 +49454,10 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. + jwt_federation_providers: + type: array + items: + type: integer access_token_validity: type: string minLength: 1 @@ -51504,7 +51520,7 @@ components: readOnly: true cookie_domain: type: string - jwks_sources: + jwt_federation_sources: type: array items: type: string @@ -51512,6 +51528,10 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. + jwt_federation_providers: + type: array + items: + type: integer access_token_validity: type: string 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. cookie_domain: type: string - jwks_sources: + jwt_federation_sources: type: array items: type: string @@ -51620,6 +51640,10 @@ components: title: Any JWT signed by the JWK of the selected source can be used to authenticate. title: Any JWT signed by the JWK of the selected source can be used to authenticate. + jwt_federation_providers: + type: array + items: + type: integer access_token_validity: type: string minLength: 1 diff --git a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts index 0eb85cf0d0..ebdf1472a3 100644 --- a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts +++ b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts @@ -307,7 +307,7 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel { > diff --git a/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts b/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts index 6219beaa0a..af7d8fd16d 100644 --- a/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts +++ b/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts @@ -248,7 +248,9 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel { > diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index 29ceb68792..7e6d1b35f0 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -13,6 +13,7 @@ import "@goauthentik/components/ak-textarea-input"; 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-provider.js"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/Radio"; @@ -116,6 +117,45 @@ export const redirectUriHelp = html`${redirectUriHelpMessages.map( (m) => html`

${m}

`, )}`; +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[]) => + mappings.filter( + ([_0, _1, _2, source]: DualSelectPair) => 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 * @@ -381,12 +421,12 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { ${msg("Machine-to-Machine authentication settings")}
@@ -396,6 +436,22 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { )}

+ + +

+ ${msg( + "JWTs signed by the selected providers can be used to authenticate to this provider.", + )} +

+
`; } diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index aa93ea08ad..d111f20b4c 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -1,6 +1,10 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; +import { + oauth2ProviderSelector, + oauth2ProvidersProvider, +} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; import { oauth2SourcesProvider, oauth2SourcesSelector, @@ -385,11 +389,11 @@ ${this.instance?.skipPathRegex} @@ -399,6 +403,24 @@ ${this.instance?.skipPathRegex} + + +

+ ${msg( + "JWTs signed by the selected providers can be used to authenticate to this provider.", + )} +

+
diff --git a/website/docs/add-secure-apps/flows-stages/flow/context/index.md b/website/docs/add-secure-apps/flows-stages/flow/context/index.md index d2ce76dc36..ec79f018e2 100644 --- a/website/docs/add-secure-apps/flows-stages/flow/context/index.md +++ b/website/docs/add-secure-apps/flows-stages/flow/context/index.md @@ -170,7 +170,7 @@ Example: // JWT information when `auth_method` `jwt` was used "jwt": {}, "source": null, - "jwk_id": "" + "provider": null } ``` diff --git a/website/docs/add-secure-apps/providers/oauth2/client_credentials.md b/website/docs/add-secure-apps/providers/oauth2/client_credentials.md index 18f7b0df60..fe7535a3c6 100644 --- a/website/docs/add-secure-apps/providers/oauth2/client_credentials.md +++ b/website/docs/add-secure-apps/providers/oauth2/client_credentials.md @@ -30,17 +30,13 @@ In addition to that, with authentik 2024.4 it is also possible to pass the confi ### JWT-authentication -Starting with authentik 2022.4, you can authenticate and get a token using an existing JWT. +#### Externally issued JWTs authentik 2022.4+ -(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 -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: +With this configuration, any JWT issued by the configured sources' certificates can be used to authenticate: ```http POST /application/o/token/ HTTP/1.1 @@ -53,11 +49,38 @@ 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. +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 authentik 2024.12+ + +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 return request.context["oauth_jwt"]["iss"] == "https://my.issuer"