diff --git a/authentik/providers/oauth2/tests/test_token_exchange.py b/authentik/providers/oauth2/tests/test_token_exchange.py index b50a1ffbe376..33e1aa0530e0 100644 --- a/authentik/providers/oauth2/tests/test_token_exchange.py +++ b/authentik/providers/oauth2/tests/test_token_exchange.py @@ -2,7 +2,9 @@ from datetime import datetime, timedelta from json import loads +from uuid import uuid4 +from django.http import HttpResponse from django.test import RequestFactory from django.urls import reverse from django.utils.timezone import now @@ -81,6 +83,19 @@ def setUp(self) -> None: name=generate_id(), slug=generate_id(), provider=self.provider ) + # The provider a token can be requested for via `audience` + self.target_cert = create_test_cert() + self.target_provider = OAuth2Provider.objects.create( + name=generate_id(), + authorization_flow=create_test_flow(), + signing_key=self.target_cert, + ) + self.target_provider.jwt_federation_providers.add(self.provider) + self.target_provider.property_mappings.set(ScopeMapping.objects.all()) + self.target_app = Application.objects.create( + name=generate_id(), slug=generate_id(), provider=self.target_provider + ) + self.user = create_test_user() self.subject_token = self.create_subject_token(self.user) @@ -171,8 +186,76 @@ def test_actor_token_unsupported_type_rejected(self): body = loads(response.content.decode()) self.assertEqual(body["error"], "invalid_request") - def test_audience_rejected(self): - """test that a requested audience is refused rather than silently ignored""" + def _exchange(self, **extra) -> HttpResponse: + """Run an otherwise-valid exchange, with `extra` merged into the request""" + return self.client.post( + reverse("authentik_providers_oauth2:token"), + { + "grant_type": GRANT_TYPE_TOKEN_EXCHANGE, + "scope": SCOPES, + "client_id": self.provider.client_id, + "client_secret": self.provider.client_secret, + "subject_token": self.subject_token, + "subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN, + **extra, + }, + ) + + def _decode_for(self, provider: OAuth2Provider, access_token: str) -> dict: + _, alg = provider.jwt_key + return decode( + access_token, + key=provider.signing_key.public_key, + algorithms=[alg], + audience=provider.client_id, + ) + + def test_audience_client_id(self): + """test that an audience naming a provider's client_id issues on that provider""" + response = self._exchange(audience=self.target_provider.client_id) + self.assertEqual(response.status_code, 200, response.content) + body = loads(response.content.decode()) + + jwt = self._decode_for(self.target_provider, body["access_token"]) + self.assertEqual(jwt["aud"], self.target_provider.client_id) + self.assertEqual(jwt["azp"], self.target_provider.client_id) + self.assertIn(self.target_app.slug, jwt["iss"]) + self.assertEqual(jwt["preferred_username"], self.user.username) + + access_token = AccessToken.objects.get(token=body["access_token"]) + self.assertEqual(access_token.provider_id, self.target_provider.pk) + self.assertEqual(access_token.user_id, self.user.pk) + + def test_audience_pbm_uuid(self): + """test that an audience naming an application's pbm_uuid issues on its provider""" + response = self._exchange(audience=str(self.target_app.pbm_uuid)) + self.assertEqual(response.status_code, 200, response.content) + body = loads(response.content.decode()) + + jwt = self._decode_for(self.target_provider, body["access_token"]) + self.assertEqual(jwt["aud"], self.target_provider.client_id) + access_token = AccessToken.objects.get(token=body["access_token"]) + self.assertEqual(access_token.provider_id, self.target_provider.pk) + + def test_audience_self(self): + """test that naming the requesting provider still issues on it""" + response = self._exchange(audience=self.provider.client_id) + self.assertEqual(response.status_code, 200, response.content) + body = loads(response.content.decode()) + + access_token = AccessToken.objects.get(token=body["access_token"]) + self.assertEqual(access_token.provider_id, self.provider.pk) + + def test_audience_unknown(self): + """test an audience that matches no provider, both as a URI and as a UUID""" + for audience in ["https://api.example.com", str(uuid4())]: + with self.subTest(audience=audience): + response = self._exchange(audience=audience) + self.assertEqual(response.status_code, 400) + self.assertEqual(loads(response.content.decode())["error"], "invalid_target") + + def test_audience_multiple(self): + """test that multi-provider tokens are refused rather than silently narrowed""" response = self.client.post( reverse("authentik_providers_oauth2:token"), { @@ -182,12 +265,47 @@ def test_audience_rejected(self): "client_secret": self.provider.client_secret, "subject_token": self.subject_token, "subject_token_type": TOKEN_TYPE_URI_ACCESS_TOKEN, - "audience": "https://api.example.com", + "audience": [self.provider.client_id, self.target_provider.client_id], }, ) self.assertEqual(response.status_code, 400) + self.assertEqual(loads(response.content.decode())["error"], "invalid_target") + + def test_audience_not_federated(self): + """test an audience that does not federate with the requesting provider""" + self.target_provider.jwt_federation_providers.clear() + response = self._exchange(audience=self.target_provider.client_id) + self.assertEqual(response.status_code, 400) + self.assertEqual(loads(response.content.decode())["error"], "invalid_target") + + def test_audience_without_application(self): + """test an audience whose provider has no application""" + self.target_app.delete() + response = self._exchange(audience=self.target_provider.client_id) + self.assertEqual(response.status_code, 400) + self.assertEqual(loads(response.content.decode())["error"], "invalid_target") + + def test_audience_policy_denied(self): + """test that the target application's policies gate the exchange""" + PolicyBinding.objects.create( + group=Group.objects.create(name=generate_id()), + target=self.target_app, + order=0, + ) + response = self._exchange(audience=self.target_provider.client_id) + self.assertEqual(response.status_code, 400) + self.assertEqual(loads(response.content.decode())["error"], "invalid_grant") + self.assertFalse(AccessToken.objects.filter(provider=self.target_provider).exists()) + + def test_audience_scopes_from_target(self): + """test that scopes are clamped to the target provider's mappings, not the client's""" + self.target_provider.property_mappings.set( + ScopeMapping.objects.filter(scope_name=SCOPE_OPENID) + ) + response = self._exchange(audience=self.target_provider.client_id) + self.assertEqual(response.status_code, 200, response.content) body = loads(response.content.decode()) - self.assertEqual(body["error"], "invalid_target") + self.assertEqual(body["scope"], SCOPE_OPENID) def test_resource_rejected(self): """test that a requested resource is refused rather than silently ignored""" @@ -416,13 +534,7 @@ def test_successful_requested_jwt(self): self.assertEqual(body["issued_token_type"], TOKEN_TYPE_URI_JWT) def _decode(self, access_token: str) -> dict: - _, alg = self.provider.jwt_key - return decode( - access_token, - key=self.provider.signing_key.public_key, - algorithms=[alg], - audience=self.provider.client_id, - ) + return self._decode_for(self.provider, access_token) def _actor_token_jwt(self, actor: Actor) -> str: """Issue an access token for `actor` from the federated provider, usable as a diff --git a/authentik/providers/oauth2/token/base.py b/authentik/providers/oauth2/token/base.py index fdb0b6b26220..f59a03be4833 100644 --- a/authentik/providers/oauth2/token/base.py +++ b/authentik/providers/oauth2/token/base.py @@ -46,6 +46,7 @@ class TokenRequest: dpop_jwk: dict | None = None provider: OAuth2Provider + audience_provider: OAuth2Provider | None = None logger: BoundLogger def __init__(self, provider: OAuth2Provider, client_id: str, client_secret: str): @@ -54,6 +55,11 @@ def __init__(self, provider: OAuth2Provider, client_id: str, client_secret: str) self.client_id = client_id self.client_secret = client_secret + @property + def token_provider(self) -> OAuth2Provider: + """Provider the issued token is for: the `audience` target, else the client's own.""" + return self.audience_provider or self.provider + def parse(self, request: HttpRequest) -> None: self.redirect_uri = request.POST.get("redirect_uri", "") self.grant_type = request.POST.get("grant_type", "") @@ -88,11 +94,17 @@ def parse(self, request: HttpRequest) -> None: client_id=self.provider.client_id, ) raise TokenError("invalid_client").with_cause("invalid_secret") + # Resolved before scopes, so they're clamped to the target provider's mappings + self.resolve_audience(request) self.check_scopes() + def resolve_audience(self, request: HttpRequest) -> None: + """Resolve the provider the issued token is for. Only token exchange supports + targeting a provider other than the client's own.""" + def check_scopes(self): allowed_scope_names = set( - ScopeMapping.objects.filter(provider__in=[self.provider]).values_list( + ScopeMapping.objects.filter(provider__in=[self.token_provider]).values_list( "scope_name", flat=True ) ) diff --git a/authentik/providers/oauth2/token/token_exchange.py b/authentik/providers/oauth2/token/token_exchange.py index 4e58638f86d7..fc9c6a63a30c 100644 --- a/authentik/providers/oauth2/token/token_exchange.py +++ b/authentik/providers/oauth2/token/token_exchange.py @@ -1,3 +1,5 @@ +from uuid import UUID + from django.http import HttpRequest from authentik.common.oauth.constants import ( @@ -10,21 +12,52 @@ from authentik.events.models import Event, EventAction from authentik.flows.planner import PLAN_CONTEXT_APPLICATION from authentik.providers.oauth2.errors import TokenExchangeError +from authentik.providers.oauth2.models import OAuth2Provider from authentik.providers.oauth2.token.base_fed import FederatedTokenRequest from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS class TokenExchangeTokenRequest(FederatedTokenRequest): + def resolve_audience(self, request: HttpRequest) -> None: + """RFC 8693 §2.1 `audience`: the provider the issued token is for, named by its + `client_id` or by its application's `pbm_uuid`. Targets that cannot be honored are + refused with invalid_target (§2.2.2) rather than ignored.""" + # RFC 8707 resource indicators are not implemented. + if request.POST.getlist("resource"): + self.logger.warning("Resource indicators are not supported") + raise TokenExchangeError("invalid_target").with_cause("target_unsupported") + audiences = request.POST.getlist("audience") + if not audiences: + return + # Multi-provider tokens are not supported. + if len(audiences) > 1: + self.logger.warning("Multiple audiences are not supported") + raise TokenExchangeError("invalid_target").with_cause("multiple_audiences") + audience = audiences[0] + target = OAuth2Provider.objects.filter(client_id=audience).first() + if not target: + try: + pbm_uuid = UUID(audience) + except ValueError: + pbm_uuid = None + if pbm_uuid: + target = OAuth2Provider.objects.filter(application__pbm_uuid=pbm_uuid).first() + if not target: + self.logger.warning("Audience does not match any provider", audience=audience) + raise TokenExchangeError("invalid_target").with_cause("unknown_target") + # Targeting itself is the default behavior, nothing to switch to. + if target.pk == self.provider.pk: + return + # The target must explicitly federate with the requesting provider. + if not target.jwt_federation_providers.filter(pk=self.provider.pk).exists(): + self.logger.warning("Audience does not federate with the requesting provider") + raise TokenExchangeError("invalid_target").with_cause("target_not_federated") + self.audience_provider = target + def parse(self, request: HttpRequest) -> None: """See https://datatracker.ietf.org/doc/html/rfc8693#section-2.1""" super().parse(request) - # Token targeting is not implemented. RFC 8693 §2.2.2 requires invalid_target when the - # requested target cannot be honored, so the parameters are refused rather than ignored. - if request.POST.getlist("audience") or request.POST.getlist("resource"): - self.logger.warning("Token targeting is not supported") - raise TokenExchangeError("invalid_target").with_cause("target_unsupported") - subject_token = request.POST.get("subject_token", "") subject_token_type = request.POST.get("subject_token_type", "") if not subject_token or not subject_token_type: @@ -62,6 +95,14 @@ def parse(self, request: HttpRequest) -> None: if not federated_party.user: self.user = self.create_user_from_jwt(federated_party, app, request) + if self.audience_provider: + # An application is also required to give the issued token an `iss` + target_app = Application.objects.filter(provider=self.audience_provider).first() + if not target_app: + self.logger.info("Audience provider has no application") + raise TokenExchangeError("invalid_target").with_cause("target_without_application") + self.check_policy_access(target_app, request, oauth_jwt=federated_party.parsed_token) + self.post_init_token_exchange_actor(request) method_args = { @@ -70,6 +111,8 @@ def parse(self, request: HttpRequest) -> None: "requested_token_type": self.requested_token_type, federated_party.type: federated_party.party, } + if self.audience_provider: + method_args["audience"] = self.audience_provider.client_id Event.new( action=EventAction.LOGIN, **{ diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 2057b01f7c6e..9935eda3dc2c 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -335,10 +335,12 @@ def create_device_code_response(self) -> dict[str, Any]: def create_token_exchange_response(self) -> dict[str, Any]: """See https://datatracker.ietf.org/doc/html/rfc8693#section-2.2.1""" + # Issued on the `audience` target, else on the client's own provider + provider = self.params.token_provider now = timezone.now() - access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) + access_token_expiry = now + timedelta_from_string(provider.access_token_validity) access_token = AccessToken( - provider=self.provider, + provider=provider, user=self.params.user, actor=self.params.actor, expires=access_token_expiry, @@ -346,7 +348,7 @@ def create_token_exchange_response(self) -> dict[str, Any]: auth_time=now, ) access_token.id_token = IDToken.new( - self.provider, + provider, access_token, self.request, ) @@ -360,6 +362,6 @@ def create_token_exchange_response(self) -> dict[str, Any]: "token_type": TOKEN_TYPE, "scope": " ".join(access_token.scope), "expires_in": int( - timedelta_from_string(self.provider.access_token_validity).total_seconds() + timedelta_from_string(provider.access_token_validity).total_seconds() ), } diff --git a/website/docs/add-secure-apps/providers/oauth2/token_exchange.md b/website/docs/add-secure-apps/providers/oauth2/token_exchange.md index b92e6e3d1ad2..516d54d32b57 100644 --- a/website/docs/add-secure-apps/providers/oauth2/token_exchange.md +++ b/website/docs/add-secure-apps/providers/oauth2/token_exchange.md @@ -48,7 +48,7 @@ The response contains the following fields: - `expires_in`: The total seconds after which the issued token will expire - `scope`: The scopes granted to the issued token -The issued token is a new access token for the requesting provider, carrying the identity of the user named by the subject token. +The issued token is a new access token carrying the identity of the user named by the subject token. It is issued for the requesting provider, unless [`audience`](#audience) names a different one. ### Supported token types @@ -61,16 +61,34 @@ authentik access tokens are themselves JWTs, so both identifiers refer to the sa Any other token type is rejected with `invalid_request`. +### Audience + +By default the issued token is a token for the provider that performed the exchange. Set `audience` to receive a token for a different provider instead: + +```http +audience=target_application_client_id +``` + +The value is either the target provider's `client_id`, or the `pbm_uuid` of the application the target provider is bound to. Only a single value is accepted; multi-provider tokens are not supported. + +The issued token is then a token of the target provider in every respect: signed by its signing key, with its issuer as `iss`, its `client_id` as `aud`, and its subject mode and scope mappings applied. The target provider's own endpoints (userinfo, introspection, revocation) accept it. + +Two conditions must both hold, or the request is rejected with `invalid_target`: + +- The target provider must list the requesting provider under **Federated OAuth2/OpenID Providers**. This is the target's explicit opt-in; without it, any client could mint tokens for any provider. +- The target provider must be bound to an application. + +The user identified by the subject token must also pass that application's policy bindings, otherwise the request is rejected with `invalid_grant`. + ### Unsupported parameters authentik rejects the following rather than ignoring them, so that a client is never led to believe a restriction was applied when it was not: -- `actor_token` and `actor_token_type` are rejected with `invalid_request`, because delegation is not supported. -- `audience` and `resource` are rejected with `invalid_target`, because the issued token cannot be scoped to a named target. +- `resource` is rejected with `invalid_target`. Use `audience` to name a target. ### Scopes -The scopes granted to the issued token are the requested `scope` values, reduced to those the requesting provider is configured to issue. If `scope` is omitted, the issued token is granted no scopes. +The scopes granted to the issued token are the requested `scope` values, reduced to those the provider the token is issued for is configured to issue — the target provider when `audience` is set, otherwise the requesting provider. If `scope` is omitted, the issued token is granted no scopes. ### Configure token exchange