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:
Vojtech 2026-05-15 17:18:39 +04:00 committed by GitHub
parent 9f5adbce37
commit fbe756685b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 68 additions and 3 deletions

View file

@ -10,6 +10,9 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [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
### Changed

View file

@ -30,11 +30,12 @@ from datetime import datetime, timezone
from typing import Any, Optional
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 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 src.initial_workspace import (
TemplateValidationError,
@ -510,7 +511,8 @@ async def analyst_status(
@router.get("/api/initial-workspace.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),
):
"""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
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()
if not section.get("url"):
raise HTTPException(status_code=404, detail={"kind": "not_configured"})

View file

@ -586,6 +586,50 @@ def test_analyst_status_configured_synced(web_client, fake_remote):
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):
"""GET /api/initial-workspace.zip returns 404 when no template."""
headers = _make_user(web_client)