# Implementation Plan: JWT Bearer Token Authentication

**Branch**: `004-jwt-bearer-auth` | **Date**: 2026-05-03 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `specs/004-jwt-bearer-auth/spec.md`

## Summary

Implement Phase 2 of the progressive auth plan (constitution §2.4): replace
the no-op `AuthProvider` with a `JWTAuthProvider` that issues and validates
HS256 bearer tokens. Upload, delete, and tag-update endpoints become
protected; all read endpoints stay public. The Angular SPA gains a login page,
a session-scoped token store, an HTTP interceptor, and a route guard for the
upload page.

No database migration is required — tokens are stateless. A single new PyJWT
dependency is added to the API. The `AuthProvider` interface gains a parameter
so the JWT provider can access the `Authorization` header; `NoOpAuthProvider`
is updated to match but its behaviour is unchanged.

Changes span: `api/app/config.py` (4 new settings), `api/app/auth/provider.py`
(interface update), `api/app/auth/jwt_provider.py` (new), `api/app/routers/auth.py`
(new), `api/app/dependencies.py` (new `require_auth` dependency), three route
updates in `api/app/routers/images.py`, and on the UI side: a new `AuthService`,
`AuthInterceptor`, `AuthGuard`, and `LoginComponent`, plus small updates to
`app.routes.ts`, `app.config.ts`, and `detail.component.ts`.

## Technical Context

**Language/Version**: Python 3.12+ (API); TypeScript strict mode (UI)
**Primary Dependencies**: FastAPI, PyJWT (new), pydantic-settings, Angular
**Storage**: PostgreSQL (no schema changes); S3-compatible (no changes)
**Testing**: pytest + pytest-asyncio (API); Angular Karma/Jest + TestBed (UI)
**Target Platform**: Linux server (containerised); modern evergreen desktop browsers
**Project Type**: Web application — FastAPI API + Angular SPA
**Performance Goals**: Login round-trip under 15 s on local network (well within
  reach; no database lookup, only in-memory credential comparison + JWT signing)
**Constraints**: Stateless tokens — no server-side session storage; single owner
  account; no token revocation in v1
**Scale/Scope**: Single-user personal application; auth is a deployment-time
  configuration concern, not a runtime management concern

## Constitution Check

*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design below.*

| Principle | Check | Status |
|---|---|---|
| §2.1 Separation of concerns | JWT logic lives only in `JWTAuthProvider`; routes orchestrate; UI knows nothing about signing | ✅ |
| §2.2 Dependency direction | UI → API only; no upward imports introduced | ✅ |
| §2.3 Storage abstraction | No change to storage layer | ✅ |
| §2.4 Auth abstraction | `JWTAuthProvider` is a second `AuthProvider` implementation — exactly the pattern §2.4 designed for | ✅ |
| §2.5 DB abstraction | No DB changes; stateless JWTs require no session table | ✅ |
| §2.6 No speculative abstraction | No new interfaces; only a second concrete implementation of an already-planned interface | ✅ |
| §3.1 API versioning | New route at `/api/v1/auth/token` | ✅ |
| §3.3 Error shape | `401` uses `{"detail": "...", "code": "unauthorized"}` / `"invalid_credentials"` | ✅ |
| §5.1 TDD non-negotiable | Failing tests written before every implementation task | ✅ |
| §5.2 Test pyramid | Unit tests for JWT logic; integration tests for all changed routes | ✅ |
| §5.3 Test colocation | API tests in `api/tests/`; Angular specs colocated with components | ✅ |
| §5.4 CI gate | All tests + ruff must pass before milestone is done | ✅ |
| §7.1 One-command start | No change to `docker-compose.yml` required | ✅ |
| §7.2 Env configuration | `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`, `OWNER_USERNAME`, `OWNER_PASSWORD` added as env vars | ✅ |
| §8 Scope boundaries | §8 lists "Username/password auth (planned Phase 2)" as deferred. This feature IS Phase 2; the deferral is now lifted. All other §8 items remain deferred. | ✅ |

**Post-design re-check**: All gates still pass after Phase 1 design.

## Project Structure

### Documentation (this feature)

```text
specs/004-jwt-bearer-auth/
├── plan.md              # This file
├── spec.md              # Feature specification
├── research.md          # Phase 0 decisions
├── data-model.md        # Module and interface changes
├── contracts/
│   └── api.md           # New endpoint + changed access control
├── checklists/
│   └── requirements.md  # Spec quality checklist
└── tasks.md             # Phase 2 output (/speckit-tasks — NOT created here)
```

