Skip to content

Commit 59bcf0c

Browse files
authored
Merge pull request #97 from TaskarCenterAtUW/jeff-josm
capabilities call fix for JOSM
2 parents 767e6ef + af96b42 commit 59bcf0c

4 files changed

Lines changed: 107 additions & 5 deletions

File tree

api/core/security.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ def evict_user_from_cache(auth_uid: UUID) -> None:
167167
# Every Basic-auth rejection repeats this, because the field placement is the
168168
# thing callers get wrong and a bare "Not authenticated" gives them nothing to
169169
# act on.
170+
# Advertised in the Basic challenge on the proxied OSM surface.
171+
OSM_BASIC_REALM = "TDEI Workspaces"
172+
170173
_BASIC_USAGE_HINT = (
171174
"Supply the TDEI token as the HTTP Basic *username*; the password is "
172175
'ignored. For example: `curl -u "$TDEI_TOKEN:" ...`, or a URL of the form '
@@ -178,7 +181,9 @@ def _basic_auth_error(reason: str) -> HTTPException:
178181
return HTTPException(
179182
status_code=status.HTTP_401_UNAUTHORIZED,
180183
detail=f"{reason} {_BASIC_USAGE_HINT}",
181-
headers={"WWW-Authenticate": "Bearer"},
184+
# Challenge with the scheme the caller was using, so a client that only speaks Basic can
185+
# correct itself and retry.
186+
headers={"WWW-Authenticate": f'Basic realm="{OSM_BASIC_REALM}"'},
182187
)
183188

184189

@@ -275,11 +280,25 @@ async def __call__( # type: ignore[override]
275280
scheme, param = get_authorization_scheme_param(
276281
request.headers.get("Authorization")
277282
)
283+
basic_allowed = not request.url.path.startswith(BEARER_ONLY_PATH_PREFIXES)
284+
278285
if scheme.lower() != "basic":
286+
# No credentials at all, on a path where Basic is the accepted scheme: answer with a
287+
# Basic challenge. HTTPBearer answers `WWW-Authenticate: Bearer`, which a client that
288+
# only speaks Basic -- JOSM and other OSM editors -- cannot act on: it never sends its
289+
# credentials and reports that it could not reach the server. The /api/v1 surface is
290+
# unaffected, since Basic is not accepted there.
291+
if not scheme and basic_allowed:
292+
raise HTTPException(
293+
status_code=status.HTTP_401_UNAUTHORIZED,
294+
detail="Not authenticated",
295+
headers={"WWW-Authenticate": f'Basic realm="{OSM_BASIC_REALM}"'},
296+
)
297+
279298
# Bearer (and every rejection path) keeps FastAPI's own behavior.
280299
return await super().__call__(request)
281300

282-
if request.url.path.startswith(BEARER_ONLY_PATH_PREFIXES):
301+
if not basic_allowed:
283302
raise HTTPException(
284303
status_code=status.HTTP_401_UNAUTHORIZED,
285304
detail=(

api/main.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,23 @@ def get_workspace_repository(
209209
_WORKSPACE_PREFIX_RE = re.compile(r"^/workspace/(\d+)(/.*)$")
210210

211211

212+
# OSM clients ask for capabilities before anything else, and do so anonymously -- JOSM will not send
213+
# credentials until it has been challenged, and it cannot act on the Bearer challenge the rest of this
214+
# surface answers with. Serve every spelling they use, with or without the /workspace/{id}/ prefix.
215+
# These are declared above the catch-all so they match first and never reach validate_token.
216+
@app.get("/api/capabilities")
212217
@app.get("/api/capabilities.json")
213-
async def capabilities(request: Request):
214-
"""Proxy OSM capabilities manifest without requiring authentication."""
218+
@app.get("/api/0.6/capabilities")
219+
@app.get("/workspace/{workspace_id}/api/capabilities")
220+
@app.get("/workspace/{workspace_id}/api/capabilities.json")
221+
@app.get("/workspace/{workspace_id}/api/0.6/capabilities")
222+
async def capabilities(request: Request, workspace_id: int | None = None):
223+
"""Proxy the OSM capabilities manifest without requiring authentication.
224+
225+
The manifest is public metadata and carries nothing workspace-specific, so the
226+
`/workspace/{id}/` prefix is accepted only because clients are configured with it as their
227+
server URL; it is stripped and otherwise ignored.
228+
"""
215229

216230
client = _require_osm_client()
217231
client_host = request.client.host if request.client else "unknown"
@@ -227,7 +241,13 @@ async def capabilities(request: Request):
227241
(b"X-Forwarded-Proto", request.url.scheme.encode()),
228242
]
229243

230-
url = httpx.URL(path="/api/capabilities.json")
244+
# Forward whichever spelling was asked for, minus any workspace prefix, rather than a fixed path.
245+
proxied_path = request.url.path
246+
prefix_match = _WORKSPACE_PREFIX_RE.match(proxied_path)
247+
if prefix_match is not None:
248+
proxied_path = prefix_match.group(2)
249+
250+
url = httpx.URL(path=proxied_path)
231251
rp_req = client.build_request("GET", url, headers=req_headers)
232252

233253
try:

tests/integration/test_proxy.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,29 @@ async def test_capabilities_proxies_without_auth(client, mock_osm):
5555
assert mock_osm.last_request.url.path == "/api/capabilities.json"
5656

5757

58+
# OSM editors fetch capabilities before anything else and do so anonymously, under whatever server
59+
# URL they are configured with -- which for this proxy includes the /workspace/{id}/ prefix.
60+
61+
62+
@pytest.mark.parametrize(
63+
"path",
64+
[
65+
"/api/capabilities",
66+
"/api/capabilities.json",
67+
"/api/0.6/capabilities",
68+
"/workspace/7/api/capabilities",
69+
"/workspace/7/api/capabilities.json",
70+
"/workspace/7/api/0.6/capabilities",
71+
],
72+
)
73+
async def test_capabilities_needs_no_auth_under_every_spelling(client, mock_osm, path):
74+
response = await client.get(path)
75+
76+
assert response.status_code == 200
77+
# The workspace prefix is stripped before proxying; upstream only ever sees the OSM path.
78+
assert mock_osm.last_request.url.path == path.replace("/workspace/7", "")
79+
80+
5881
# --- auth / tenant gating --------------------------------------------------
5982

6083

tests/unit/test_security.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,46 @@ async def test_basic_is_accepted_on_proxied_osm_paths(path):
538538
assert creds.credentials == "a.valid.jwt"
539539

540540

541+
# A client that only speaks Basic never sends credentials until it has been challenged, so the
542+
# challenge has to name a scheme it can act on. JOSM reports "failed to initialize communication"
543+
# against a Bearer challenge and never gets as far as sending its token.
544+
545+
546+
async def test_missing_credentials_on_an_osm_path_challenge_basic():
547+
with pytest.raises(HTTPException) as excinfo:
548+
await sec.security(_request_with_auth(None, path="/api/0.6/map"))
549+
550+
assert excinfo.value.status_code == 401
551+
assert excinfo.value.headers is not None
552+
assert excinfo.value.headers["WWW-Authenticate"].startswith("Basic")
553+
554+
555+
async def test_missing_credentials_on_a_prefixed_osm_path_challenge_basic():
556+
with pytest.raises(HTTPException) as excinfo:
557+
await sec.security(_request_with_auth(None, path="/workspace/7/api/0.6/map"))
558+
559+
assert excinfo.value.status_code == 401
560+
assert excinfo.value.headers["WWW-Authenticate"].startswith("Basic")
561+
562+
563+
async def test_missing_credentials_on_native_api_paths_still_challenge_bearer():
564+
# The /api/v1 surface does not accept Basic, so it must not invite it either.
565+
with pytest.raises(HTTPException) as excinfo:
566+
await sec.security(_request_with_auth(None, path="/api/v1/workspaces"))
567+
568+
assert excinfo.value.status_code in (401, 403)
569+
headers = excinfo.value.headers or {}
570+
assert not str(headers.get("WWW-Authenticate", "")).startswith("Basic")
571+
572+
573+
async def test_unusable_basic_credentials_are_rechallenged_with_basic():
574+
with pytest.raises(HTTPException) as excinfo:
575+
await sec.security(_request_with_auth("Basic !!!not-base64!!!", path="/api/0.6/map"))
576+
577+
assert excinfo.value.status_code == 401
578+
assert excinfo.value.headers["WWW-Authenticate"].startswith("Basic")
579+
580+
541581
async def test_bearer_is_accepted_on_native_api_paths():
542582
# The scoping restricts Basic only; Bearer works everywhere as before.
543583
creds = await sec.security(

0 commit comments

Comments
 (0)