Skip to content

Commit 58d05ea

Browse files
feat: add configurable audiences (#83)
Enables the JWT audiences to be configured using the `ALLOWED_JWT_AUDIENCES` environment variable
1 parent fc1e217 commit 58d05ea

File tree

5 files changed

+156
-1
lines changed

5 files changed

+156
-1
lines changed

docs/user-guide/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ The application is configurable via environment variables.
7979
**Required:** No, defaults to the value of `OIDC_DISCOVERY_URL`
8080
**Example:** `http://auth/.well-known/openid-configuration`
8181

82+
### `ALLOWED_JWT_AUDIENCES`
83+
84+
: Unique identifier(s) of API resource server(s)
85+
86+
**Type:** string
87+
**Required:** No
88+
**Example:** `https://auth.example.audience.1.net,https://auth.example.audience.2.net`
89+
**Note** A comma-separated list of the intended recipient(s) of the JWT. At least one audience value must match the `aud` (audience) claim present in the incoming JWT. If undefined, the API will not impose a check on the `aud` claim
90+
91+
8292
### `DEFAULT_PUBLIC`
8393

8494
: Default access policy for endpoints

src/stac_auth_proxy/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ async def lifespan(app: FastAPI):
157157
private_endpoints=settings.private_endpoints,
158158
default_public=settings.default_public,
159159
oidc_discovery_url=settings.oidc_discovery_internal_url,
160+
allowed_jwt_audiences=settings.allowed_jwt_audiences,
160161
)
161162

162163
if settings.root_path or settings.upstream_url.path != "/":

src/stac_auth_proxy/config.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import importlib
44
from typing import Any, Literal, Optional, Sequence, TypeAlias, Union
55

6-
from pydantic import BaseModel, Field, model_validator
6+
from pydantic import BaseModel, Field, field_validator, model_validator
77
from pydantic.networks import HttpUrl
88
from pydantic_settings import BaseSettings, SettingsConfigDict
99

@@ -16,6 +16,14 @@
1616
_PREFIX_PATTERN = r"^/.*$"
1717

1818

19+
def str2list(x: Optional[str] = None) -> Optional[Sequence[str]]:
20+
"""Convert string to list based on , delimiter."""
21+
if x:
22+
return x.replace(" ", "").split(",")
23+
24+
return None
25+
26+
1927
class _ClassInput(BaseModel):
2028
"""Input model for dynamically loading a class or function."""
2129

@@ -39,6 +47,7 @@ class Settings(BaseSettings):
3947
upstream_url: HttpUrl
4048
oidc_discovery_url: HttpUrl
4149
oidc_discovery_internal_url: HttpUrl
50+
allowed_jwt_audiences: Optional[Sequence[str]] = None
4251

4352
root_path: str = ""
4453
override_host: bool = True
@@ -92,3 +101,9 @@ def _default_oidc_discovery_internal_url(cls, data: Any) -> Any:
92101
if not data.get("oidc_discovery_internal_url"):
93102
data["oidc_discovery_internal_url"] = data.get("oidc_discovery_url")
94103
return data
104+
105+
@field_validator("allowed_jwt_audiences", mode="before")
106+
@classmethod
107+
def parse_audience(cls, v) -> Optional[Sequence[str]]:
108+
"""Parse a comma separated string list of audiences into a list."""
109+
return str2list(v)