### Files changed or created

```text
api/
├── pyproject.toml                              # Add PyJWT dependency
├── app/
│   ├── config.py                              # Add 4 new settings
│   ├── dependencies.py                        # Add require_auth dependency
│   ├── auth/
│   │   ├── provider.py                        # get_identity(authorization) signature
│   │   ├── noop.py                            # Updated signature, same behaviour
│   │   └── jwt_provider.py                    # NEW — JWTAuthProvider
│   └── routers/
│       ├── auth.py                            # NEW — POST /auth/token
│       └── images.py                          # Protect upload, delete, patch-tags
├── main.py                                    # Register auth router
└── .env.example                               # Add 4 new vars

ui/
└── src/
    └── app/
        ├── auth/
        │   ├── auth.service.ts                # NEW
        │   ├── auth.service.spec.ts           # NEW
        │   ├── auth.interceptor.ts            # NEW
        │   ├── auth.interceptor.spec.ts       # NEW
        │   └── auth.guard.ts                  # NEW
        ├── login/
        │   ├── login.component.ts             # NEW
        │   ├── login.component.html           # NEW
        │   └── login.component.spec.ts        # NEW
        ├── detail/
        │   └── detail.component.ts            # Conditionally show edit/delete
        ├── app.routes.ts                      # Add /login; guard /upload
        ├── app.config.ts                      # Register authInterceptor
        └── app.component.ts                   # Add logout button (visible when authenticated)
```

## Milestones

> **TDD ORDER IS MANDATORY** (constitution §5.1): For every milestone, write
> the failing test(s) first, confirm they fail, then implement until they pass.

---

### M1 — JWT provider: token signing and validation

**Goal**: A tested `JWTAuthProvider` that can mint tokens and validate bearer
tokens from an `Authorization` header. The `AuthProvider` interface is updated;
`NoOpAuthProvider` is kept compatible.

**Deliverables**:
- Add `PyJWT>=2.8` to `[project.dependencies]` in `api/pyproject.toml`
- Update `api/app/config.py`:
  - `jwt_secret_key: str` (required — no default; validated by pydantic)
  - `jwt_expiry_seconds: int = 86400`
  - `owner_username: str` (required)
  - `owner_password: str` (required)
- Update `api/app/auth/provider.py`: `get_identity(self, authorization: str | None) -> Identity`
- Update `api/app/auth/noop.py`: match new signature; behaviour unchanged
- Create `api/app/auth/jwt_provider.py` with `JWTAuthProvider`:
  - `create_token() -> str` — mint HS256 JWT with `sub="owner"`, `iat`, `exp`
  - `verify_credentials(username, password) -> bool` — `secrets.compare_digest`
  - `get_identity(authorization) -> Identity` — parse `"Bearer <token>"`,
    decode JWT, return `Identity(id="owner", anonymous=False)` on success, or
    raise `HTTPException(401, {"detail": "...", "code": "unauthorized"})` on
    any failure (missing header, invalid format, bad signature, expired)

**Unit tests** in `api/tests/unit/test_jwt_auth.py` (write first, confirm fail):
- `test_create_token_is_valid_jwt` — minted token decodes with PyJWT without error
- `test_get_identity_returns_owner` — valid token → non-anonymous Identity
- `test_get_identity_raises_on_expired_token` — token with past `exp` → 401
- `test_get_identity_raises_on_wrong_key` — token signed with different key → 401
- `test_get_identity_raises_on_garbage` — random string as token → 401
- `test_get_identity_raises_on_missing_header` — `authorization=None` → 401
- `test_get_identity_raises_on_missing_bearer_prefix` — `"token-without-prefix"` → 401
- `test_verify_credentials_true` — correct username + password → True
- `test_verify_credentials_false_wrong_password` — wrong password → False
- `test_verify_credentials_false_wrong_username` — wrong username → False

**Done criterion**: All 10 unit tests pass; `ruff check api/` passes; existing
tests unaffected.

---

### M2 — Login endpoint

**Goal**: `POST /api/v1/auth/token` issues a token for valid credentials and
rejects invalid ones.

