Skip to content

Commit 14e860c

Browse files
feat(OIDC): Add OIDC token exchange endpoint (#8043)
Co-authored-by: flagsmith-engineering[bot] <flagsmith-engineering[bot]@users.noreply.github.com>
1 parent 8b4d846 commit 14e860c

24 files changed

Lines changed: 1320 additions & 15 deletions

File tree

api/api_keys/views.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
from organisations.permissions.permissions import (
77
NestedIsOrganisationAdminPermission,
88
)
9+
from trust_relationships.authentication import (
10+
TrustRelationshipTokenAuthentication,
11+
)
912

1013
from .authentication import MasterAPIKeyAuthentication
1114
from .models import MasterAPIKey
@@ -18,7 +21,10 @@ def get_authenticators(self) -> list[BaseAuthentication]:
1821
return [
1922
authenticator
2023
for authenticator in super().get_authenticators()
21-
if not isinstance(authenticator, MasterAPIKeyAuthentication)
24+
if not isinstance(
25+
authenticator,
26+
(MasterAPIKeyAuthentication, TrustRelationshipTokenAuthentication),
27+
)
2228
]
2329

2430

api/app/settings/common.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@
343343

344344
LOGIN_THROTTLE_RATE = env("LOGIN_THROTTLE_RATE", "20/min")
345345
DCR_THROTTLE_RATE = env("DCR_THROTTLE_RATE", "500/month")
346+
OIDC_TOKEN_EXCHANGE_THROTTLE_RATE = env("OIDC_TOKEN_EXCHANGE_THROTTLE_RATE", "60/min")
347+
TRUST_RELATIONSHIP_ACCESS_TOKEN_LIFETIME_SECONDS = env.int(
348+
"TRUST_RELATIONSHIP_ACCESS_TOKEN_LIFETIME_SECONDS", default=3600
349+
)
346350
SIGNUP_THROTTLE_RATE = env("SIGNUP_THROTTLE_RATE", "10000/min")
347351
USER_THROTTLE_RATE = env("USER_THROTTLE_RATE", default=None)
348352
MASTER_API_KEY_THROTTLE_RATE = env("MASTER_API_KEY_THROTTLE_RATE", default=None)
@@ -354,6 +358,7 @@
354358
"rest_framework.authentication.TokenAuthentication",
355359
"api_keys.authentication.MasterAPIKeyAuthentication",
356360
"oauth2_metadata.authentication.OAuth2BearerTokenAuthentication",
361+
"trust_relationships.authentication.TrustRelationshipTokenAuthentication",
357362
),
358363
"PAGE_SIZE": 10,
359364
"UNICODE_JSON": False,
@@ -362,6 +367,7 @@
362367
"DEFAULT_THROTTLE_RATES": {
363368
"login": LOGIN_THROTTLE_RATE,
364369
"dcr_register": DCR_THROTTLE_RATE,
370+
"oidc_token_exchange": OIDC_TOKEN_EXCHANGE_THROTTLE_RATE,
365371
"signup": SIGNUP_THROTTLE_RATE,
366372
"master_api_key": MASTER_API_KEY_THROTTLE_RATE,
367373
"mfa_code": "5/min",

api/app/settings/test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
2222
"login": "100/min",
2323
"dcr_register": "100/min",
24+
"oidc_token_exchange": "100/min",
2425
"mfa_code": "5/min",
2526
"invite": "10/min",
2627
"signup": "100/min",

api/custom_auth/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
FFAdminUserViewSet,
99
delete_token,
1010
)
11+
from trust_relationships.views import OIDCTokenExchangeView
1112

1213
app_name = "custom_auth"
1314

@@ -38,4 +39,9 @@
3839
path("", include("djoser.urls")),
3940
path("", include("custom_auth.mfa.trench.urls")), # MFA
4041
path("oauth/", include("custom_auth.oauth.urls")),
42+
path(
43+
"oidc/token/",
44+
OIDCTokenExchangeView.as_view(),
45+
name="oidc-token-exchange",
46+
),
4147
]

api/tests/integration/trust_relationships/conftest.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
from typing import Generator
2+
3+
import jwt
14
import pytest
5+
import responses as responses_lib
6+
from pytest_mock import MockerFixture
27
from rest_framework import status
38
from rest_framework.test import APIClient
49

10+
from tests.test_helpers import OIDCIssuerStub
11+
from trust_relationships.oidc import get_jwks_client
12+
513

