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>
8.1 KiB
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.