**Deliverables**:
- Create `api/app/routers/auth.py`:
  - `LoginRequest` Pydantic model: `username: str`, `password: str`
  - `POST /auth/token` route: call `auth_provider.verify_credentials()`; on
    success call `auth_provider.create_token()` and return `TokenResponse`
    `{access_token, token_type="bearer", expires_in}`; on failure raise
    `401 invalid_credentials`
- Update `api/app/dependencies.py`: instantiate `JWTAuthProvider` (reading
  settings) instead of `NoOpAuthProvider` in `get_auth()`
- Update `api/app/main.py`: register `auth.router` under `/api/v1`

**Integration tests** in `api/tests/integration/test_auth.py` (write first):
- `test_login_success` — POST valid creds → 200, response contains
  `access_token` (non-empty string), `token_type="bearer"`, `expires_in > 0`
- `test_login_wrong_password` — correct username, wrong password → 401,
  code `invalid_credentials`
- `test_login_wrong_username` — wrong username → 401, code `invalid_credentials`
- `test_login_missing_password` — body `{"username": "x"}` → 422
- `test_login_missing_username` — body `{"password": "x"}` → 422

**Test infrastructure note**: The integration test `conftest.py` currently
overrides `get_auth` with `NoOpAuthProvider`. Tests for the auth endpoint
need to override with a test `JWTAuthProvider` (a real provider with test
credentials). Add a `jwt_auth_provider` fixture and an `authed_client` fixture
(with a bearer token) to `conftest.py` for use in M3.

**Done criterion**: All 5 new tests pass; all existing tests pass (the
`NoOpAuthProvider` override in `conftest.py` means existing tests are
unaffected by switching the production `get_auth()` to `JWTAuthProvider`).

---

### M3 — Protected endpoints

**Goal**: Upload, delete, and patch-tags reject unauthenticated requests.
All public endpoints remain accessible without a token.

**Deliverables**:
- Add `require_auth` to `api/app/dependencies.py`:
  ```python
  async def require_auth(
      authorization: str | None = Header(None, alias="Authorization"),
      auth: AuthProvider = Depends(get_auth),
  ) -> Identity:
      identity = await auth.get_identity(authorization)
      if identity.anonymous:
          raise HTTPException(
              status_code=401,
              detail={"detail": "Authentication required", "code": "unauthorized"},
          )
      return identity
  ```
- In `api/app/routers/images.py`:
  - `upload_image()`: add `_: Identity = Depends(require_auth)` parameter
  - `delete_image()`: add `_: Identity = Depends(require_auth)` parameter
  - `update_image_tags()`: add `_: Identity = Depends(require_auth)` parameter
  - Remove the now-redundant `auth: AuthProvider = Depends(get_auth)` from
    `upload_image()` (it was injected but never called; `require_auth` subsumes it)

**Integration tests** — add to existing test files (write failing tests first):

In `api/tests/integration/test_upload.py`:
- `test_upload_without_token_returns_401` — POST without `Authorization` → 401, code `unauthorized`
- `test_upload_with_valid_token_succeeds` — POST with valid bearer token → 200/201

In `api/tests/integration/test_delete.py`:
- `test_delete_without_token_returns_401` — DELETE without token → 401
- `test_delete_with_valid_token_succeeds` — DELETE with valid token → 204

In `api/tests/integration/test_serving.py` (tag update lives here conceptually
but the route is in images; add to `test_upload.py` or a new `test_tags.py`):
- `test_patch_tags_without_token_returns_401` — PATCH without token → 401
- `test_patch_tags_with_valid_token_succeeds` — PATCH with valid token → 200

Public endpoint regression tests (confirm no 401 regression):
- `test_list_images_without_token_is_200` — GET /images → 200 (no auth)
- `test_get_image_without_token_is_200` — GET /images/{id} → 200
- `test_serve_file_without_token_is_200` — GET /images/{id}/file → 200
- `test_serve_thumbnail_without_token_is_200` — GET /images/{id}/thumbnail → 200
- `test_list_tags_without_token_is_200` — GET /tags → 200

**conftest.py update**: The `client` fixture already overrides `get_auth` with
`NoOpAuthProvider`, so all existing tests (which do not send tokens) continue
to pass without modification. The new `authed_client` fixture (from M2) uses
a `JWTAuthProvider` override and injects a valid token via the `Authorization`
header.

**Done criterion**: All new tests pass; all existing tests continue to pass.

---

### M4 — UI: `AuthService`, `AuthInterceptor`, `AuthGuard`, `LoginComponent`

