# 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 ` 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 " 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 - 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 ` 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=`. 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]))`.