Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ The application is configurable via environment variables.
> [!NOTE]
> This is independent of the upstream API's path. The proxy will handle removing this prefix from incoming requests and adding it to outgoing links.

### `ROOT_PATH_SKIP_PREFIXES`

: Path prefixes of sibling services on the proxy's hostname whose links must not be rewritten

- **Type:** comma-separated list of path prefixes
- **Required:** No, defaults to `''` (rewrite all same-host links)
- **Example:** `/raster,/vector,/browser`

> [!NOTE]
> When the proxy shares a hostname with other services (e.g. the proxy at `/stac` alongside a tiler at `/raster`), STAC responses may contain links to those sibling services on the same host. By default the proxy cannot distinguish these from its own links and would prepend `ROOT_PATH` to them, breaking them. List the sibling services' path prefixes here to leave such links untouched. Matching is path-segment-aware (`/raster` matches `/raster` and `/raster/...`, but not `/rasterfoo`).

## Authentication

### `OIDC_DISCOVERY_URL`
Expand Down
2 changes: 2 additions & 0 deletions docs/user-guide/tips.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ The proxy can be optionally served from a non-root path (e.g., `/api/v1`). Addit
- Update the OpenAPI specification to include the `ROOT_PATH` in the servers field
- Handle requests that don't match the `ROOT_PATH` with a 404 response

If the proxy shares its hostname with other services (e.g. the proxy at `/stac` alongside a tiler at `/raster`), links to those services would also receive the `ROOT_PATH` prefix. Use `ROOT_PATH_SKIP_PREFIXES` to exempt their path prefixes from link rewriting.

## Non-OIDC Workaround

If the upstream server utilizes RS256 JWTs but does not utilize a proper OIDC server, the proxy can be configured to work around this by setting the `OIDC_DISCOVERY_URL` to a statically-hosted OIDC discovery document that points to a valid JWKS endpoint.
Expand Down
1 change: 1 addition & 0 deletions src/stac_auth_proxy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ def configure_app(
ProcessLinksMiddleware,
upstream_url=str(settings.upstream_url),
root_path=settings.root_path,
root_path_skip_prefixes=settings.root_path_skip_prefixes,
)

if settings.root_path:
Expand Down
20 changes: 20 additions & 0 deletions src/stac_auth_proxy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class Settings(BaseSettings):
allowed_jwt_audiences: Optional[Sequence[str]] = None

root_path: str = ""
root_path_skip_prefixes: Sequence[str] = ()
override_host: bool = True
healthz_prefix: str = Field(pattern=_PREFIX_PATTERN, default="/healthz")
upstream_timeout: float = 15.0
Expand Down Expand Up @@ -147,3 +148,22 @@ def _default_oidc_discovery_internal_url(cls, data: Any) -> Any:
def parse_audience(cls, v) -> Sequence[str] | None:
"""Parse a comma separated string list of audiences into a list."""
return str2list(v)

@field_validator("root_path_skip_prefixes", mode="before")
@classmethod
def parse_root_path_skip_prefixes(cls, v) -> Sequence[str] | None:
"""Parse a comma separated string of path prefixes into a normalized list."""
values = str2list(v)
if values is None:
return values
prefixes = []
for value in values:
prefix = value.strip().rstrip("/")
if not prefix:
if value.strip():
raise ValueError(f"Path prefix {value!r} would match every path")
continue
if not prefix.startswith("/"):
raise ValueError(f"Path prefix {value!r} must start with '/'")
prefixes.append(prefix)
return prefixes
26 changes: 25 additions & 1 deletion src/stac_auth_proxy/middleware/ProcessLinksMiddleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import logging
import re
from dataclasses import dataclass
from typing import Any, Optional
from typing import Any, Optional, Sequence
from urllib.parse import ParseResult, urlparse, urlunparse

from starlette.datastructures import Headers
Expand All @@ -27,9 +27,18 @@ class ProcessLinksMiddleware(JsonResponseMiddleware):
app: ASGIApp
upstream_url: str
root_path: Optional[str] = None
root_path_skip_prefixes: Sequence[str] = ()