**Goal**: Angular has a working login flow. The upload page is protected.
The detail page shows/hides write controls based on auth state.

**Deliverables**:

`ui/src/app/auth/auth.service.ts`:
- `TOKEN_KEY = 'auth_token'`
- `login(username, password): Observable<void>` — POST to `/api/v1/auth/token`,
  store `access_token` in `sessionStorage` on success
- `logout(): void` — `sessionStorage.removeItem(TOKEN_KEY)`
- `getToken(): string | null`
- `isAuthenticated(): boolean`

`ui/src/app/auth/auth.interceptor.ts` (functional interceptor):
- If `AuthService.getToken()` returns non-null, clone request with
  `Authorization: Bearer <token>` header; otherwise pass through

`ui/src/app/auth/auth.guard.ts` (`CanActivateFn`):
- If not authenticated: `router.createUrlTree(['/login'], {queryParams: {returnUrl: state.url}})`
- If authenticated: `true`

`ui/src/app/login/login.component.ts`:
- Reactive form with `username` (required) and `password` (required) controls
- `onSubmit()`: calls `AuthService.login()`; on success navigates to `returnUrl`
  query param (default `/`); on error displays inline "Invalid username or password"
- Loading state while request is in flight; button disabled during loading

`ui/src/app/detail/detail.component.ts` (update):
- Inject `AuthService`
- In template: `*ngIf="auth.isAuthenticated()"` wraps the tag-edit input and
  the delete button

`ui/src/app/app.routes.ts` (update):
- Add `{ path: 'login', loadComponent: () => import('./login/login.component').then(...) }`
- Add `canActivate: [authGuard]` to the `/upload` route

`ui/src/app/app.config.ts` (update):
- `provideHttpClient(withInterceptors([authInterceptor]))`

**Angular unit tests** (write first, confirm fail):

`ui/src/app/auth/auth.service.spec.ts`:
- `test_login_stores_token` — mock HTTP, verify `sessionStorage` has token after login
- `test_logout_clears_token` — store a token, logout, verify `sessionStorage` empty
- `test_isAuthenticated_true_when_token_present` — set token, assert true
- `test_isAuthenticated_false_when_no_token` — clear sessionStorage, assert false

`ui/src/app/auth/auth.interceptor.spec.ts`:
- `test_adds_auth_header_when_authenticated` — authenticated state, outbound
  request has `Authorization: Bearer <token>` header
- `test_no_auth_header_when_not_authenticated` — unauthenticated, outbound
  request has no `Authorization` header

`ui/src/app/login/login.component.spec.ts`:
- `test_submit_calls_auth_service_login` — spy on `AuthService.login`, submit
  form, verify called with correct username/password
- `test_navigates_on_success` — mock successful login, verify router navigate called
- `test_shows_error_on_failure` — mock 401, verify error message visible

**Done criterion**: Angular build clean; all Angular tests pass.

---

### M5 — `.env.example` update and final validation

**Goal**: New env vars documented; full test suite green.

**Deliverables**:
- Update `.env.example`: add `JWT_SECRET_KEY`, `JWT_EXPIRY_SECONDS`,
  `OWNER_USERNAME`, `OWNER_PASSWORD` with example values and comments
- Run `pytest api/ -v` — confirm all tests pass (expected: existing ~57 tests
  + ~20 new tests ≈ 77 total)
- Run `ruff check api/ && ruff format --check api/` — zero violations
- Run `ng test` (inside UI container) — all Angular tests pass
- Run `ng build` — Angular build succeeds

**Done criterion**: All tests pass; both linters pass; `docker compose up`
starts the full stack and the login flow works end-to-end in the browser.

## Post-design Constitution Re-check

| Principle | Verdict |
|---|---|
| §2.4 Auth abstraction | `JWTAuthProvider` is a drop-in second implementation; business logic in routes is unchanged except for the added `require_auth` dependency | ✅ |
| §2.6 No speculative abstraction | No new interfaces; `JWTAuthProvider` is concrete and implements an already-planned interface | ✅ |
| §3.3 Error shape | `401` envelope uses `code` field throughout | ✅ |
| §5.1 TDD | Failing tests precede every implementation milestone | ✅ |
| §7.2 Env config | All four new settings come from env vars; no hardcoded credentials | ✅ |

All gates pass. Feature is ready for `/speckit-tasks`.
