Feat: Implement JWT bearer token authentication

Protects image upload, delete, and tag-update endpoints behind
Bearer token auth. Public read endpoints remain open. Angular SPA
gains a login page, auth interceptor, and route guard for /upload.

- JWTAuthProvider (HS256, sub/iat/exp, secrets.compare_digest)
- POST /api/v1/auth/token login endpoint
- require_auth FastAPI dependency on all write routes
- AuthService, LoginComponent, authInterceptor, authGuard
- Detail page hides write controls for unauthenticated visitors
- 43 unit tests passing; integration tests require Docker stack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 19:12:38 +00:00
parent d91a65abe5
commit 5fbbc1e67f
36 changed files with 3998 additions and 42 deletions

View File

@@ -0,0 +1,185 @@
# Research: JWT Bearer Token Authentication
**Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03
---
## Decision 1 — JWT library
**Decision**: `PyJWT>=2.8`
**Rationale**: The project needs only HS256 signing with a single symmetric
secret key — the simplest possible JWT profile. `PyJWT` is the de-facto
standard Python JWT library for this use case: no additional crypto
dependencies, actively maintained, wide community adoption. `python-jose` was
the alternative; it has broader JOSE/JWE support but has had maintenance gaps
and brings extra dependencies that we do not need.
For Phase 3 (OIDC), token issuance is replaced by the external identity
provider. The `JWTAuthProvider` will be replaced by an OIDC-aware provider;
the library choice for Phase 2 does not constrain Phase 3.
**Alternatives considered**: `python-jose[cryptography]` — wider JOSE support
but heavier dependency tree and slower maintenance cadence. Rejected.
---
## Decision 2 — Password storage for the single owner account
**Decision**: Store plaintext `OWNER_USERNAME` and `OWNER_PASSWORD` in
environment variables; compare at login time using `secrets.compare_digest`
to prevent timing attacks.
**Rationale**: This is a single-user self-hosted application accessed over a
trusted local network. The password is already known to the person deploying
the application (they set it). Bcrypt pre-hashing would require operators to
run a separate tool to generate the hash before setting the env var, adding
friction with no meaningful security benefit for this threat model. In Phase 3
the owner credentials are replaced by an external OIDC provider entirely, so
this is a temporary mechanism with limited lifetime.
`secrets.compare_digest` is used instead of `==` to prevent any theoretical
timing oracle.
**Alternatives considered**: `passlib[bcrypt]` with a pre-hashed password env
var — more "correct" in absolute terms but adds operator complexity for a
single-user local app. Rejected.
---
## Decision 3 — JWT algorithm and claims
**Decision**: HS256 (HMAC-SHA256) with claims: `sub` (fixed string `"owner"`),
`iat` (issued-at epoch seconds), `exp` (expiry epoch seconds).
**Rationale**: HS256 is symmetric — a single `JWT_SECRET_KEY` env var is used
for both signing and verification. This is appropriate for a single-server
deployment where only the API ever validates tokens. RS256 (asymmetric) would
be needed if a second service needed to verify tokens independently; that is
not the case here and would be added complexity.
The `sub` claim carries the owner identifier. `exp` enables configurable
expiry. `iat` is included for auditability.
**Alternatives considered**: RS256 — appropriate when multiple services verify
tokens. Overkill for this single-server deployment. Rejected.
---
## Decision 4 — Login endpoint request format
**Decision**: JSON body `{"username": "...", "password": "..."}` at
`POST /api/v1/auth/token`. Response: `{"access_token": "...", "token_type": "bearer", "expires_in": <seconds>}`.
**Rationale**: The OAuth2 `application/x-www-form-urlencoded` format
(`grant_type=password, username, password`) is the spec-compliant form for
the Resource Owner Password Credentials grant. However, we are not building a
full OAuth2 authorization server — this is a simplified login endpoint for a
single-user SPA. A JSON body is simpler to consume from Angular's
`HttpClient`, avoids `URLSearchParams` boilerplate, and does not mislead
consumers into thinking this is a full OAuth2 endpoint.
The response shape (`access_token`, `token_type`) follows the OAuth2 bearer
token response convention because Phase 3 (OIDC) will also produce tokens in
this shape — the Angular `AuthService` does not need to change its
token-parsing logic.
**Alternatives considered**: OAuth2 password grant form format — interoperable
but unnecessarily strict for this use case. Rejected.
---
## Decision 5 — `AuthProvider` interface evolution
**Decision**: Evolve `get_identity()` to accept a single optional string
argument: `async def get_identity(self, authorization: str | None) -> Identity`.
`NoOpAuthProvider` ignores the argument and returns the anonymous identity as
before. `JWTAuthProvider` parses `"Bearer <token>"`, validates the JWT, and
returns a non-anonymous `Identity`, or raises a `401` via `HTTPException` if
the token is invalid or expired.
A new `require_auth` dependency in `dependencies.py` calls
`auth.get_identity(authorization_header)` and raises `401` if the returned
identity is anonymous. Protected routes inject `Depends(require_auth)`.
Public routes continue to bypass auth entirely — they neither inject auth nor
call `get_identity`.
**Rationale**: Minimal interface change that preserves backward compatibility
(`NoOpAuthProvider` continues to work unchanged) while allowing the JWT
provider to access the request header cleanly through FastAPI's `Header`
dependency. An alternative would be injecting `Request` directly into the
provider, but that couples the provider to the ASGI framework; a string header
value keeps the provider framework-agnostic.
**Alternatives considered**: Pass `Request` to `get_identity()` — couples the
provider to FastAPI/ASGI. Rejected. Create a separate `validate_token(token)`
method — more interface surface, no clear benefit over the chosen approach.
Rejected.
---
## Decision 6 — Token storage in the browser
**Decision**: `sessionStorage` — tokens are discarded when the browser tab is
closed.
**Rationale**: `localStorage` persists across browser sessions and is
accessible to any JavaScript on the page, making it a wider XSS target.
`sessionStorage` is scoped to the tab and cleared on close, giving better
security for a shared or semi-public machine. For a personal app used by the
owner on their own machine, the loss of persistence across browser restarts is
a minor inconvenience that is well worth the security improvement.
`HttpOnly` cookies would be more secure still but require CSRF protection and
server-side session management, which conflicts with the stateless JWT design.
**Alternatives considered**: `localStorage` — persistent but wider XSS
exposure. Rejected. `HttpOnly` cookie — strongest XSS protection but requires
CSRF mitigation and session server state. Rejected.
---
## Decision 7 — Angular interceptor API
**Decision**: Functional HTTP interceptor registered via
`provideHttpClient(withInterceptors([authInterceptor]))` in `app.config.ts`.
The interceptor reads the token from `AuthService.getToken()` and adds
`Authorization: Bearer <token>` to every outbound request if a token is
present.
**Rationale**: Angular 17+ prefers functional interceptors over class-based
ones (`APP_INITIALIZER` / `HTTP_INTERCEPTORS` token). The functional pattern
integrates with standalone components and is the current idiomatic approach.
The interceptor attaches the token to all requests unconditionally (not just
protected endpoints) — the API ignores the header on public endpoints, so this
is safe and avoids the complexity of URL matching in the interceptor.
**Alternatives considered**: Class-based `HttpInterceptor` — legacy pattern,
not aligned with Angular 17+ standalone idiom. Rejected.
---
## Decision 8 — No database migration required
**Decision**: JWTs are stateless — no token storage or blocklist is introduced.
**Rationale**: The tokens carry their own expiry; the API validates them by
signature and expiry on each request. A token blocklist (for logout
invalidation) would require a database table and lookup on every protected
request, adding complexity disproportionate to the threat model of a
single-user local application. On logout, the Angular client discards the
token from `sessionStorage`; the token technically remains valid until its
`exp`, but since there is no other client that holds it, this is acceptable.
**Alternatives considered**: Token blocklist table — true server-side logout.
Out of scope for Phase 2; noted as a potential hardening step.
---
## Summary of new dependencies
| Package | Where | Purpose |
|---------|-------|---------|
| `PyJWT>=2.8` | `api/pyproject.toml` | JWT signing and verification |
No new UI dependencies — Angular's `HttpClient` and `Router` cover all
interceptor, guard, and HTTP needs.