src/stac_auth_proxy/middleware/EnforceAuthMiddleware.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ def validate_token(
153153
# NOTE: Audience validation MUST match audience claim if set in token (https://pyjwt.readthedocs.io/en/stable/changelog.html?highlight=audience#id40)
154154
audience=self.allowed_jwt_audiences,
155155
)
156+
except jwt.InvalidAudienceError as e:
157+
logger.error("InvalidAudienceError: %r", e)
158+
raise HTTPException(
159+
status_code=status.HTTP_401_UNAUTHORIZED,
160+
detail="Could not validate Audience",
161+
headers={"WWW-Authenticate": "Bearer"},
162+
)
156163
except (
157164
jwt.exceptions.InvalidTokenError,
158165
jwt.exceptions.DecodeError,

tests/test_authn.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,125 @@ def test_options_requests_with_cors_headers(source_api_server):
342342
assert (
343343
response.status_code == 200
344344
), "OPTIONS request with CORS headers should succeed"
345+
346+
347+
@pytest.mark.parametrize(
348+
"token_audiences,allowed_audiences,expected_status",
349+
[
350+
# Single audience scenarios
351+
(["stac-api"], "stac-api", 200),
352+
(["stac-api"], "different-api", 401),
353+
(["stac-api"], "stac-api,other-api", 200),
354+
# Multiple audiences in token
355+
(["stac-api", "other-api"], "stac-api", 200),
356+
(["stac-api", "other-api"], "other-api", 200),
357+
(["stac-api", "other-api"], "different-api", 401),
358+
(["stac-api", "other-api"], "stac-api, other-api,third-api", 200),
359+
# No audience in token
360+
(None, "stac-api", 401),
361+
("", "stac-api", 401),
362+
# Empty allowed audiences will regect tokens with an `aud` claim
363+
("any-api", "", 401),
364+
("any-api", None, 401),
365+
# Backward compatibility - no audience configured
366+
(None, None, 200),
367+
("", None, 200),
368+
],
369+
)
370+
def test_jwt_audience_validation(
371+
source_api_server,
372+
token_builder,
373+
token_audiences,
374+
allowed_audiences,
375+
expected_status,
376+
):
377+
"""Test JWT audience validation with various configurations."""
378+
# Build app with audience configuration
379+
app_factory = AppFactory(
380+
oidc_discovery_url="https://example-stac-api.com/.well-known/openid-configuration",
381+
default_public=False,
382+
allowed_jwt_audiences=allowed_audiences,
383+
)
384+
test_app = app_factory(upstream_url=source_api_server)
385+
386+
# Build token with audience claim
387+
token_payload = {}
388+
if token_audiences is not None:
389+
token_payload["aud"] = token_audiences
390+
391+
valid_auth_token = token_builder(token_payload)
392+
393+
client = TestClient(test_app)
394+
response = client.get(
395+
"/collections",
396+
headers={"Authorization": f"Bearer {valid_auth_token}"},
397+
)
398+
assert response.status_code == expected_status
399+
400+
401+
@pytest.mark.parametrize(
402+
"aud_value,scope,expected_status,description",
403+
[
404+
(["stac-api"], "openid", 401, "Valid audience but missing scope"),
405+
(["stac-api"], "collection:create", 200, "Valid audience and valid scope"),
406+
(["wrong-api"], "collection:create", 401, "Invalid audience but valid scope"),
407+
],
408+
)
409+
def test_audience_validation_with_scopes(
410+
source_api_server, token_builder, aud_value, scope, expected_status, description
411+
):
412+
"""Test that audience validation works alongside scope validation."""
413+
app_factory = AppFactory(
414+
oidc_discovery_url="https://example-stac-api.com/.well-known/openid-configuration",
415+
default_public=False,
416+
allowed_jwt_audiences="stac-api",
417+
private_endpoints={r"^/collections$": [("POST", "collection:create")]},
418+
)
419+
test_app = app_factory(upstream_url=source_api_server)
420+
421+
client = TestClient(test_app)
422+
423+
token = token_builder({"aud": aud_value, "scope": scope})
424+
response = client.post(
425+
"/collections",
426+
headers={"Authorization": f"Bearer {token}"},
427+
)
428+
assert response.status_code == expected_status
429+
430+
431+
@pytest.mark.parametrize(
432+
"allowed_audiences_config,test_audience,expected_status",
433+
[
434+
# Comma-separated string
435+
("stac-api,other-api", "stac-api", 200),
436+
("stac-api,other-api", "other-api", 200),
437+
("stac-api,other-api", "unknown-api", 401),
438+
# Comma-separated with spaces
439+
("stac-api, other-api", "stac-api", 200),
440+
("stac-api, other-api", "other-api", 200),
441+
("stac-api, other-api", "unknown-api", 401),
442+
],
443+
)
444+
def test_allowed_audiences_configuration_formats(
445+
source_api_server,
446+
token_builder,
447+
allowed_audiences_config,
448+
test_audience,
449+
expected_status,
450+
):
451+
"""Test different configuration formats for ALLOWED_JWT_AUDIENCES."""
452+
app_factory = AppFactory(
453+
oidc_discovery_url="https://example-stac-api.com/.well-known/openid-configuration",
454+
default_public=False,
455+
allowed_jwt_audiences=allowed_audiences_config,
456+
)
457+
test_app = app_factory(upstream_url=source_api_server)
458+
459+
client = TestClient(test_app)
460+
461+
token = token_builder({"aud": [test_audience]})
462+
response = client.get(
463+
"/collections",
464+
headers={"Authorization": f"Bearer {token}"},
465+
)
466+
assert response.status_code == expected_status

0 commit comments

Comments
 (0)