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:
185
specs/004-jwt-bearer-auth/research.md
Normal file
185
specs/004-jwt-bearer-auth/research.md
Normal 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.
|
||||
Reference in New Issue
Block a user