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>
356 lines
17 KiB
Markdown
356 lines
17 KiB
Markdown
# 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`.
|