Files
reactbin/specs/004-jwt-bearer-auth/plan.md
agatha 5fbbc1e67f Feat: Implement JWT bearer token authentication
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>
2026-05-03 19:12:38 +00:00

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.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) -> boolsecrets.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_headerauthorization=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:
    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(): voidsessionStorage.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.