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