614
@pytest.fixture()
715
def trust_relationship(
@@ -21,3 +29,35 @@ def trust_relationship(
2129
)
2230
assert response.status_code == status.HTTP_201_CREATED
2331
return response.json()["id"] # type: ignore[no-any-return]
32+
33+
34+
@pytest.fixture(autouse=True)
35+
def clear_jwks_client_cache() -> Generator[None, None, None]:
36+
get_jwks_client.cache_clear()
37+
yield
38+
get_jwks_client.cache_clear()
39+
40+
41+
@pytest.fixture()
42+
def oidc_issuer(
43+
responses: responses_lib.RequestsMock,
44+
mocker: MockerFixture,
45+
) -> OIDCIssuerStub:
46+
issuer = OIDCIssuerStub("https://token.actions.githubusercontent.com")
47+
# Rejections short-circuiting before discovery are expected.
48+
responses.assert_all_requests_are_fired = False
49+
responses.add(
50+
responses_lib.GET,
51+
"https://token.actions.githubusercontent.com/.well-known/openid-configuration",
52+
json={
53+
"jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks"
54+
},
55+
)
56+
mocker.patch.object(jwt.PyJWKClient, "fetch_data", return_value=issuer.jwks)
57+
return issuer
58+
59+
60+
@pytest.fixture()
61+
def machine_client() -> APIClient:
62+
# A client that is never force-authenticated
63+
return APIClient()
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
from rest_framework import status
2+
from rest_framework.test import APIClient
3+
4+
from tests.test_helpers import OIDCIssuerStub
5+
6+
7+
def test_token_exchange__matching_token__returns_usable_access_token(
8+
machine_client: APIClient,
9+
organisation: int,
10+
trust_relationship: int,
11+
oidc_issuer: OIDCIssuerStub,
12+
) -> None:
13+
# Given
14+
token = oidc_issuer.sign_token(
15+
aud="https://github.com/Flagsmith",
16+
sub="repo:Flagsmith/flagsmith:ref:refs/heads/main",
17+
repository="Flagsmith/flagsmith",
18+
)
19+
20+
# When
21+
response = machine_client.post(
22+
"/api/v1/auth/oidc/token/", data={"token": token}, format="json"
23+
)
24+
25+
# Then
26+
assert response.status_code == status.HTTP_200_OK
27+
response_json = response.json()
28+
assert response_json["token_type"] == "Bearer"
29+
assert response_json["expires_in"] == 3600
30+
31+
# And the minted token authenticates against the admin API
32+
machine_client.credentials(
33+
HTTP_AUTHORIZATION=f"Bearer {response_json['access_token']}"
34+
)
35+
organisations_response = machine_client.get("/api/v1/organisations/")
36+
assert organisations_response.status_code == status.HTTP_200_OK
37+
assert organisations_response.json()["results"][0]["id"] == organisation
38+
39+
40+
def test_token_exchange__deleted_trust_relationship__access_token_rejected(
41+
machine_client: APIClient,
42+
admin_client: APIClient,
43+
organisation: int,
44+
trust_relationship: int,
45+
oidc_issuer: OIDCIssuerStub,
46+
) -> None:
47+
# Given
48+
token = oidc_issuer.sign_token(
49+
aud="https://github.com/Flagsmith",
50+
sub="repo:Flagsmith/flagsmith:ref:refs/heads/main",
51+
repository="Flagsmith/flagsmith",
52+
)
53+
access_token = machine_client.post(
54+
"/api/v1/auth/oidc/token/", data={"token": token}, format="json"
55+
).json()["access_token"]
56+
admin_client.delete(
57+
f"/api/v1/organisations/{organisation}"
58+
f"/trust-relationships/{trust_relationship}/"
59+
)
60+
61+
# When
62+
machine_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
63+
response = machine_client.get("/api/v1/organisations/")
64+
65+
# Then
66+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
67+
68+
69+
def test_token_exchange__minted_token__cannot_manage_machine_credentials(
70+
machine_client: APIClient,
71+
organisation: int,
72+
trust_relationship: int,
73+
oidc_issuer: OIDCIssuerStub,
74+
) -> None:
75+
# Given
76+
token = oidc_issuer.sign_token(
77+
aud="https://github.com/Flagsmith",
78+
sub="repo:Flagsmith/flagsmith:ref:refs/heads/main",
79+
repository="Flagsmith/flagsmith",
80+
)
81+
access_token = machine_client.post(
82+
"/api/v1/auth/oidc/token/", data={"token": token}, format="json"
83+
).json()["access_token"]
84+
machine_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
85+
86+
# When
87+
master_api_keys_response = machine_client.post(
88+
f"/api/v1/organisations/{organisation}/master-api-keys/",
89+
data={"name": "sneaky", "organisation": organisation},
90+
)
91+
trust_relationships_response = machine_client.get(
92+
f"/api/v1/organisations/{organisation}/trust-relationships/"
93+
)
94+
95+
# Then
96+
assert master_api_keys_response.status_code == status.HTTP_401_UNAUTHORIZED
97+
assert trust_relationships_response.status_code == status.HTTP_401_UNAUTHORIZED
98+
99+
100+
def test_token_exchange__no_matching_trust_relationship__returns_401(
101+
machine_client: APIClient,
102+
trust_relationship: int,
103+
oidc_issuer: OIDCIssuerStub,
104+
) -> None:
105+
# Given
106+
token = oidc_issuer.sign_token(
107+
aud="https://github.com/Flagsmith",
108+
sub="repo:SomeoneElse/repo:ref:refs/heads/main",
109+
repository="SomeoneElse/repo",
110+
)
111+
112+
# When
113+
response = machine_client.post(
114+
"/api/v1/auth/oidc/token/", data={"token": token}, format="json"
115+
)
116+
117+
# Then
118+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
119+
120+
121+
def test_token_exchange__multi_audience_token_matching_multiple__returns_401(
122+
machine_client: APIClient,
123+
admin_client: APIClient,
124+
organisation: int,
125+
trust_relationship: int,
126+
oidc_issuer: OIDCIssuerStub,
127+
) -> None:
128+
# Given: a second trust relationship under a different audience, and a
129+
# token listing both audiences
130+
create_response = admin_client.post(
131+
f"/api/v1/organisations/{organisation}/trust-relationships/",
132+
data={
133+
"name": "GitHub Actions (CI)",
134+
"issuer": "https://token.actions.githubusercontent.com",
135+
"audience": "flagsmith-ci",
136+
"is_admin": True,
137+
},
138+
format="json",
139+
)
140+
assert create_response.status_code == status.HTTP_201_CREATED
141+
token = oidc_issuer.sign_token(
142+
aud=["https://github.com/Flagsmith", "flagsmith-ci"],
143+
sub="repo:Flagsmith/flagsmith:ref:refs/heads/main",
144+
repository="Flagsmith/flagsmith",
145+
)
146+
147+
# When
148+
response = machine_client.post(
149+
"/api/v1/auth/oidc/token/", data={"token": token}, format="json"
150+
)
151+
152+
# Then
153+
assert response.status_code == status.HTTP_401_UNAUTHORIZED
154+
assert response.json()["detail"] == (
155+
"Token audience matches multiple trust relationships."
156+
)
157+
158+
159+
def test_token_exchange__missing_token__returns_400(
160+
machine_client: APIClient,
161+
) -> None:
162+
# Given / When
163+
response = machine_client.post("/api/v1/auth/oidc/token/", data={}, format="json")
164+
165+
# Then
166+
assert response.status_code == status.HTTP_400_BAD_REQUEST

api/tests/integration/trust_relationships/test_viewset.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,17 @@ def test_create_trust_relationship__valid_data__returns_backing_key_details(
3333
]
3434

3535

36-
def test_create_trust_relationship__trailing_slash_issuer__returns_normalised_issuer(
36+
def test_create_trust_relationship__trailing_slash_issuer__returns_issuer_verbatim(
3737
admin_client: APIClient,
3838
organisation: int,
3939
) -> None:
40-
# Given
40+
# Given: an issuer that ends in a slash, as Auth0's does. `iss` is matched
41+
# by exact string at exchange time, so the slash must survive the round trip.
4142
url = f"/api/v1/organisations/{organisation}/trust-relationships/"
4243
data = {
43-
"name": "GitHub Actions",
44-
"issuer": "https://token.actions.githubusercontent.com/",
45-
"audience": "https://github.com/Flagsmith",
44+
"name": "Auth0",
45+
"issuer": "https://tenant.eu.auth0.com/",
46+
"audience": "https://api.flagsmith.com",
4647
"is_admin": True,
4748
}
4849

@@ -51,7 +52,7 @@ def test_create_trust_relationship__trailing_slash_issuer__returns_normalised_is
5152

5253
# Then
5354
assert response.status_code == status.HTTP_201_CREATED
54-
assert response.json()["issuer"] == "https://token.actions.githubusercontent.com"
55+
assert response.json()["issuer"] == "https://tenant.eu.auth0.com/"
5556

5657

5758
def test_create_trust_relationship__http_issuer__returns_400(

api/tests/test_helpers.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import json
2+
from datetime import datetime, timedelta, timezone
13
from typing import Any
24

5+
import jwt
6+
from cryptography.hazmat.primitives.asymmetric import rsa
37
from flag_engine.segments.types import ConditionOperator
48

59

@@ -32,3 +36,40 @@ def generate_segment_data(
3236
}
3337
],
3438
}
39+
40+
41+
class OIDCIssuerStub:
42+
"""An RSA keypair posing as an OIDC issuer: signs tokens and serves the
43+
matching JWKS in tests.
44+
"""
45+
46+
KEY_ID = "test-key"
47+
48+
def __init__(self, issuer: str) -> None:
49+
self.issuer = issuer
50+
self._private_key = rsa.generate_private_key(
51+
public_exponent=65537, key_size=2048
52+
)
53+
54+
@property
55+
def jwks(self) -> dict[str, Any]:
56+
jwk = json.loads(
57+
jwt.algorithms.RSAAlgorithm.to_jwk(self._private_key.public_key())
58+
)
59+
jwk["kid"] = self.KEY_ID
60+
return {"keys": [jwk]}
61+
62+
def sign_token(self, expires_in_seconds: int = 300, **claims: Any) -> str:
63+
now = datetime.now(tz=timezone.utc)
64+
payload: dict[str, Any] = {
65+
"iss": self.issuer,
66+
"iat": now,
67+
"exp": now + timedelta(seconds=expires_in_seconds),
68+
**claims,
69+
}
70+
return jwt.encode(
71+
payload,
72+
self._private_key,
73+
algorithm="RS256",
74+
headers={"kid": self.KEY_ID},
75+
)

0 commit comments

Comments
 (0)