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>
17 KiB
Implementation Plan: JWT Bearer Token Authentication
Branch: 004-jwt-bearer-auth | Date: 2026-05-03 | Spec: 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)
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
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.8to[project.dependencies]inapi/pyproject.toml - Update
api/app/config.py:jwt_secret_key: str(required — no default; validated by pydantic)jwt_expiry_seconds: int = 86400owner_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.pywithJWTAuthProvider:create_token() -> str— mint HS256 JWT withsub="owner",iat,expverify_credentials(username, password) -> bool—secrets.compare_digestget_identity(authorization) -> Identity— parse"Bearer <token>", decode JWT, returnIdentity(id="owner", anonymous=False)on success, or raiseHTTPException(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 errortest_get_identity_returns_owner— valid token → non-anonymous Identitytest_get_identity_raises_on_expired_token— token with pastexp→ 401test_get_identity_raises_on_wrong_key— token signed with different key → 401test_get_identity_raises_on_garbage— random string as token → 401test_get_identity_raises_on_missing_header—authorization=None→ 401test_get_identity_raises_on_missing_bearer_prefix—"token-without-prefix"→ 401test_verify_credentials_true— correct username + password → Truetest_verify_credentials_false_wrong_password— wrong password → Falsetest_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:LoginRequestPydantic model:username: str,password: strPOST /auth/tokenroute: callauth_provider.verify_credentials(); on success callauth_provider.create_token()and returnTokenResponse{access_token, token_type="bearer", expires_in}; on failure raise401 invalid_credentials
- Update
api/app/dependencies.py: instantiateJWTAuthProvider(reading settings) instead ofNoOpAuthProvideringet_auth() - Update
api/app/main.py: registerauth.routerunder/api/v1
Integration tests in api/tests/integration/test_auth.py (write first):
test_login_success— POST valid creds → 200, response containsaccess_token(non-empty string),token_type="bearer",expires_in > 0test_login_wrong_password— correct username, wrong password → 401, codeinvalid_credentialstest_login_wrong_username— wrong username → 401, codeinvalid_credentialstest_login_missing_password— body{"username": "x"}→ 422test_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_authtoapi/app/dependencies.py: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)parameterdelete_image(): add_: Identity = Depends(require_auth)parameterupdate_image_tags(): add_: Identity = Depends(require_auth)parameter- Remove the now-redundant
auth: AuthProvider = Depends(get_auth)fromupload_image()(it was injected but never called;require_authsubsumes 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 withoutAuthorization→ 401, codeunauthorizedtest_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 → 401test_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 → 401test_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} → 200test_serve_file_without_token_is_200— GET /images/{id}/file → 200test_serve_thumbnail_without_token_is_200— GET /images/{id}/thumbnail → 200test_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, storeaccess_tokeninsessionStorageon successlogout(): void—sessionStorage.removeItem(TOKEN_KEY)getToken(): string | nullisAuthenticated(): boolean
ui/src/app/auth/auth.interceptor.ts (functional interceptor):
- If
AuthService.getToken()returns non-null, clone request withAuthorization: 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) andpassword(required) controls onSubmit(): callsAuthService.login(); on success navigates toreturnUrlquery 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/uploadroute
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, verifysessionStoragehas token after logintest_logout_clears_token— store a token, logout, verifysessionStorageemptytest_isAuthenticated_true_when_token_present— set token, assert truetest_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 hasAuthorization: Bearer <token>headertest_no_auth_header_when_not_authenticated— unauthenticated, outbound request has noAuthorizationheader
ui/src/app/login/login.component.spec.ts:
test_submit_calls_auth_service_login— spy onAuthService.login, submit form, verify called with correct username/passwordtest_navigates_on_success— mock successful login, verify router navigate calledtest_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: addJWT_SECRET_KEY,JWT_EXPIRY_SECONDS,OWNER_USERNAME,OWNER_PASSWORDwith 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.