Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 125 additions & 11 deletions authentik/providers/oauth2/tests/test_token_exchange.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,12 +26,14 @@
Actor,
ActorPolicyInheritance,
Application,
Group,
Token,
TokenIntents,
User,
)
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.models import (
AccessToken,
ClientType,
Expand Down Expand Up @@ -79,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)

Expand Down Expand Up @@ -169,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"),
{
Expand All @@ -180,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"""
Expand Down Expand Up @@ -375,13 +495,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
Expand Down
74 changes: 63 additions & 11 deletions authentik/providers/oauth2/views/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from re import fullmatch
from typing import Any
from urllib.parse import urlparse
from uuid import UUID

from django.http import HttpRequest, HttpResponse
from django.urls import reverse
Expand Down Expand Up @@ -118,6 +119,7 @@ class TokenParams:
dpop_jwk: dict | None = None

requested_token_type: str | None = None
audience_provider: OAuth2Provider | None = None

raw_code: InitVar[str] = ""
raw_token: InitVar[str] = ""
Expand Down Expand Up @@ -152,9 +154,14 @@ def parse(
requested_token_type=request.POST.get("requested_token_type"),
)

@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 __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
)
)
Expand Down Expand Up @@ -250,6 +257,9 @@ def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
client_id=self.provider.client_id,
)
raise TokenError("invalid_client").with_cause("invalid_secret")
if self.grant_type == GRANT_TYPE_TOKEN_EXCHANGE:
# Resolved before scopes, so they're clamped to the target provider's mappings
self.__resolve_audience(request)
self.__check_scopes()
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
with start_span(
Expand Down Expand Up @@ -647,14 +657,44 @@ def __post_init_device_code(self, request: HttpRequest):
flow_name="device code",
)

def __post_init_token_exchange(self, request: HttpRequest):
"""See https://datatracker.ietf.org/doc/html/rfc8693#section-2.1"""
# 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"):
LOGGER.warning("Token targeting is not supported")
def __resolve_audience(self, request: HttpRequest):
"""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"):
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:
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:
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():
LOGGER.warning("Audience does not federate with the requesting provider")
raise TokenExchangeError("invalid_target").with_cause("target_not_federated")
self.audience_provider = target

def __post_init_token_exchange(self, request: HttpRequest):
"""See https://datatracker.ietf.org/doc/html/rfc8693#section-2.1"""
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:
Expand Down Expand Up @@ -692,13 +732,23 @@ def __post_init_token_exchange(self, request: HttpRequest):
else:
self.__create_user_from_jwt(token, app, source, 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:
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=token)

self.__post_init_token_exchange_actor(request)

method_args = {
"jwt": token,
"subject_token_type": subject_token_type,
"requested_token_type": self.requested_token_type,
}
if self.audience_provider:
method_args["audience"] = self.audience_provider.client_id
if source:
method_args["source"] = source
if provider:
Expand Down Expand Up @@ -1095,18 +1145,20 @@ 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,
scope=self.params.scope,
auth_time=now,
)
access_token.id_token = IDToken.new(
self.provider,
provider,
access_token,
self.request,
)
Expand All @@ -1120,6 +1172,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()
),
}
26 changes: 22 additions & 4 deletions website/docs/add-secure-apps/providers/oauth2/token_exchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"different one" what? Maybe "different provider"?


### Supported token types

Expand All @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"By default the issued token is for the provider that performed the exchange."


```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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"... the provider for which the token is issued." The two nouns in a row is confusing to parse.


### Configure token exchange

Expand Down
Loading