Skip to content

Commit b76751f

Browse files
committed
security(annotations): require scoped capabilities
1 parent cda00d4 commit b76751f

2 files changed

Lines changed: 81 additions & 76 deletions

File tree

‎app/auth.py‎

Lines changed: 14 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,13 @@
44
import hashlib
55
import hmac
66
import json
7-
import logging
87
import time
9-
from typing import Any
108

11-
import httpx
129
from fastapi import Depends, HTTPException, Request, status
1310
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
14-
from jose import JWTError, jwt
1511

1612
from .config import settings
1713

18-
logger = logging.getLogger(__name__)
19-
2014

2115
class InvalidWsiToken(ValueError):
2216
"""Raised when a WSI capability cannot be trusted."""
@@ -82,11 +76,18 @@ def validate_wsi_token(
8276
if "wsi:read" in required:
8377
if payload.get("wsi_auth_version") != 2:
8478
raise InvalidWsiToken("unsupported WSI authorization contract")
85-
if not isinstance(payload.get("image_id"), str) or not payload["image_id"].strip():
79+
if (
80+
not isinstance(payload.get("image_id"), str)
81+
or not payload["image_id"].strip()
82+
):
8683
raise InvalidWsiToken("invalid token image")
8784
for claim in ("tile_source_sha256", "thumbnail_source_sha256"):
8885
value = payload.get(claim)
89-
if not isinstance(value, str) or len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
86+
if (
87+
not isinstance(value, str)
88+
or len(value) != 64
89+
or any(char not in "0123456789abcdef" for char in value)
90+
):
9091
raise InvalidWsiToken("invalid token source binding")
9192
for claim in ("thumbnail_width", "thumbnail_height"):
9293
value = payload.get(claim)
@@ -102,40 +103,13 @@ def validate_wsi_token(
102103

103104

104105
_bearer = HTTPBearer(auto_error=False)
105-
_jwks_cache: dict[str, Any] = {}
106-
_jwks_fetched_at: float = 0.0
107-
_JWKS_TTL = 3600
108-
109-
110-
async def _get_jwks() -> dict[str, Any]:
111-
global _jwks_cache, _jwks_fetched_at
112-
if time.monotonic() - _jwks_fetched_at < _JWKS_TTL and _jwks_cache:
113-
return _jwks_cache
114-
if not settings.keycloak_jwks_url:
115-
raise HTTPException(
116-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
117-
detail="KEYCLOAK_JWKS_URL is not configured",
118-
)
119-
try:
120-
async with httpx.AsyncClient(timeout=10) as client:
121-
resp = await client.get(settings.keycloak_jwks_url)
122-
resp.raise_for_status()
123-
_jwks_cache = resp.json()
124-
_jwks_fetched_at = time.monotonic()
125-
return _jwks_cache
126-
except Exception as exc:
127-
logger.error("Failed to fetch JWKS from %s: %s", settings.keycloak_jwks_url, exc)
128-
raise HTTPException(
129-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
130-
detail="Unable to reach Keycloak JWKS endpoint",
131-
)
132106

133107

134108
async def require_user(
135109
request: Request,
136110
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
137-
) -> dict[str, Any]:
138-
"""Return the authenticated Keycloak subject and groups for annotations."""
111+
) -> dict:
112+
"""Return the subject and study scope from a cBioPortal capability."""
139113
if not settings.annotation_auth_enabled:
140114
return {"sub": "dev-user", "groups": []}
141115
if creds is None:
@@ -159,22 +133,9 @@ async def require_user(
159133
"groups": [],
160134
"study_id": capability["study_id"],
161135
}
162-
except InvalidWsiToken:
163-
pass
164-
try:
165-
payload = jwt.decode(
166-
creds.credentials,
167-
await _get_jwks(),
168-
algorithms=["RS256"],
169-
options={"verify_aud": False},
170-
)
171-
except JWTError as exc:
136+
except InvalidWsiToken as exc:
172137
raise HTTPException(
173138
status_code=status.HTTP_401_UNAUTHORIZED,
174-
detail=f"Invalid token: {exc}",
139+
detail="Invalid annotation capability",
175140
headers={"WWW-Authenticate": "Bearer"},
176-
)
177-
sub: str = payload.get("sub", "")
178-
if not sub:
179-
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token missing 'sub' claim")
180-
return {"sub": sub, "groups": payload.get("groups", []), "study_id": None}
141+
) from exc

‎tests/test_auth.py‎

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
import time
66

77
import pytest
8+
from fastapi import HTTPException, Request
9+
from fastapi.security import HTTPAuthorizationCredentials
810