json_content_type_expr: str = r"application/(geo\+)?json"

def __post_init__(self) -> None:
"""Normalize skip prefixes, dropping empty entries and trailing slashes."""
self.root_path_skip_prefixes = tuple(
prefix.rstrip("/")
for prefix in self.root_path_skip_prefixes
if prefix.rstrip("/")
)

def should_transform_response(self, request: Request, scope: Scope) -> bool:
"""Only transform responses with JSON content type."""
return bool(
Expand Down Expand Up @@ -70,6 +79,21 @@ def _update_link(

parsed_link = urlparse(link["href"])

# Leave links to sibling services untouched. On a shared-host deployment
# (e.g. this proxy at /stac and a tiler at /raster on the same hostname),
# same-host links to other services must not be treated as local to the
# upstream API and must not have the root_path prepended.
if parsed_link.netloc == request_url.netloc and any(
parsed_link.path == prefix or parsed_link.path.startswith(f"{prefix}/")
for prefix in self.root_path_skip_prefixes
):
logger.debug(
"Ignoring link %s because its path matches a configured skip prefix (%s)",
link["href"],
self.root_path_skip_prefixes,
)
return

if parsed_link.netloc not in [
request_url.netloc,
upstream_url.netloc,
Expand Down
37 changes: 37 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Test config classes."""

import pytest

from stac_auth_proxy.config import CorsSettings, Settings


Expand Down Expand Up @@ -53,6 +55,41 @@ def test_settings_model_config():
assert settings.allowed_jwt_audiences == [""]


def test_root_path_skip_prefixes():
"""Test parsing and normalization of root_path_skip_prefixes."""
common_kwargs = {
"upstream_url": "https://example.com",
"oidc_discovery_url": "https://example.com/.well-known/openid-configuration",
}

# Defaults to empty (feature disabled)
settings = Settings(**common_kwargs)
assert list(settings.root_path_skip_prefixes) == []

# Comma-separated string, with trailing slashes normalized
settings = Settings(
**common_kwargs,
root_path_skip_prefixes="/raster/,/vector,/browser",
)
assert list(settings.root_path_skip_prefixes) == ["/raster", "/vector", "/browser"]

# List input
settings = Settings(**common_kwargs, root_path_skip_prefixes=["/raster"])
assert list(settings.root_path_skip_prefixes) == ["/raster"]

# Empty entries are dropped
settings = Settings(**common_kwargs, root_path_skip_prefixes="/raster,,")
assert list(settings.root_path_skip_prefixes) == ["/raster"]

# Prefixes must start with a slash
with pytest.raises(ValueError):
Settings(**common_kwargs, root_path_skip_prefixes="raster")

# A bare slash would skip every same-host link
with pytest.raises(ValueError):
Settings(**common_kwargs, root_path_skip_prefixes="/")


def test_settings_model_config_with_environment_variables(monkeypatch):
"""Test that the model config is set correctly with environment variables."""
monkeypatch.setenv("UPSTREAM_URL", "https://example.com")
Expand Down
158 changes: 158 additions & 0 deletions tests/test_process_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,164 @@ def test_transform_upstream_links_nested_objects():
)


@pytest.mark.parametrize(
"root_path_skip_prefixes,input_links,expected_links",
[
# Same-host links to sibling services are skipped; STAC links still rewritten
(
["/raster", "/vector"],
[
{"rel": "self", "href": "http://proxy.example.com/collections"},
{
"rel": "xyz",
"href": "http://proxy.example.com/raster/collections/c/items/i/{z}/{x}/{y}",
},
{
"rel": "tilejson",
"href": "http://proxy.example.com/vector/tilejson.json",
},
],
[
"http://proxy.example.com/stac/collections",
"http://proxy.example.com/raster/collections/c/items/i/{z}/{x}/{y}",
"http://proxy.example.com/vector/tilejson.json",
],
),
# Default (no skip prefixes): same-host sibling link gets root_path (current behavior)
(
(),
[
{"rel": "xyz", "href": "http://proxy.example.com/raster/tiles"},
],
[
"http://proxy.example.com/stac/raster/tiles",
],
),
# Matching is path-segment-aware: "/raster" must not match "/rasterfoo"
(
["/raster"],
[
{"rel": "self", "href": "http://proxy.example.com/rasterfoo/tiles"},
{"rel": "exact", "href": "http://proxy.example.com/raster"},
{"rel": "child", "href": "http://proxy.example.com/raster/tiles"},
],
[
"http://proxy.example.com/stac/rasterfoo/tiles",
"http://proxy.example.com/raster",
"http://proxy.example.com/raster/tiles",
],
),
# Trailing slashes on configured prefixes are normalized
(
["/raster/"],
[
{"rel": "xyz", "href": "http://proxy.example.com/raster/tiles"},
{"rel": "exact", "href": "http://proxy.example.com/raster"},
],
[
"http://proxy.example.com/raster/tiles",
"http://proxy.example.com/raster",
],
),
# Skip prefixes only apply to the request host; other hosts are untouched anyway
(
["/raster"],
[
{"rel": "xyz", "href": "http://other.example.com/raster/tiles"},
],
[
"http://other.example.com/raster/tiles",
],
),
],
)
def test_transform_with_root_path_skip_prefixes(
root_path_skip_prefixes, input_links, expected_links
):
"""Test that same-host links matching a skip prefix are not rewritten."""
middleware = ProcessLinksMiddleware(
app=None,
upstream_url="http://upstream.example.com",
root_path="/stac",
root_path_skip_prefixes=root_path_skip_prefixes,
)
request_scope = {
"type": "http",
"path": "/test",
"headers": [
(b"host", b"proxy.example.com"),
(b"content-type", b"application/json"),
],
}

data = {"links": input_links}
transformed = middleware.transform_json(data, Request(request_scope))

for i, expected in enumerate(expected_links):
assert transformed["links"][i]["href"] == expected


def test_transform_with_root_path_skip_prefixes_and_forwarded_headers():
"""Test that skip prefixes apply to the forwarded (client-facing) host."""
middleware = ProcessLinksMiddleware(
app=None,
upstream_url="http://upstream.example.com",
root_path="/stac",
root_path_skip_prefixes=["/raster"],
)
request_scope = {
"type": "http",
"path": "/test",
"headers": [
(b"host", b"internal-proxy:8080"),
(b"content-type", b"application/json"),
(b"x-forwarded-host", b"api.example.com"),
(b"x-forwarded-proto", b"https"),
],
}

data = {
"links": [
{"rel": "self", "href": "https://api.example.com/collections"},
{"rel": "xyz", "href": "https://api.example.com/raster/tiles"},
]
}
transformed = middleware.transform_json(data, Request(request_scope))

assert transformed["links"][0]["href"] == "https://api.example.com/stac/collections"
assert transformed["links"][1]["href"] == "https://api.example.com/raster/tiles"


def test_transform_upstream_netloc_links_ignore_skip_prefixes():
"""Skip prefixes only apply to client-facing links, not upstream-host links."""
middleware = ProcessLinksMiddleware(
app=None,
upstream_url="http://upstream.example.com",
root_path="/stac",
root_path_skip_prefixes=["/raster"],
)
request_scope = {
"type": "http",
"path": "/test",
"headers": [
(b"host", b"proxy.example.com"),
(b"content-type", b"application/json"),
],
}

# A link on the upstream host is rewritten to the proxy host with root_path,
# even when its path matches a skip prefix: skip prefixes describe sibling
# services on the proxy's public hostname, not paths behind the upstream.
data = {
"links": [{"rel": "xyz", "href": "http://upstream.example.com/raster/tiles"}]
}
transformed = middleware.transform_json(data, Request(request_scope))

assert (
transformed["links"][0]["href"] == "http://proxy.example.com/stac/raster/tiles"
)


@pytest.mark.parametrize(
"headers,expected_base_url",
[
Expand Down
Loading