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>
186 lines
8.1 KiB
Markdown
186 lines
8.1 KiB
Markdown
# 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.
|