911
from app.auth import (
1012
InvalidWsiToken,
13+
require_user,
1114
source_digest,
1215
validate_wsi_auth_configuration,
1316
validate_wsi_token,
@@ -16,27 +19,39 @@
1619

1720
def make_token(secret: str, **claims) -> str:
1821
def encode(value):
19-
return base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode()
22+
return (
23+
base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode()
24+
)
2025

2126
header = encode({"alg": "HS256", "typ": "JWT"})
2227
payload = encode(claims)
2328
signing_input = f"{header}.{payload}".encode()
24-
signature = base64.urlsafe_b64encode(
25-
hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
26-
).rstrip(b"=").decode()
29+
signature = (
30+
base64.urlsafe_b64encode(
31+
hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
32+
)
33+
.rstrip(b"=")
34+
.decode()
35+
)
2736
return f"{header}.{payload}.{signature}"
2837

2938

3039
def make_raw_token(secret: str, header, payload) -> str:
3140
def encode(value):
32-
return base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode()
41+
return (
42+
base64.urlsafe_b64encode(json.dumps(value).encode()).rstrip(b"=").decode()
43+
)
3344

3445
encoded_header = encode(header)
3546
encoded_payload = encode(payload)
3647
signing_input = f"{encoded_header}.{encoded_payload}".encode()
37-
signature = base64.urlsafe_b64encode(
38-
hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
39-
).rstrip(b"=").decode()
48+
signature = (
49+
base64.urlsafe_b64encode(
50+
hmac.new(secret.encode(), signing_input, hashlib.sha256).digest()
51+
)
52+
.rstrip(b"=")
53+
.decode()
54+
)
4055
return f"{encoded_header}.{encoded_payload}.{signature}"
4156

4257

@@ -62,9 +77,12 @@ def valid_claims(**overrides):
6277

6378
def test_valid_wsi_token():
6479
secret = "s" * 32
65-
assert validate_wsi_token(
66-
make_token(secret, **valid_claims()), secret, "cbioportal-wsi"
67-
)["sub"] == "user@example.org"
80+
assert (
81+
validate_wsi_token(
82+
make_token(secret, **valid_claims()), secret, "cbioportal-wsi"
83+
)["sub"]
84+
== "user@example.org"
85+
)
6886

6987

7088
@pytest.mark.parametrize(
@@ -82,11 +100,14 @@ def test_invalid_wsi_auth_configuration_is_rejected(secret, audience, max_ttl):
82100
validate_wsi_auth_configuration(secret, audience, max_ttl)
83101

84102

85-
@pytest.mark.parametrize("change", [
86-
{"scope": "wsi:write"},
87-
{"aud": "other-service"},
88-
{"exp": int(time.time()) - 1},
89-
])
103+
@pytest.mark.parametrize(
104+
"change",
105+
[
106+
{"scope": "wsi:write"},
107+
{"aud": "other-service"},
108+
{"exp": int(time.time()) - 1},
109+
],
110+
)
90111
def test_invalid_claims_are_rejected(change):
91112
secret = "s" * 32
92113
claims = valid_claims(**change)
@@ -114,13 +135,16 @@ def test_non_object_header_and_payload_are_rejected():
114135
)
115136

116137

117-
@pytest.mark.parametrize("change", [
118-
{"study_id": ""},
119-
{"image_id": ""},
120-
{"tile_source_sha256": ""},
121-
{"thumbnail_width": 0},
122-
{"exp": int(time.time()) + 1000},
123-
])
138+
@pytest.mark.parametrize(
139+
"change",
140+
[
141+
{"study_id": ""},
142+
{"image_id": ""},
143+
{"tile_source_sha256": ""},
144+
{"thumbnail_width": 0},
145+
{"exp": int(time.time()) + 1000},
146+
],
147+
)
124148
def test_source_bound_claims_and_max_ttl_are_required(change):
125149
secret = "s" * 32
126150
with pytest.raises(InvalidWsiToken):
@@ -169,3 +193,23 @@ def test_annotation_capability_requires_both_annotation_scopes():
169193
"cbioportal-wsi",
170194
required_scopes={"annotations:read", "annotations:write"},
171195
)
196+
197+
198+
@pytest.mark.asyncio
199+
async def test_annotation_auth_rejects_non_capability_tokens():
200+
request = Request(
201+
{
202+
"type": "http",
203+
"method": "GET",
204+
"path": "/annotations",
205+
"headers": [],
206+
}
207+
)
208+
credentials = HTTPAuthorizationCredentials(
209+
scheme="Bearer", credentials="generic-keycloak-token"
210+
)
211+
212+
with pytest.raises(HTTPException) as error:
213+
await require_user(request, credentials)
214+
215+
assert error.value.status_code == 401

0 commit comments

Comments
 (0)