# 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 "`, 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` — 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 ` 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 ` 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`.