diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py index ff272b5c62f2..67a829dd8808 100644 --- a/authentik/blueprints/v1/importer.py +++ b/authentik/blueprints/v1/importer.py @@ -65,7 +65,12 @@ 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 83d1cba2857a..daf87cac357b 100644 --- a/authentik/providers/oauth2/api/providers.py +++ b/authentik/providers/oauth2/api/providers.py @@ -73,7 +73,8 @@ class Meta: "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 000000000000..1f8292918924 --- /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 7e9ee3276f79..f6e5e7bb7333 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 000000000000..abe1b5c7575f --- /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 d52a2ed020de..5de0bd7ebc6c 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 @@ def setUp(self) -> None: 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 @@ def test_invalid_jwt(self): 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 @@ def test_invalid_signature(self): 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 @@ def test_invalid_no_app(self): """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 @@ def test_invalid_access_denied(self): 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 @@ def test_invalid_access_denied(self): 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 85b32d805152..0fc114bcd418 100644 --- a/authentik/providers/oauth2/views/device_init.py +++ b/authentik/providers/oauth2/views/device_init.py @@ -137,7 +137,7 @@ def validate_code(self, code: int) -> HttpResponse | None: 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 4ce13a6bcac4..9ee25dd555ed 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -362,23 +362,9 @@ def __post_init_client_credentials_creds( }, ).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 @@ def __post_init_client_credentials_jwt(self, request: HttpRequest): 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 @@ def __post_init_client_credentials_jwt(self, request: HttpRequest): 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 @@ def __post_init_client_credentials_jwt(self, request: HttpRequest): # 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 @@ def __post_init_client_credentials_jwt(self, request: HttpRequest): 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 2d02096bb87c..d228180ffe49 100644 --- a/authentik/providers/proxy/api.py +++ b/authentik/providers/proxy/api.py @@ -94,7 +94,8 @@ class Meta: "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 51e5dc871dd2..7ff0b11d1376 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 ee2ad7970005..40d5ba2bd7a3 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 0eb85cf0d0ce..ebdf1472a336 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 6219beaa0aa8..af7d8fd16d9a 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 29ceb68792a3..7e6d1b35f0b0 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 aa93ea08adbf..d111f20b4c13 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 d2ce76dc3671..ec79f018e2cf 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 18f7b0df60e2..fe7535a3c6ab 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 `$inputJWT`, for applications that can set the password from a file but not other parameters. + +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 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 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 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 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"