fix(api): redirect unauthorized browser requests to login for initial workspace zip (#315)
* fix(api): redirect unauthorized browser requests to login for initial workspace zip * fix(api): import Request and RedirectResponse in initial_workspace router FastAPI was treating `request` as a required query parameter because `Request` was missing from the fastapi import, causing 422 on GET /api/initial-workspace.zip. `RedirectResponse` was also missing (used for browser redirect to /login). * review fixes: CHANGELOG + comment + 2 edge tests - CHANGELOG.md: add [Unreleased] ### Fixed bullet per project rule. - app/api/initial_workspace.py: comment explaining why this /api/* endpoint intentionally opts out of the _API_PATH_PREFIXES "never redirect /api/*" contract in app/main.py, and why matching only `text/html` (not `*/*`) mirrors _wants_html()'s rationale. - tests: add Accept: */* (curl default) and empty-Accept cases — both lock in 401, defending the curl-tooling-must-keep-getting-401 contract the comment now documents. --------- Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
This commit is contained in:
parent
9f5adbce37
commit
fbe756685b
3 changed files with 68 additions and 3 deletions
|
|
@ -10,6 +10,9 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Unauthenticated browser requests to `GET /api/initial-workspace.zip` now redirect to `/login?next=/api/initial-workspace.zip` instead of returning a raw JSON 401** (#315). This is the one `/api/*` endpoint that's designed to be hit directly from a browser bookmark (the analyst clean-install zip), so it intentionally opts out of the global `_API_PATH_PREFIXES` "never redirect /api/*" contract in `app/main.py`. CLI / curl / other API clients (any `Accept` without `text/html` — including the `*/*` default) keep getting the 401 they can handle.
|
||||||
|
|
||||||
## [0.54.17] — 2026-05-15
|
## [0.54.17] — 2026-05-15
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,12 @@ from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.auth.access import require_admin
|
from app.auth.access import require_admin
|
||||||
from app.auth.dependencies import _get_db, get_current_user
|
from app.auth.dependencies import _get_db, get_current_user, get_optional_user
|
||||||
from app.secrets import persist_overlay_token
|
from app.secrets import persist_overlay_token
|
||||||
from src.initial_workspace import (
|
from src.initial_workspace import (
|
||||||
TemplateValidationError,
|
TemplateValidationError,
|
||||||
|
|
@ -510,7 +511,8 @@ async def analyst_status(
|
||||||
|
|
||||||
@router.get("/api/initial-workspace.zip")
|
@router.get("/api/initial-workspace.zip")
|
||||||
async def analyst_zip(
|
async def analyst_zip(
|
||||||
user: dict = Depends(get_current_user),
|
request: Request,
|
||||||
|
user: Optional[dict] = Depends(get_optional_user),
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
"""Return the zip of the cloned template tree (sans ``.git/``).
|
"""Return the zip of the cloned template tree (sans ``.git/``).
|
||||||
|
|
@ -524,6 +526,22 @@ async def analyst_zip(
|
||||||
this; defense in depth). 503 when configured but never synced — the
|
this; defense in depth). 503 when configured but never synced — the
|
||||||
CLI then surfaces a typed error pointing at "Sync now".
|
CLI then surfaces a typed error pointing at "Sync now".
|
||||||
"""
|
"""
|
||||||
|
if user is None:
|
||||||
|
# Browser → redirect to /login (target preserved via ?next=).
|
||||||
|
# CLI / curl / API client → raw 401 they can handle.
|
||||||
|
# This endpoint is the one `/api/*` URL designed to be hit directly
|
||||||
|
# from a browser bookmark (analyst clean-install zip), so it
|
||||||
|
# intentionally opts out of the global `_API_PATH_PREFIXES`
|
||||||
|
# "never redirect /api/*" contract in `app/main.py`. Matching only
|
||||||
|
# `text/html` — NOT `*/*` — mirrors `_wants_html()` in `app/main.py`:
|
||||||
|
# `*/*` is curl's default and must keep getting the raw 401 so
|
||||||
|
# tooling that parses `{"detail": "..."}` doesn't silently break.
|
||||||
|
if "text/html" in request.headers.get("accept", ""):
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/login?next=/api/initial-workspace.zip", status_code=302
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
||||||
|
|
||||||
section = _read_section()
|
section = _read_section()
|
||||||
if not section.get("url"):
|
if not section.get("url"):
|
||||||
raise HTTPException(status_code=404, detail={"kind": "not_configured"})
|
raise HTTPException(status_code=404, detail={"kind": "not_configured"})
|
||||||
|
|
|
||||||
|
|
@ -586,6 +586,50 @@ def test_analyst_status_configured_synced(web_client, fake_remote):
|
||||||
assert "CLAUDE.md" in body["files"]
|
assert "CLAUDE.md" in body["files"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyst_zip_browser_unauthenticated_redirects_to_login(web_client):
|
||||||
|
"""Unauthenticated browser request (Accept: text/html) redirects to /login."""
|
||||||
|
r = web_client.get(
|
||||||
|
"/api/initial-workspace.zip",
|
||||||
|
headers={"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert r.headers["location"] == "/login?next=/api/initial-workspace.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyst_zip_api_unauthenticated_returns_401(web_client):
|
||||||
|
"""Unauthenticated API client (no text/html in Accept) still gets a JSON 401."""
|
||||||
|
r = web_client.get(
|
||||||
|
"/api/initial-workspace.zip",
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyst_zip_curl_default_accept_returns_401(web_client):
|
||||||
|
"""`Accept: */*` (curl's default with no `-H`) lands in the 401 branch.
|
||||||
|
|
||||||
|
Mirrors the `_wants_html()` contract in `app/main.py`: `*/*` must NOT
|
||||||
|
silently flip a curl/tooling client to an HTML response — they expect
|
||||||
|
`{"detail": "..."}` and a real 401.
|
||||||
|
"""
|
||||||
|
r = web_client.get(
|
||||||
|
"/api/initial-workspace.zip",
|
||||||
|
headers={"Accept": "*/*"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyst_zip_empty_accept_returns_401(web_client):
|
||||||
|
"""Empty `Accept` header lands in the 401 branch — same shape as the `*/*`
|
||||||
|
case (no `text/html` substring means: not a browser, give the raw 401)."""
|
||||||
|
r = web_client.get(
|
||||||
|
"/api/initial-workspace.zip",
|
||||||
|
headers={"Accept": ""},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_analyst_zip_404_when_not_configured(web_client):
|
def test_analyst_zip_404_when_not_configured(web_client):
|
||||||
"""GET /api/initial-workspace.zip returns 404 when no template."""
|
"""GET /api/initial-workspace.zip returns 404 when no template."""
|
||||||
headers = _make_user(web_client)
|
headers = _make_user(web_client)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue