Files
reactbin/specs/004-jwt-bearer-auth/data-model.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

6.1 KiB

Data Model: JWT Bearer Token Authentication

Feature: 004-jwt-bearer-auth | Date: 2026-05-03


Database Changes

None. JWTs are stateless bearer tokens. The API validates them by cryptographic signature and embedded expiry claim on each request. No token storage, session table, or blocklist is introduced in Phase 2.


Configuration Schema (new env vars)

Four new environment variables are added to api/app/config.py and .env.example.

Variable Type Required Default Description
JWT_SECRET_KEY str Yes HMAC-SHA256 signing secret. Must be a long random string; no default (startup fails if absent).
JWT_EXPIRY_SECONDS int No 86400 Token lifetime in seconds (24 h).
OWNER_USERNAME str Yes Login username for the single owner account.
OWNER_PASSWORD str Yes Login password for the single owner account.

These values are loaded via pydantic-settings (BaseSettings) alongside the existing database and S3 settings. JWT_SECRET_KEY, OWNER_USERNAME, and OWNER_PASSWORD have no defaults and will raise a validation error at startup if absent, providing a clear "misconfigured" failure rather than a silent security hole.


Token Structure

A JWT issued by the login endpoint carries the following claims.

Claim Type Value
sub string "owner" — fixed identifier for the single owner
iat integer Unix epoch seconds at time of issuance
exp integer iat + JWT_EXPIRY_SECONDS

Algorithm: HS256 (HMAC-SHA256). Secret: JWT_SECRET_KEY setting.

The token is opaque to the client. The Angular SPA stores it in sessionStorage and transmits it as Authorization: Bearer <token> on every request.


Module and Interface Changes

api/app/auth/provider.py — updated interface

The get_identity() method gains an authorization parameter — the raw value of the Authorization HTTP header (or None if the header is absent).

AuthProvider (abstract)
  get_identity(authorization: str | None) -> Identity

Identity (dataclass)
  id: str
  anonymous: bool = True

api/app/auth/noop.py — no behavioural change

NoOpAuthProvider.get_identity() continues to return the static anonymous identity regardless of the authorization argument. The signature is updated to match the new interface.

api/app/auth/jwt_provider.py — new module

JWTAuthProvider (AuthProvider)
  __init__(secret_key: str, expiry_seconds: int, owner_username: str, owner_password: str)
  
  get_identity(authorization: str | None) -> Identity
    - Parses "Bearer <token>" from authorization header
    - Decodes and validates the JWT (signature + exp)
    - Returns Identity(id="owner", anonymous=False) on success
    - Raises HTTPException 401 with code "unauthorized" on any failure

  create_token() -> str
    - Mints a new HS256 JWT with sub="owner", iat=now, exp=now+expiry_seconds
    - Returns the encoded token string
  
  verify_credentials(username: str, password: str) -> bool
    - Compares username and password against OWNER_USERNAME / OWNER_PASSWORD
    - Uses secrets.compare_digest to prevent timing attacks
    - Returns True on match, False otherwise

api/app/dependencies.py — new require_auth dependency

require_auth(
  authorization: str | None = Header(None, alias="Authorization"),
  auth: AuthProvider = Depends(get_auth)
) -> Identity
  - Calls auth.get_identity(authorization)
  - Raises HTTPException 401 if identity.anonymous is True
  - Returns the Identity on success

Protected routes inject identity: Identity = Depends(require_auth) and do not need to perform any additional auth checks — the dependency raises before the route body executes if authentication fails.

api/app/routers/auth.py — new router

POST /api/v1/auth/token
  Request body: LoginRequest { username: str, password: str }
  Success (200): TokenResponse { access_token: str, token_type: "bearer", expires_in: int }
  Failure (401): { detail: "Invalid credentials", code: "invalid_credentials" }

Angular Module Changes

ui/src/app/auth/auth.service.ts — new service

AuthService
  TOKEN_KEY = 'auth_token'            (sessionStorage key)

  login(username: string, password: string): Observable<void>
    - POST /api/v1/auth/token
    - On success: stores access_token in sessionStorage, emits completion
    - On 401: propagates error for the component to handle

  logout(): void
    - Removes token from sessionStorage

  getToken(): string | null
    - Returns stored token or null

  isAuthenticated(): boolean
    - Returns true if getToken() is non-null

ui/src/app/auth/auth.interceptor.ts — new functional interceptor

Attaches Authorization: Bearer <token> to every outbound HttpRequest if AuthService.getToken() returns a non-null value. Requests without a token are passed through unmodified.

ui/src/app/auth/auth.guard.ts — new route guard

Functional CanActivateFn. If AuthService.isAuthenticated() is false, redirects to /login?returnUrl=<current-url>. Otherwise allows navigation.

ui/src/app/login/login.component.ts — new component

Route: /login

LoginComponent
  Fields: username (required), password (required)
  On submit:
    - Calls AuthService.login()
    - On success: navigates to returnUrl query param, or '/' if absent
    - On 401: displays inline error "Invalid username or password"
    - On other error: displays generic error message
  Shows loading state while request is in flight

ui/src/app/detail/detail.component.ts — updated

Injects AuthService. Hides tag-edit input and delete button when auth.isAuthenticated() is false. Shows them when authenticated. Read-only view (image display, tag chips) is always visible.

ui/src/app/app.routes.ts — updated

/upload route gains canActivate: [authGuard]. /login route is added (unguarded). All other routes are unchanged.

ui/src/app/app.config.ts — updated

provideHttpClient() becomes provideHttpClient(withInterceptors([authInterceptor])).