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>
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])).