Files
brain-of-reese/app/api/auth.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

131 lines
5.3 KiB
Python

"""Auth API — admin sign-in (phase 16) + token login (phase 79).
* ``POST /api/login`` — 204 + signed session cookie on success; 401
``invalid password`` on any mismatch (constant-time, one generic
message, no session set).
* ``POST /api/token-auth`` — 204 + signed session cookie for a valid,
unrevoked API token (PUBLIC — it is the token holders' login route);
every failure shape (malformed / unknown / revoked / empty) is ONE
generic 401 ``invalid token`` (no enumeration, the phase-16 pattern).
* ``POST /api/logout`` — 204; clears the session and expires the
cookie (idempotent for anonymous callers — one logout wipes BOTH
roles, the session is one dict).
* ``GET /api/whoami`` — ``{"authenticated": bool, "role":
"admin"|"user"|"anonymous"}``; the single source of truth for all UI
gating (phase 79: the third role; the UI's admin-only surfaces key
off ``role === "admin"`` specifically).
The rest of the public surface is the phase-79 contract: health,
config, whoami, login, token-auth, and the shared chats stay
anonymous — chat, suggestions, and the document viewer content are
user-gated (``require_user``).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from app.config import get_settings
from app.core import tokens as token_service
from app.core.auth import (
ADMIN_SESSION_KEY,
USER_SESSION_KEY,
USER_TOKEN_ID_KEY,
check_password,
sign_in,
sign_out,
)
from app.db import get_db
from app.schemas import LoginRequest, TokenAuthRequest, WhoamiResponse
router = APIRouter(tags=["auth"])
@router.post("/login", status_code=204)
def login(payload: LoginRequest, request: Request) -> Response:
"""Sign in the single admin.
Success: 204 + the signed ``bor_session`` cookie (``same_site=lax``,
12 h default lifetime). Failure: one generic 401 — the admin count is
one, so there is nothing else to leak, and a wrong password must not
set any session state.
"""
settings = get_settings()
if check_password(payload.password, settings.admin_password):
sign_in(request.session)
return Response(status_code=204)
raise HTTPException(status_code=401, detail="invalid password")
@router.post("/token-auth", status_code=204)
def token_auth(
payload: TokenAuthRequest,
request: Request,
db: Session = Depends(get_db), # noqa: B008
) -> Response:
"""Sign a token holder in (phase 79 — PUBLIC: this IS the login).
A valid, unrevoked token → 204 + the signed ``bor_session`` cookie
(the same SessionMiddleware mechanism as ``/api/login``): the
session carries the ``user`` key plus the token's row id
(``user_token_id``), and ``require_user``'s live row check enforces
revocation from the holder's very next request. The token's
``last_used_at`` is stamped here (the admin's Tokens view shows the
login as the last use).
EVERY failure shape — malformed, unknown, revoked, empty — is ONE
generic 401 ``invalid token``: the lookup is by hash (the service
returns ``None`` for anything that is not an active row), so there
is no enumeration surface (the phase-16 pattern).
"""
token = payload.token.strip()
row = token_service.find_active_by_token(db, token) if token else None
if row is None:
raise HTTPException(status_code=401, detail="invalid token")
token_service.mark_used(row)
db.commit()
request.session[USER_SESSION_KEY] = True
request.session[USER_TOKEN_ID_KEY] = str(row.id)
return Response(status_code=204)
@router.post("/logout", status_code=204)
def logout(request: Request, response: Response) -> Response:
"""Sign out: clear the session AND expire the browser cookie.
``sign_out`` empties the session dict (which the middleware does not
re-persist — an empty session has nothing to sign), so this route
also sends ``delete_cookie`` to make the browser drop the signed
cookie right now. One logout wipes BOTH roles (admin and token —
the session is one dict). Idempotent: an anonymous logout is still
a 204.
"""
sign_out(request.session)
response.delete_cookie(get_settings().session_cookie, path="/")
return Response(status_code=204)
@router.get("/whoami", response_model=WhoamiResponse)
def whoami(request: Request) -> WhoamiResponse:
"""Who is the caller? Drives every UI gating decision.
Three roles (phase 79): ``admin`` (the signed-in admin — wins when
the browser holds BOTH an admin and a token session), ``user`` (a
token holder), ``anonymous``. ``authenticated`` is true for admin
AND user; the UI's admin-only surfaces key off ``role === "admin"``
specifically, not off ``authenticated``.
This endpoint only reads the session keys — it does NOT live-check
the token row. A token session whose row was just revoked still
reports ``user`` here until the next gated request: ``require_user``
then pops the dead keys, after which this endpoint reports
anonymous (the phase-79 live-enforcement contract).
"""
if request.session.get(ADMIN_SESSION_KEY):
role = "admin"
elif request.session.get(USER_SESSION_KEY):
role = "user"
else:
role = "anonymous"
return WhoamiResponse(authenticated=role != "anonymous", role=role)