Files
reactbin/specs/004-jwt-bearer-auth/research.md
agatha 5fbbc1e67f 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>
2026-05-03 19:12:38 +00:00

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.