From 912fc06f82520e74895ea982c8086970bf0ebc62 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 18 Jul 2026 15:54:22 +0100 Subject: [PATCH 1/3] feat(trust-relationships): Add OIDC token exchange endpoint POST /api/v1/auth/oidc/token/ validates an external OIDC token (signature via issuer JWKS, expiry, audience, configured claim rules) against the organisation's trust relationships and mints a short-lived HS256 access token. The token resolves to the trust relationship's backing master API key at request time, so revocation and role changes apply to outstanding tokens immediately. beep boop --- api/api_keys/views.py | 8 +- api/app/settings/common.py | 6 + api/app/settings/test.py | 1 + api/custom_auth/urls.py | 6 + .../trust_relationships/conftest.py | 40 ++++ .../test_token_exchange.py | 164 ++++++++++++++ api/tests/test_helpers.py | 41 ++++ .../unit/trust_relationships/conftest.py | 50 +++++ .../test_authentication.py | 56 +++++ .../unit/trust_relationships/test_oidc.py | 124 ++++++++++ .../unit/trust_relationships/test_services.py | 77 +++++++ .../test_token_exchange.py | 211 ++++++++++++++++++ api/trust_relationships/authentication.py | 46 ++++ api/trust_relationships/constants.py | 8 + api/trust_relationships/dataclasses.py | 7 + api/trust_relationships/exceptions.py | 21 ++ api/trust_relationships/oidc.py | 55 +++++ api/trust_relationships/serializers.py | 17 ++ api/trust_relationships/services.py | 135 ++++++++++- api/trust_relationships/types.py | 12 + api/trust_relationships/views.py | 61 ++++- .../observability/_events-catalogue.md | 31 ++- openapi.yaml | 55 +++++ 23 files changed, 1224 insertions(+), 8 deletions(-) create mode 100644 api/tests/integration/trust_relationships/test_token_exchange.py create mode 100644 api/tests/unit/trust_relationships/conftest.py create mode 100644 api/tests/unit/trust_relationships/test_authentication.py create mode 100644 api/tests/unit/trust_relationships/test_oidc.py create mode 100644 api/tests/unit/trust_relationships/test_token_exchange.py create mode 100644 api/trust_relationships/authentication.py create mode 100644 api/trust_relationships/constants.py create mode 100644 api/trust_relationships/dataclasses.py create mode 100644 api/trust_relationships/exceptions.py create mode 100644 api/trust_relationships/oidc.py create mode 100644 api/trust_relationships/types.py diff --git a/api/api_keys/views.py b/api/api_keys/views.py index 3aebea0e41b2..cb60f26ac917 100644 --- a/api/api_keys/views.py +++ b/api/api_keys/views.py @@ -6,6 +6,9 @@ from organisations.permissions.permissions import ( NestedIsOrganisationAdminPermission, ) +from trust_relationships.authentication import ( + TrustRelationshipTokenAuthentication, +) from .authentication import MasterAPIKeyAuthentication from .models import MasterAPIKey @@ -18,7 +21,10 @@ def get_authenticators(self) -> list[BaseAuthentication]: return [ authenticator for authenticator in super().get_authenticators() - if not isinstance(authenticator, MasterAPIKeyAuthentication) + if not isinstance( + authenticator, + (MasterAPIKeyAuthentication, TrustRelationshipTokenAuthentication), + ) ] diff --git a/api/app/settings/common.py b/api/app/settings/common.py index ec0eb427a52c..a449ba40ec28 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -337,6 +337,10 @@ LOGIN_THROTTLE_RATE = env("LOGIN_THROTTLE_RATE", "20/min") DCR_THROTTLE_RATE = env("DCR_THROTTLE_RATE", "500/month") +OIDC_TOKEN_EXCHANGE_THROTTLE_RATE = env("OIDC_TOKEN_EXCHANGE_THROTTLE_RATE", "60/min") +TRUST_RELATIONSHIP_ACCESS_TOKEN_LIFETIME_SECONDS = env.int( + "TRUST_RELATIONSHIP_ACCESS_TOKEN_LIFETIME_SECONDS", default=3600 +) SIGNUP_THROTTLE_RATE = env("SIGNUP_THROTTLE_RATE", "10000/min") USER_THROTTLE_RATE = env("USER_THROTTLE_RATE", default=None) MASTER_API_KEY_THROTTLE_RATE = env("MASTER_API_KEY_THROTTLE_RATE", default=None) @@ -348,6 +352,7 @@ "rest_framework.authentication.TokenAuthentication", "api_keys.authentication.MasterAPIKeyAuthentication", "oauth2_metadata.authentication.OAuth2BearerTokenAuthentication", + "trust_relationships.authentication.TrustRelationshipTokenAuthentication", ), "PAGE_SIZE": 10, "UNICODE_JSON": False, @@ -356,6 +361,7 @@ "DEFAULT_THROTTLE_RATES": { "login": LOGIN_THROTTLE_RATE, "dcr_register": DCR_THROTTLE_RATE, + "oidc_token_exchange": OIDC_TOKEN_EXCHANGE_THROTTLE_RATE, "signup": SIGNUP_THROTTLE_RATE, "master_api_key": MASTER_API_KEY_THROTTLE_RATE, "mfa_code": "5/min", diff --git a/api/app/settings/test.py b/api/app/settings/test.py index c659649528a0..c0fb26794742 100644 --- a/api/app/settings/test.py +++ b/api/app/settings/test.py @@ -21,6 +21,7 @@ REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { "login": "100/min", "dcr_register": "100/min", + "oidc_token_exchange": "100/min", "mfa_code": "5/min", "invite": "10/min", "signup": "100/min", diff --git a/api/custom_auth/urls.py b/api/custom_auth/urls.py index 1494567fc698..7bfbd6f02f69 100644 --- a/api/custom_auth/urls.py +++ b/api/custom_auth/urls.py @@ -8,6 +8,7 @@ FFAdminUserViewSet, delete_token, ) +from trust_relationships.views import OIDCTokenExchangeView app_name = "custom_auth" @@ -38,4 +39,9 @@ path("", include("djoser.urls")), path("", include("custom_auth.mfa.trench.urls")), # MFA path("oauth/", include("custom_auth.oauth.urls")), + path( + "oidc/token/", + OIDCTokenExchangeView.as_view(), + name="oidc-token-exchange", + ), ] diff --git a/api/tests/integration/trust_relationships/conftest.py b/api/tests/integration/trust_relationships/conftest.py index 5b499452131a..a7e85313287f 100644 --- a/api/tests/integration/trust_relationships/conftest.py +++ b/api/tests/integration/trust_relationships/conftest.py @@ -1,7 +1,15 @@ +from typing import Generator + +import jwt import pytest +import responses as responses_lib +from pytest_mock import MockerFixture from rest_framework import status from rest_framework.test import APIClient +from tests.test_helpers import OIDCIssuerStub +from trust_relationships.oidc import get_jwks_client + @pytest.fixture() def trust_relationship( @@ -20,3 +28,35 @@ def trust_relationship( ) assert response.status_code == status.HTTP_201_CREATED return response.json()["id"] # type: ignore[no-any-return] + + +@pytest.fixture(autouse=True) +def clear_jwks_client_cache() -> Generator[None, None, None]: + get_jwks_client.cache_clear() + yield + get_jwks_client.cache_clear() + + +@pytest.fixture() +def oidc_issuer( + responses: responses_lib.RequestsMock, + mocker: MockerFixture, +) -> OIDCIssuerStub: + issuer = OIDCIssuerStub("https://token.actions.githubusercontent.com") + # Rejections short-circuiting before discovery are expected. + responses.assert_all_requests_are_fired = False + responses.add( + responses_lib.GET, + "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + json={ + "jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks" + }, + ) + mocker.patch.object(jwt.PyJWKClient, "fetch_data", return_value=issuer.jwks) + return issuer + + +@pytest.fixture() +def machine_client() -> APIClient: + # A client that is never force-authenticated + return APIClient() diff --git a/api/tests/integration/trust_relationships/test_token_exchange.py b/api/tests/integration/trust_relationships/test_token_exchange.py new file mode 100644 index 000000000000..d7b01238bb59 --- /dev/null +++ b/api/tests/integration/trust_relationships/test_token_exchange.py @@ -0,0 +1,164 @@ +from rest_framework import status +from rest_framework.test import APIClient + +from tests.test_helpers import OIDCIssuerStub + + +def test_token_exchange__matching_token__returns_usable_access_token( + machine_client: APIClient, + organisation: int, + trust_relationship: int, + oidc_issuer: OIDCIssuerStub, +) -> None: + # Given + token = oidc_issuer.sign_token( + aud="https://github.com/Flagsmith", + sub="repo:Flagsmith/flagsmith:ref:refs/heads/main", + repository="Flagsmith/flagsmith", + ) + + # When + response = machine_client.post( + "/api/v1/auth/oidc/token/", data={"token": token}, format="json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + response_json = response.json() + assert response_json["token_type"] == "Bearer" + assert response_json["expires_in"] == 3600 + + # And the minted token authenticates against the admin API + machine_client.credentials( + HTTP_AUTHORIZATION=f"Bearer {response_json['access_token']}" + ) + organisations_response = machine_client.get("/api/v1/organisations/") + assert organisations_response.status_code == status.HTTP_200_OK + assert organisations_response.json()["results"][0]["id"] == organisation + + +def test_token_exchange__deleted_trust_relationship__access_token_rejected( + machine_client: APIClient, + admin_client: APIClient, + organisation: int, + trust_relationship: int, + oidc_issuer: OIDCIssuerStub, +) -> None: + # Given + token = oidc_issuer.sign_token( + aud="https://github.com/Flagsmith", + sub="repo:Flagsmith/flagsmith:ref:refs/heads/main", + repository="Flagsmith/flagsmith", + ) + access_token = machine_client.post( + "/api/v1/auth/oidc/token/", data={"token": token}, format="json" + ).json()["access_token"] + admin_client.delete( + f"/api/v1/organisations/{organisation}" + f"/trust-relationships/{trust_relationship}/" + ) + + # When + machine_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + response = machine_client.get("/api/v1/organisations/") + + # Then + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_token_exchange__minted_token__cannot_manage_machine_credentials( + machine_client: APIClient, + organisation: int, + trust_relationship: int, + oidc_issuer: OIDCIssuerStub, +) -> None: + # Given + token = oidc_issuer.sign_token( + aud="https://github.com/Flagsmith", + sub="repo:Flagsmith/flagsmith:ref:refs/heads/main", + repository="Flagsmith/flagsmith", + ) + access_token = machine_client.post( + "/api/v1/auth/oidc/token/", data={"token": token}, format="json" + ).json()["access_token"] + machine_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + + # When + master_api_keys_response = machine_client.post( + f"/api/v1/organisations/{organisation}/master-api-keys/", + data={"name": "sneaky", "organisation": organisation}, + ) + trust_relationships_response = machine_client.get( + f"/api/v1/organisations/{organisation}/trust-relationships/" + ) + + # Then + assert master_api_keys_response.status_code == status.HTTP_401_UNAUTHORIZED + assert trust_relationships_response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_token_exchange__no_matching_trust_relationship__returns_401( + machine_client: APIClient, + trust_relationship: int, + oidc_issuer: OIDCIssuerStub, +) -> None: + # Given + token = oidc_issuer.sign_token( + aud="https://github.com/Flagsmith", + sub="repo:SomeoneElse/repo:ref:refs/heads/main", + repository="SomeoneElse/repo", + ) + + # When + response = machine_client.post( + "/api/v1/auth/oidc/token/", data={"token": token}, format="json" + ) + + # Then + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_token_exchange__multi_audience_token_matching_multiple__returns_401( + machine_client: APIClient, + admin_client: APIClient, + organisation: int, + trust_relationship: int, + oidc_issuer: OIDCIssuerStub, +) -> None: + # Given: a second trust relationship under a different audience, and a + # token listing both audiences + admin_client.post( + f"/api/v1/organisations/{organisation}/trust-relationships/", + data={ + "name": "GitHub Actions (CI)", + "issuer": "https://token.actions.githubusercontent.com", + "audience": "flagsmith-ci", + }, + format="json", + ) + token = oidc_issuer.sign_token( + aud=["https://github.com/Flagsmith", "flagsmith-ci"], + sub="repo:Flagsmith/flagsmith:ref:refs/heads/main", + repository="Flagsmith/flagsmith", + ) + + # When + response = machine_client.post( + "/api/v1/auth/oidc/token/", data={"token": token}, format="json" + ) + + # Then + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json()["detail"] == ( + "Token audience matches multiple trust relationships." + ) + + +def test_token_exchange__missing_token__returns_400( + machine_client: APIClient, +) -> None: + # Given / When + response = machine_client.post("/api/v1/auth/oidc/token/", data={}, format="json") + + # Then + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/api/tests/test_helpers.py b/api/tests/test_helpers.py index 7d9b213faa54..2d38010d23e9 100644 --- a/api/tests/test_helpers.py +++ b/api/tests/test_helpers.py @@ -1,5 +1,9 @@ +import json +from datetime import datetime, timedelta, timezone from typing import Any +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa from flag_engine.segments.types import ConditionOperator @@ -32,3 +36,40 @@ def generate_segment_data( } ], } + + +class OIDCIssuerStub: + """An RSA keypair posing as an OIDC issuer: signs tokens and serves the + matching JWKS in tests. + """ + + KEY_ID = "test-key" + + def __init__(self, issuer: str) -> None: + self.issuer = issuer + self._private_key = rsa.generate_private_key( + public_exponent=65537, key_size=2048 + ) + + @property + def jwks(self) -> dict[str, Any]: + jwk = json.loads( + jwt.algorithms.RSAAlgorithm.to_jwk(self._private_key.public_key()) + ) + jwk["kid"] = self.KEY_ID + return {"keys": [jwk]} + + def sign_token(self, expires_in_seconds: int = 300, **claims: Any) -> str: + now = datetime.now(tz=timezone.utc) + payload: dict[str, Any] = { + "iss": self.issuer, + "iat": now, + "exp": now + timedelta(seconds=expires_in_seconds), + **claims, + } + return jwt.encode( + payload, + self._private_key, + algorithm="RS256", + headers={"kid": self.KEY_ID}, + ) diff --git a/api/tests/unit/trust_relationships/conftest.py b/api/tests/unit/trust_relationships/conftest.py new file mode 100644 index 000000000000..5ca7e007708d --- /dev/null +++ b/api/tests/unit/trust_relationships/conftest.py @@ -0,0 +1,50 @@ +from typing import Generator + +import jwt +import pytest +import responses as responses_lib +from pytest_mock import MockerFixture + +from organisations.models import Organisation +from tests.test_helpers import OIDCIssuerStub +from trust_relationships.models import TrustRelationship +from trust_relationships.oidc import get_jwks_client +from trust_relationships.services import create_trust_relationship + + +@pytest.fixture(autouse=True) +def clear_jwks_client_cache() -> Generator[None, None, None]: + get_jwks_client.cache_clear() + yield + get_jwks_client.cache_clear() + + +@pytest.fixture() +def oidc_issuer( + responses: responses_lib.RequestsMock, + mocker: MockerFixture, +) -> OIDCIssuerStub: + issuer = OIDCIssuerStub("https://token.actions.githubusercontent.com") + # Rejections short-circuiting before discovery are expected. + responses.assert_all_requests_are_fired = False + responses.add( + responses_lib.GET, + "https://token.actions.githubusercontent.com/.well-known/openid-configuration", + json={ + "jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks" + }, + ) + mocker.patch.object(jwt.PyJWKClient, "fetch_data", return_value=issuer.jwks) + return issuer + + +@pytest.fixture() +def github_trust_relationship(organisation: Organisation) -> TrustRelationship: + return create_trust_relationship( + organisation_id=organisation.id, + name="GitHub Actions", + issuer="https://token.actions.githubusercontent.com", + audience="https://github.com/Flagsmith", + is_admin=True, + claim_rules=[{"claim": "repository", "values": ["Flagsmith/*"]}], + ) diff --git a/api/tests/unit/trust_relationships/test_authentication.py b/api/tests/unit/trust_relationships/test_authentication.py new file mode 100644 index 000000000000..38f24119ed99 --- /dev/null +++ b/api/tests/unit/trust_relationships/test_authentication.py @@ -0,0 +1,56 @@ +import pytest +from rest_framework import exceptions +from rest_framework.request import Request +from rest_framework.test import APIRequestFactory + +from trust_relationships.authentication import ( + TrustRelationshipTokenAuthentication, +) +from trust_relationships.models import TrustRelationship +from trust_relationships.services import mint_access_token + + +def test_authenticate__no_bearer_header__returns_none() -> None: + # Given + request = Request( + APIRequestFactory().get("/", HTTP_AUTHORIZATION="Api-Key some-key") + ) + + # When + result = TrustRelationshipTokenAuthentication().authenticate(request) + + # Then + assert result is None + + +def test_authenticate__foreign_bearer_token__returns_none() -> None: + # Given + request = Request( + APIRequestFactory().get("/", HTTP_AUTHORIZATION="Bearer not-one-of-ours") + ) + + # When + result = TrustRelationshipTokenAuthentication().authenticate(request) + + # Then + assert result is None + + +def test_authenticate__revoked_backing_key__raises_authentication_failed( + github_trust_relationship: TrustRelationship, +) -> None: + # Given + result = mint_access_token( + github_trust_relationship, + sub="repo:Flagsmith/flagsmith:ref:refs/heads/main", + ) + backing_key = github_trust_relationship.master_api_key + backing_key.revoked = True + backing_key.save() + request = Request( + APIRequestFactory().get("/", HTTP_AUTHORIZATION=f"Bearer {result.access_token}") + ) + + # When / Then + with pytest.raises(exceptions.AuthenticationFailed): + TrustRelationshipTokenAuthentication().authenticate(request) diff --git a/api/tests/unit/trust_relationships/test_oidc.py b/api/tests/unit/trust_relationships/test_oidc.py new file mode 100644 index 000000000000..672479671ad8 --- /dev/null +++ b/api/tests/unit/trust_relationships/test_oidc.py @@ -0,0 +1,124 @@ +from typing import Any + +import pytest +import responses as responses_lib + +from trust_relationships.exceptions import InvalidTokenError +from trust_relationships.oidc import get_jwks_client, match_claim_rules + + +@pytest.mark.parametrize( + "claims, claim_rules, expected", + [ + pytest.param({}, [], True, id="no-rules-always-match"), + pytest.param( + {"repository": "Flagsmith/flagsmith"}, + [{"claim": "repository", "values": ["Flagsmith/flagsmith"]}], + True, + id="exact-match", + ), + pytest.param( + {"repository": "Flagsmith/flagsmith"}, + [{"claim": "repository", "values": ["Flagsmith/*"]}], + True, + id="glob-match", + ), + pytest.param( + {"repository": "Flagsmith/flagsmith"}, + [{"claim": "repository", "values": ["SomeoneElse/*", "Flagsmith/*"]}], + True, + id="any-value-matches", + ), + pytest.param( + {"repository": "SomeoneElse/repo"}, + [{"claim": "repository", "values": ["Flagsmith/*"]}], + False, + id="value-mismatch", + ), + pytest.param( + {}, + [{"claim": "repository", "values": ["Flagsmith/*"]}], + False, + id="missing-claim", + ), + pytest.param( + {"repository": "Flagsmith/flagsmith", "environment": "staging"}, + [ + {"claim": "repository", "values": ["Flagsmith/*"]}, + {"claim": "environment", "values": ["production"]}, + ], + False, + id="all-rules-must-match", + ), + pytest.param( + {"groups": ["deployers", "admins"]}, + [{"claim": "groups", "values": ["deployers"]}], + True, + id="list-claim-any-element", + ), + pytest.param( + {"run_attempt": 1}, + [{"claim": "run_attempt", "values": ["1"]}], + True, + id="non-string-claim-stringified", + ), + ], +) +def test_match_claim_rules__various_rules__returns_expected( + claims: dict[str, Any], + claim_rules: list[dict[str, Any]], + expected: bool, +) -> None: + # Given / When + result = match_claim_rules(claims, claim_rules) + + # Then + assert result is expected + + +def test_get_jwks_client__discovery_error__raises_invalid( + responses: responses_lib.RequestsMock, +) -> None: + # Given + issuer = "https://broken.example.com" + responses.add( + responses_lib.GET, + f"{issuer}/.well-known/openid-configuration", + status=500, + ) + + # When / Then + with pytest.raises(InvalidTokenError): + get_jwks_client(issuer) + + +def test_get_jwks_client__missing_jwks_uri__raises_invalid( + responses: responses_lib.RequestsMock, +) -> None: + # Given + issuer = "https://incomplete.example.com" + responses.add( + responses_lib.GET, + f"{issuer}/.well-known/openid-configuration", + json={"issuer": issuer}, + ) + + # When / Then + with pytest.raises(InvalidTokenError): + get_jwks_client(issuer) + + +def test_get_jwks_client__non_https_jwks_uri__raises_invalid( + responses: responses_lib.RequestsMock, +) -> None: + # Given + issuer = "https://sneaky.example.com" + responses.add( + responses_lib.GET, + f"{issuer}/.well-known/openid-configuration", + json={"jwks_uri": "http://sneaky.example.com/jwks"}, + ) + + # When / Then + with pytest.raises(InvalidTokenError): + get_jwks_client(issuer) diff --git a/api/tests/unit/trust_relationships/test_services.py b/api/tests/unit/trust_relationships/test_services.py index 9f957c4660d3..3de365ad1246 100644 --- a/api/tests/unit/trust_relationships/test_services.py +++ b/api/tests/unit/trust_relationships/test_services.py @@ -1,3 +1,9 @@ +import datetime + +import jwt +import pytest +from django.conf import settings +from freezegun import freeze_time from pytest_structlog import StructuredLogCapture from api_keys.models import MasterAPIKey @@ -5,7 +11,9 @@ from trust_relationships.models import TrustRelationship from trust_relationships.services import ( create_trust_relationship, + decode_access_token, delete_trust_relationship, + mint_access_token, update_trust_relationship, ) from users.models import FFAdminUser @@ -116,3 +124,72 @@ def test_delete_trust_relationship__existing__revokes_backing_key( organisation__id=organisation.id, trust_relationship__id=trust_relationship_id, ) + + +def test_mint_access_token__valid_input__round_trips( + github_trust_relationship: TrustRelationship, +) -> None: + # Given + sub = "repo:Flagsmith/flagsmith:ref:refs/heads/main" + + # When + result = mint_access_token(github_trust_relationship, sub=sub) + + # Then + assert result.expires_in == 3600 + claims = decode_access_token(result.access_token) + assert claims["trust_relationship_id"] == github_trust_relationship.id + assert claims["sub"] == "repo:Flagsmith/flagsmith:ref:refs/heads/main" + assert claims["jti"] + + +def test_decode_access_token__foreign_hs256_token__raises_invalid() -> None: + # Given: an HS256 token signed with SECRET_KEY but minted elsewhere + # (e.g. a simplejwt sliding cookie token) + now = datetime.datetime.now(tz=datetime.timezone.utc) + token = jwt.encode( + { + "jti": "abc", + "iat": now, + "exp": now + datetime.timedelta(hours=1), + }, + settings.SECRET_KEY, + algorithm="HS256", + ) + + # When / Then + with pytest.raises(jwt.InvalidTokenError): + decode_access_token(token) + + +def test_decode_access_token__expired_token__raises_invalid( + github_trust_relationship: TrustRelationship, +) -> None: + # Given + with freeze_time("2026-07-18T10:00:00Z"): + result = mint_access_token(github_trust_relationship, sub="test") + + # When / Then + with freeze_time("2026-07-18T12:00:00Z"): + with pytest.raises(jwt.ExpiredSignatureError): + decode_access_token(result.access_token) + + +def test_decode_access_token__missing_trust_relationship_id__raises_invalid() -> None: + # Given + # a token with the right type but no trust_relationship_id + now = datetime.datetime.now(tz=datetime.timezone.utc) + token = jwt.encode( + { + "token_type": "trust_relationship", + "jti": "abc", + "iat": now, + "exp": now + datetime.timedelta(hours=1), + }, + settings.SECRET_KEY, + algorithm="HS256", + ) + + # When / Then + with pytest.raises(jwt.InvalidTokenError): + decode_access_token(token) diff --git a/api/tests/unit/trust_relationships/test_token_exchange.py b/api/tests/unit/trust_relationships/test_token_exchange.py new file mode 100644 index 000000000000..70065c07159e --- /dev/null +++ b/api/tests/unit/trust_relationships/test_token_exchange.py @@ -0,0 +1,211 @@ +import jwt +import pytest +from pytest_structlog import StructuredLogCapture + +from organisations.models import Organisation +from tests.test_helpers import OIDCIssuerStub +from trust_relationships.exceptions import ( + InvalidTokenError, + NoMatchingTrustRelationshipError, +) +from trust_relationships.models import TrustRelationship +from trust_relationships.services import ( + create_trust_relationship, + decode_access_token, + exchange_oidc_token, +) + + +def test_exchange_oidc_token__matching_token__returns_access_token( + oidc_issuer: OIDCIssuerStub, + github_trust_relationship: TrustRelationship, + log: StructuredLogCapture, +) -> None: + # Given + token = oidc_issuer.sign_token( + aud="https://github.com/Flagsmith", + sub="repo:Flagsmith/flagsmith:ref:refs/heads/main", + repository="Flagsmith/flagsmith", + ) + + # When + result = exchange_oidc_token(token) + + # Then + assert result.expires_in == 3600 + access_token_claims = decode_access_token(result.access_token) + assert access_token_claims["trust_relationship_id"] == github_trust_relationship.id + assert access_token_claims["sub"] == "repo:Flagsmith/flagsmith:ref:refs/heads/main" + assert log.has( + "token.exchanged", + organisation__id=github_trust_relationship.organisation_id, + trust_relationship__id=github_trust_relationship.id, + token__sub="repo:Flagsmith/flagsmith:ref:refs/heads/main", + ) + + +def test_exchange_oidc_token__unknown_issuer__raises_no_match( + oidc_issuer: OIDCIssuerStub, + github_trust_relationship: TrustRelationship, + log: StructuredLogCapture, +) -> None: + # Given + token = oidc_issuer.sign_token( + iss="https://other.example.com", + aud="https://github.com/Flagsmith", + repository="Flagsmith/flagsmith", + ) + + # When / Then + with pytest.raises(NoMatchingTrustRelationshipError): + exchange_oidc_token(token) + assert log.has("token.rejected", reason="unknown_issuer") + + +def test_exchange_oidc_token__audience_mismatch__raises_no_match( + oidc_issuer: OIDCIssuerStub, + github_trust_relationship: TrustRelationship, +) -> None: + # Given + token = oidc_issuer.sign_token( + aud="https://github.com/SomeoneElse", + repository="Flagsmith/flagsmith", + ) + + # When / Then + with pytest.raises(NoMatchingTrustRelationshipError): + exchange_oidc_token(token) + + +def test_exchange_oidc_token__claim_rules_mismatch__raises_no_match( + oidc_issuer: OIDCIssuerStub, + github_trust_relationship: TrustRelationship, + log: StructuredLogCapture, +) -> None: + # Given + token = oidc_issuer.sign_token( + aud="https://github.com/Flagsmith", + repository="SomeoneElse/repo", + ) + + # When / Then + with pytest.raises(NoMatchingTrustRelationshipError): + exchange_oidc_token(token) + assert log.has("token.rejected", reason="no_match") + + +def test_exchange_oidc_token__multi_audience_token_matching_multiple__raises_invalid( + oidc_issuer: OIDCIssuerStub, + github_trust_relationship: TrustRelationship, + organisation: Organisation, + log: StructuredLogCapture, +) -> None: + # Given: a second trust relationship under a different audience, and a + # token listing both audiences + create_trust_relationship( + organisation_id=organisation.id, + name="GitHub Actions (CI)", + issuer="https://token.actions.githubusercontent.com", + audience="flagsmith-ci", + is_admin=True, + claim_rules=[], + ) + token = oidc_issuer.sign_token( + aud=["https://github.com/Flagsmith", "flagsmith-ci"], + repository="Flagsmith/flagsmith", + ) + + # When / Then + with pytest.raises(InvalidTokenError): + exchange_oidc_token(token) + assert log.has("token.rejected", reason="ambiguous") + + +def test_exchange_oidc_token__expired_token__raises_invalid( + oidc_issuer: OIDCIssuerStub, + github_trust_relationship: TrustRelationship, + log: StructuredLogCapture, +) -> None: + # Given + token = oidc_issuer.sign_token( + expires_in_seconds=-60, + aud="https://github.com/Flagsmith", + repository="Flagsmith/flagsmith", + ) + + # When / Then + with pytest.raises(InvalidTokenError): + exchange_oidc_token(token) + assert log.has("token.rejected", reason="verification_failed") + + +def test_exchange_oidc_token__wrong_signing_key__raises_invalid( + oidc_issuer: OIDCIssuerStub, + github_trust_relationship: TrustRelationship, + log: StructuredLogCapture, +) -> None: + # Given: a token signed by an impostor with the same issuer and key id + impostor = OIDCIssuerStub("https://token.actions.githubusercontent.com") + token = impostor.sign_token( + aud="https://github.com/Flagsmith", + repository="Flagsmith/flagsmith", + ) + + # When / Then + with pytest.raises(InvalidTokenError): + exchange_oidc_token(token) + assert log.has("token.rejected", reason="verification_failed") + + +def test_exchange_oidc_token__malformed_token__raises_invalid( + github_trust_relationship: TrustRelationship, + log: StructuredLogCapture, +) -> None: + # Given + token = "not-a-jwt" + + # When / Then + with pytest.raises(InvalidTokenError): + exchange_oidc_token(token) + assert log.has("token.rejected", reason="malformed") + + +def test_exchange_oidc_token__symmetric_algorithm__raises_invalid( + github_trust_relationship: TrustRelationship, + log: StructuredLogCapture, +) -> None: + # Given + token = jwt.encode( + { + "iss": "https://token.actions.githubusercontent.com", + "aud": "https://github.com/Flagsmith", + "repository": "Flagsmith/flagsmith", + }, + "some-secret", + algorithm="HS256", + ) + + # When / Then + with pytest.raises(InvalidTokenError): + exchange_oidc_token(token) + assert log.has("token.rejected", reason="disallowed_algorithm") + + +def test_exchange_oidc_token__unknown_signing_key_id__raises_invalid( + oidc_issuer: OIDCIssuerStub, + github_trust_relationship: TrustRelationship, + log: StructuredLogCapture, +) -> None: + # Given + # a token whose key id is absent from the issuer's JWKS + impostor = OIDCIssuerStub("https://token.actions.githubusercontent.com") + impostor.KEY_ID = "unknown-key" + token = impostor.sign_token( + aud="https://github.com/Flagsmith", + repository="Flagsmith/flagsmith", + ) + + # When / Then + with pytest.raises(InvalidTokenError): + exchange_oidc_token(token) + assert log.has("token.rejected", reason="signing_key_not_found") diff --git a/api/trust_relationships/authentication.py b/api/trust_relationships/authentication.py new file mode 100644 index 000000000000..b96aab48f506 --- /dev/null +++ b/api/trust_relationships/authentication.py @@ -0,0 +1,46 @@ +from typing import Optional + +import jwt +from rest_framework import exceptions +from rest_framework.authentication import BaseAuthentication +from rest_framework.request import Request + +from api_keys.user import APIKeyUser +from trust_relationships.models import TrustRelationship +from trust_relationships.services import decode_access_token + + +class TrustRelationshipTokenAuthentication(BaseAuthentication): + """Authenticate short-lived access tokens minted by the OIDC exchange. + + Resolves the token to the trust relationship's backing master API key, + so the request authenticates as an `APIKeyUser` and revocation or role + changes on the trust relationship apply to outstanding tokens. + """ + + def authenticate(self, request: Request) -> Optional[tuple[APIKeyUser, None]]: + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + if not auth_header.startswith("Bearer "): + return None + + token = auth_header.removeprefix("Bearer ").strip() + try: + claims = decode_access_token(token) + except jwt.InvalidTokenError: + # Not one of ours; let other authenticators have a go. + return None + + try: + trust_relationship = TrustRelationship.objects.select_related( + "master_api_key" + ).get(id=claims["trust_relationship_id"]) + except TrustRelationship.DoesNotExist: + raise exceptions.AuthenticationFailed( + "Trust relationship no longer exists." + ) + + master_api_key = trust_relationship.master_api_key + if master_api_key.revoked or master_api_key.has_expired: + raise exceptions.AuthenticationFailed("Trust relationship is revoked.") + + return APIKeyUser(master_api_key), None diff --git a/api/trust_relationships/constants.py b/api/trust_relationships/constants.py new file mode 100644 index 000000000000..a24592b00f63 --- /dev/null +++ b/api/trust_relationships/constants.py @@ -0,0 +1,8 @@ +# Asymmetric algorithms accepted on inbound external OIDC tokens. +ALLOWED_SIGNING_ALGORITHMS = ["RS256", "ES256"] + +# `token_type` claim value distinguishing minted access tokens from any other +# HS256 JWT signed with SECRET_KEY (e.g. simplejwt sliding cookie tokens). +ACCESS_TOKEN_TYPE = "trust_relationship" + +DISCOVERY_TIMEOUT_SECONDS = 5 diff --git a/api/trust_relationships/dataclasses.py b/api/trust_relationships/dataclasses.py new file mode 100644 index 000000000000..9325e7bcf51d --- /dev/null +++ b/api/trust_relationships/dataclasses.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class TokenExchangeResult: + access_token: str + expires_in: int diff --git a/api/trust_relationships/exceptions.py b/api/trust_relationships/exceptions.py new file mode 100644 index 000000000000..1fac25ba5d21 --- /dev/null +++ b/api/trust_relationships/exceptions.py @@ -0,0 +1,21 @@ +from rest_framework import status +from rest_framework.exceptions import APIException + + +class TokenExchangeError(APIException): + """Base error for OIDC token exchange rejections, rendered by DRF's + exception handler. + """ + + status_code: int = status.HTTP_401_UNAUTHORIZED + default_detail = "Token validation failed." + default_code = "token_exchange_failed" + + +class InvalidTokenError(TokenExchangeError): + default_code = "invalid_token" + + +class NoMatchingTrustRelationshipError(TokenExchangeError): + default_detail = "No trust relationship matches this token." + default_code = "no_matching_trust_relationship" diff --git a/api/trust_relationships/oidc.py b/api/trust_relationships/oidc.py new file mode 100644 index 000000000000..84883f449e19 --- /dev/null +++ b/api/trust_relationships/oidc.py @@ -0,0 +1,55 @@ +from fnmatch import fnmatchcase +from functools import lru_cache +from typing import Any +from urllib.parse import urlparse + +import jwt +import requests + +from trust_relationships.constants import DISCOVERY_TIMEOUT_SECONDS +from trust_relationships.exceptions import InvalidTokenError + + +@lru_cache(maxsize=128) +def get_jwks_client(issuer: str) -> jwt.PyJWKClient: + """Resolve an issuer's JWKS endpoint via OIDC discovery. + + Only successful lookups are cached; the returned client caches fetched + keys internally. + """ + try: + response = requests.get( + f"{issuer}/.well-known/openid-configuration", + timeout=DISCOVERY_TIMEOUT_SECONDS, + ) + response.raise_for_status() + jwks_uri = response.json()["jwks_uri"] + except (requests.RequestException, ValueError, KeyError) as exc: + raise InvalidTokenError("Unable to resolve issuer signing keys.") from exc + if urlparse(jwks_uri).scheme != "https": + raise InvalidTokenError("Issuer JWKS endpoint must be served over https.") + return jwt.PyJWKClient(jwks_uri, cache_keys=True) + + +def match_claim_rules( + claims: dict[str, Any], + claim_rules: list[dict[str, Any]], +) -> bool: + """Check token claims against a trust relationship's claim rules. + + Every rule must match. A rule matches when the claim is present and any + of the rule's values matches it; values support `*` glob wildcards. + List-valued claims match if any element matches. + """ + for rule in claim_rules: + claim_value = claims.get(rule["claim"]) + if claim_value is None: + return False + candidates = claim_value if isinstance(claim_value, list) else [claim_value] + if not any( + fnmatchcase(str(candidate), pattern) + for candidate in candidates + for pattern in rule["values"] + ): + return False + return True diff --git a/api/trust_relationships/serializers.py b/api/trust_relationships/serializers.py index 8303b1013759..356e8dc5529c 100644 --- a/api/trust_relationships/serializers.py +++ b/api/trust_relationships/serializers.py @@ -11,6 +11,23 @@ ) +class TokenExchangeRequestSerializer(serializers.Serializer[None]): + token = serializers.CharField( + help_text="An OIDC token issued by a trusted identity provider, " + "e.g. a GitHub Actions job token.", + ) + + +class TokenExchangeResponseSerializer(serializers.Serializer[dict[str, Any]]): + access_token = serializers.CharField( + help_text="Short-lived access token for the Admin API.", + ) + token_type = serializers.CharField(help_text='Always "Bearer".') + expires_in = serializers.IntegerField( + help_text="Access token lifetime in seconds.", + ) + + class ClaimRuleSerializer(serializers.Serializer[None]): claim = serializers.CharField(max_length=255) values = serializers.ListField( diff --git a/api/trust_relationships/services.py b/api/trust_relationships/services.py index a09ccc4035f0..512bb1b3a176 100644 --- a/api/trust_relationships/services.py +++ b/api/trust_relationships/services.py @@ -1,10 +1,25 @@ -from typing import Any +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, cast +import jwt import structlog +from django.conf import settings from django.db import transaction from api_keys.models import MasterAPIKey +from trust_relationships.constants import ( + ACCESS_TOKEN_TYPE, + ALLOWED_SIGNING_ALGORITHMS, +) +from trust_relationships.dataclasses import TokenExchangeResult +from trust_relationships.exceptions import ( + InvalidTokenError, + NoMatchingTrustRelationshipError, +) from trust_relationships.models import TrustRelationship +from trust_relationships.oidc import get_jwks_client, match_claim_rules +from trust_relationships.types import AccessTokenClaims from users.models import FFAdminUser logger = structlog.get_logger("trust_relationships") @@ -96,3 +111,121 @@ def delete_trust_relationship(*, trust_relationship: TrustRelationship) -> None: organisation__id=trust_relationship.organisation_id, trust_relationship__id=trust_relationship.id, ) + + +def mint_access_token( + trust_relationship: TrustRelationship, + *, + sub: str, +) -> TokenExchangeResult: + """Mint a short-lived HS256 access token bound to a trust relationship.""" + now = datetime.now(tz=timezone.utc) + expires_in: int = settings.TRUST_RELATIONSHIP_ACCESS_TOKEN_LIFETIME_SECONDS + access_token = jwt.encode( + { + "token_type": ACCESS_TOKEN_TYPE, + "jti": uuid.uuid4().hex, + "sub": sub, + "trust_relationship_id": trust_relationship.id, + "iat": now, + "exp": now + timedelta(seconds=expires_in), + }, + settings.SECRET_KEY, + algorithm="HS256", + ) + return TokenExchangeResult(access_token=access_token, expires_in=expires_in) + + +def decode_access_token(token: str) -> AccessTokenClaims: + """Decode and verify a minted access token. + + Raises `jwt.InvalidTokenError` for any token not minted by + `mint_access_token`, including other HS256 JWTs signed with SECRET_KEY + (identified via the `token_type` claim). + """ + claims: dict[str, Any] = jwt.decode( + token, + settings.SECRET_KEY, + algorithms=["HS256"], + options={"require": ["exp", "iat", "jti"]}, + ) + if claims.get("token_type") != ACCESS_TOKEN_TYPE: + raise jwt.InvalidTokenError("Not a trust relationship access token.") + if "trust_relationship_id" not in claims: + raise jwt.InvalidTokenError("Missing trust_relationship_id claim.") + return cast(AccessTokenClaims, claims) + + +def exchange_oidc_token(token: str) -> TokenExchangeResult: + """Exchange an external OIDC token for a short-lived access token. + + The external token's issuer, audience and claims are checked against the + configured trust relationships; exactly one must match. + """ + try: + header = jwt.get_unverified_header(token) + unverified_claims = jwt.decode(token, options={"verify_signature": False}) + except jwt.InvalidTokenError as exc: + logger.info("token.rejected", reason="malformed", token__issuer=None) + raise InvalidTokenError("Token is not a valid JWT.") from exc + + if header.get("alg") not in ALLOWED_SIGNING_ALGORITHMS: + logger.info("token.rejected", reason="disallowed_algorithm", token__issuer=None) + raise InvalidTokenError("Token signing algorithm is not allowed.") + + issuer = str(unverified_claims.get("iss", "")).rstrip("/") + candidates = list( + TrustRelationship.objects.filter(issuer=issuer).select_related("master_api_key") + ) + if not candidates: + logger.info("token.rejected", reason="unknown_issuer", token__issuer=issuer) + raise NoMatchingTrustRelationshipError() + + jwks_client = get_jwks_client(issuer) + try: + signing_key = jwks_client.get_signing_key_from_jwt(token) + claims = jwt.decode( + token, + signing_key.key, + algorithms=ALLOWED_SIGNING_ALGORITHMS, + options={"verify_aud": False, "require": ["exp", "iat"]}, + ) + except jwt.PyJWKClientError as exc: + logger.info( + "token.rejected", reason="signing_key_not_found", token__issuer=issuer + ) + raise InvalidTokenError("Unable to resolve issuer signing keys.") from exc + except jwt.InvalidTokenError as exc: + logger.info( + "token.rejected", reason="verification_failed", token__issuer=issuer + ) + raise InvalidTokenError("Token validation failed.") from exc + + audiences = claims.get("aud") or [] + if not isinstance(audiences, list): + audiences = [audiences] + matched = [ + trust_relationship + for trust_relationship in candidates + if trust_relationship.audience in audiences + and match_claim_rules(claims, trust_relationship.claim_rules) + ] + + if not matched: + logger.info("token.rejected", reason="no_match", token__issuer=issuer) + raise NoMatchingTrustRelationshipError() + if len(matched) > 1: + # Only reachable with a multi-audience token (RFC 7519 allows `aud` + # to be an array) + logger.info("token.rejected", reason="ambiguous", token__issuer=issuer) + raise InvalidTokenError("Token audience matches multiple trust relationships.") + + trust_relationship = matched[0] + result = mint_access_token(trust_relationship, sub=str(claims.get("sub", ""))) + logger.info( + "token.exchanged", + organisation__id=trust_relationship.organisation_id, + trust_relationship__id=trust_relationship.id, + token__sub=str(claims.get("sub", "")), + ) + return result diff --git a/api/trust_relationships/types.py b/api/trust_relationships/types.py new file mode 100644 index 000000000000..2e558092b5f6 --- /dev/null +++ b/api/trust_relationships/types.py @@ -0,0 +1,12 @@ +from typing import TypedDict + + +class AccessTokenClaims(TypedDict): + """Claims carried by a minted trust relationship access token.""" + + token_type: str + jti: str + sub: str + trust_relationship_id: int + iat: int + exp: int diff --git a/api/trust_relationships/views.py b/api/trust_relationships/views.py index 3f11999fef54..9a78476b663b 100644 --- a/api/trust_relationships/views.py +++ b/api/trust_relationships/views.py @@ -1,15 +1,28 @@ from django.db.models import QuerySet +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import viewsets -from rest_framework.permissions import IsAuthenticated +from rest_framework.authentication import BaseAuthentication +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response from rest_framework.serializers import BaseSerializer +from rest_framework.throttling import ScopedRateThrottle +from rest_framework.views import APIView from api_keys.views import ExcludeMasterAPIKeyAuthenticationMixin from organisations.permissions.permissions import ( NestedIsOrganisationAdminPermission, ) from trust_relationships.models import TrustRelationship -from trust_relationships.serializers import TrustRelationshipSerializer -from trust_relationships.services import delete_trust_relationship +from trust_relationships.serializers import ( + TokenExchangeRequestSerializer, + TokenExchangeResponseSerializer, + TrustRelationshipSerializer, +) +from trust_relationships.services import ( + delete_trust_relationship, + exchange_oidc_token, +) class TrustRelationshipViewSet( @@ -37,3 +50,45 @@ def perform_create(self, serializer: BaseSerializer[TrustRelationship]) -> None: def perform_destroy(self, instance: TrustRelationship) -> None: delete_trust_relationship(trust_relationship=instance) + + +class OIDCTokenExchangeView(APIView): + """Exchange an external OIDC token for a short-lived access token.""" + + authentication_classes: list[type[BaseAuthentication]] = [] + permission_classes = [AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "oidc_token_exchange" + + @extend_schema( + operation_id="oidc_token_exchange", + description=( + "Exchange an OIDC token issued by a trusted identity provider " + "for a short-lived Flagsmith access token." + ), + request=TokenExchangeRequestSerializer, + responses={ + 200: TokenExchangeResponseSerializer, + 400: OpenApiResponse( + description="Request body is invalid.", + ), + 401: OpenApiResponse( + description=( + "Token validation failed, or no trust relationship " + "matches the token." + ), + ), + }, + ) + def post(self, request: Request) -> Response: + serializer = TokenExchangeRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + result = exchange_oidc_token(serializer.validated_data["token"]) + response_serializer = TokenExchangeResponseSerializer( + { + "access_token": result.access_token, + "token_type": "Bearer", + "expires_in": result.expires_in, + } + ) + return Response(response_serializer.data) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index eaa34053cc59..d7f6eede2b67 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -501,7 +501,7 @@ Attributes: ### `trust_relationships.created` Logged at `info` from: - - `api/trust_relationships/services.py:48` + - `api/trust_relationships/services.py:63` Attributes: - `organisation.id` @@ -511,16 +511,41 @@ Attributes: ### `trust_relationships.deleted` Logged at `info` from: - - `api/trust_relationships/services.py:94` + - `api/trust_relationships/services.py:109` + +Attributes: + - `organisation.id` + - `trust_relationship.id` + +### `trust_relationships.token.exchanged` + +Logged at `info` from: + - `api/trust_relationships/services.py:225` Attributes: - `organisation.id` + - `token.sub` - `trust_relationship.id` +### `trust_relationships.token.rejected` + +Logged at `info` from: + - `api/trust_relationships/services.py:169` + - `api/trust_relationships/services.py:173` + - `api/trust_relationships/services.py:181` + - `api/trust_relationships/services.py:194` + - `api/trust_relationships/services.py:199` + - `api/trust_relationships/services.py:215` + - `api/trust_relationships/services.py:220` + +Attributes: + - `reason` + - `token.issuer` + ### `trust_relationships.updated` Logged at `info` from: - - `api/trust_relationships/services.py:79` + - `api/trust_relationships/services.py:94` Attributes: - `organisation.id` diff --git a/openapi.yaml b/openapi.yaml index fcae85fe12f8..27b93639abd5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -544,6 +544,37 @@ paths: - {} tags: - Authentication + /api/v1/auth/oidc/token/: + post: + operationId: oidc_token_exchange + description: Exchange an OIDC token issued by a trusted identity provider for a short-lived Flagsmith access token. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TokenExchangeRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenExchangeRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenExchangeRequest' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/TokenExchangeResponse' + '400': + description: Request body is invalid. + '401': + description: 'Token validation failed, or no trust relationship matches the token.' + security: + - {} + tags: + - Authentication '/api/v1/auth/saml/{name}/metadata/': get: operationId: api_v1_auth_saml_metadata_retrieve @@ -26941,6 +26972,30 @@ components: type: string email: type: string + TokenExchangeRequest: + type: object + properties: + token: + description: 'An OIDC token issued by a trusted identity provider, e.g. a GitHub Actions job token.' + type: string + required: + - token + TokenExchangeResponse: + type: object + properties: + access_token: + description: Short-lived access token for the Admin API. + type: string + token_type: + description: Always "Bearer". + type: string + expires_in: + description: Access token lifetime in seconds. + type: integer + required: + - access_token + - expires_in + - token_type Trait: type: object properties: From d5b6dabe38f0f53722117134c816a1fefd6a3d66 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 20 Jul 2026 11:34:19 +0100 Subject: [PATCH 2/3] fix(OIDC): Token exchange rejects issuers with a trailing slash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exchanged token's `iss` claim was stripped of a trailing slash before the trust relationship lookup, so an issuer registered with the exact (slashed) value it presents — as Auth0 does — never matched and the exchange failed. Preserve the exact `iss` for the lookup and strip the slash only when composing the OIDC discovery URL. beep boop --- .../test_token_exchange.py | 34 +++++++++++++++++++ api/trust_relationships/oidc.py | 5 ++- api/trust_relationships/services.py | 4 ++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/api/tests/unit/trust_relationships/test_token_exchange.py b/api/tests/unit/trust_relationships/test_token_exchange.py index 70065c07159e..df744f07b1b8 100644 --- a/api/tests/unit/trust_relationships/test_token_exchange.py +++ b/api/tests/unit/trust_relationships/test_token_exchange.py @@ -1,5 +1,7 @@ import jwt import pytest +import responses as responses_lib +from pytest_mock import MockerFixture from pytest_structlog import StructuredLogCapture from organisations.models import Organisation @@ -44,6 +46,38 @@ def test_exchange_oidc_token__matching_token__returns_access_token( ) +def test_exchange_oidc_token__issuer_with_trailing_slash__matches( + organisation: Organisation, + responses: responses_lib.RequestsMock, + mocker: MockerFixture, +) -> None: + # Given: a trust relationship whose issuer ends in a slash, as Auth0's does + issuer = "https://tenant.eu.auth0.com/" + trust_relationship = create_trust_relationship( + organisation_id=organisation.id, + name="Auth0", + issuer=issuer, + audience="https://api.flagsmith.com", + is_admin=True, + claim_rules=[], + ) + oidc_issuer = OIDCIssuerStub(issuer) + responses.add( + responses_lib.GET, + "https://tenant.eu.auth0.com/.well-known/openid-configuration", + json={"jwks_uri": "https://tenant.eu.auth0.com/.well-known/jwks"}, + ) + mocker.patch.object(jwt.PyJWKClient, "fetch_data", return_value=oidc_issuer.jwks) + token = oidc_issuer.sign_token(aud="https://api.flagsmith.com") + + # When + result = exchange_oidc_token(token) + + # Then + access_token_claims = decode_access_token(result.access_token) + assert access_token_claims["trust_relationship_id"] == trust_relationship.id + + def test_exchange_oidc_token__unknown_issuer__raises_no_match( oidc_issuer: OIDCIssuerStub, github_trust_relationship: TrustRelationship, diff --git a/api/trust_relationships/oidc.py b/api/trust_relationships/oidc.py index 84883f449e19..08d0022803e2 100644 --- a/api/trust_relationships/oidc.py +++ b/api/trust_relationships/oidc.py @@ -18,8 +18,11 @@ def get_jwks_client(issuer: str) -> jwt.PyJWKClient: keys internally. """ try: + # Strip any trailing slash here so discovery hits + # `.../.well-known/openid-configuration`, not `...//.well-known/...`. + base_url = issuer.rstrip("/") response = requests.get( - f"{issuer}/.well-known/openid-configuration", + f"{base_url}/.well-known/openid-configuration", timeout=DISCOVERY_TIMEOUT_SECONDS, ) response.raise_for_status() diff --git a/api/trust_relationships/services.py b/api/trust_relationships/services.py index 512bb1b3a176..dbccbbaf11bd 100644 --- a/api/trust_relationships/services.py +++ b/api/trust_relationships/services.py @@ -173,7 +173,9 @@ def exchange_oidc_token(token: str) -> TokenExchangeResult: logger.info("token.rejected", reason="disallowed_algorithm", token__issuer=None) raise InvalidTokenError("Token signing algorithm is not allowed.") - issuer = str(unverified_claims.get("iss", "")).rstrip("/") + # Preserve the exact `iss` for the lookup: OIDC requires exact string + # matching, and issuers such as Auth0 legitimately end in a slash. + issuer = str(unverified_claims.get("iss", "")) candidates = list( TrustRelationship.objects.filter(issuer=issuer).select_related("master_api_key") ) From 4359ae5a5b746b06299c682dcca231bb15975d7b Mon Sep 17 00:00:00 2001 From: "flagsmith-engineering[bot]" Date: Mon, 20 Jul 2026 10:39:57 +0000 Subject: [PATCH 3/3] chore: Update documentation artefacts --- .../observability/_events-catalogue.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md index d7f6eede2b67..1a59ce693bae 100644 --- a/docs/docs/deployment-self-hosting/observability/_events-catalogue.md +++ b/docs/docs/deployment-self-hosting/observability/_events-catalogue.md @@ -520,7 +520,7 @@ Attributes: ### `trust_relationships.token.exchanged` Logged at `info` from: - - `api/trust_relationships/services.py:225` + - `api/trust_relationships/services.py:227` Attributes: - `organisation.id` @@ -532,11 +532,11 @@ Attributes: Logged at `info` from: - `api/trust_relationships/services.py:169` - `api/trust_relationships/services.py:173` - - `api/trust_relationships/services.py:181` - - `api/trust_relationships/services.py:194` - - `api/trust_relationships/services.py:199` - - `api/trust_relationships/services.py:215` - - `api/trust_relationships/services.py:220` + - `api/trust_relationships/services.py:183` + - `api/trust_relationships/services.py:196` + - `api/trust_relationships/services.py:201` + - `api/trust_relationships/services.py:217` + - `api/trust_relationships/services.py:222` Attributes: - `reason`