Files
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

28 KiB
Raw Permalink Blame History

Tasks: JWT Bearer Token Authentication

Input: Design documents from specs/004-jwt-bearer-auth/ Prerequisites: plan.md , spec.md , research.md , data-model.md , contracts/api.md

TDD: Tests are non-negotiable per constitution §5.1. Every test task MUST be written and confirmed failing before its implementation task runs.

Organization: Tasks follow user story priority order (US1 P1 → US2 P1 → US3 P1 → US4 P2). API milestones run first in each story, then Angular.

Format: [ID] [P?] [Story] Description

  • [P]: Can run in parallel (different files, no dependencies on incomplete tasks)
  • [Story]: Which user story this task belongs to (US1US4)
  • Include exact file paths in descriptions

Path Conventions

api/app/              API source
api/tests/unit/       API unit tests
api/tests/integration/ API integration tests
ui/src/app/           Angular source
ui/src/app/auth/      New auth module
ui/src/app/login/     New login component

Phase 1: Setup (Shared Infrastructure)

Purpose: New dependency, updated config, updated interfaces, and test fixtures that all user stories depend on. No user story work can begin until this is complete.

⚠️ CRITICAL: Complete all setup tasks before starting any user story phase.

  • T001 Add PyJWT>=2.8 to [project.dependencies] in api/pyproject.toml; rebuild the Docker API image (docker compose build api) so PyJWT is available inside the container for all subsequent test runs

  • T002 Add four new settings to api/app/config.py (pydantic-settings BaseSettings): jwt_secret_key: str (required — no default, startup fails if absent), jwt_expiry_seconds: int = 86400, owner_username: str (required), owner_password: str (required); confirm get_settings() still loads from env vars via the existing SettingsConfigDict

  • T003 [P] Update api/app/auth/provider.py: change get_identity(self) to get_identity(self, authorization: str | None) -> Identity; this is a breaking interface change that will cause the NoOpAuthProvider to fail type-checking until T004 is done

  • T004 [P] Update api/app/auth/noop.py: match the new get_identity(self, authorization: str | None) -> Identity signature; the implementation still returns _ANONYMOUS and ignores authorization; run pytest api/ to confirm all existing tests still pass (the conftest overrides get_auth so the interface change is invisible to running tests)

  • T005 Update api/tests/integration/conftest.py: add a # TODO: complete after T007 comment block where the jwt_auth_provider and authed_client fixtures will live — do not add the import of JWTAuthProvider yet (it does not exist until T007 and would break pytest api/ if imported now); the existing client fixture (with NoOpAuthProvider) must remain unchanged. After T007 is done, complete this task: add jwt_auth_provider fixture that constructs a JWTAuthProvider with test credentials, and authed_client fixture that overrides get_auth with that provider and yields (client, valid_token) where valid_token = auth.create_token()

Checkpoint: PyJWT installed, four new settings wired, interface updated, NoOpAuthProvider adapted, conftest ready. All existing tests still pass.


Phase 2: Foundational (JWTAuthProvider — Blocks All Stories)

Purpose: The JWTAuthProvider must exist before the login endpoint (US1), protected endpoints (US2), or public-read regression tests (US3) can be built.

⚠️ CRITICAL: All tasks in this phase must complete before any user story work begins.

Tests for JWTAuthProvider (write FIRST — must FAIL before T007) ⚠️

  • T006 Write 10 unit tests in api/tests/unit/test_jwt_auth.py for the (not-yet-existing) JWTAuthProvider: test_create_token_is_valid_jwt (minted token decodes with PyJWT without error), test_get_identity_returns_owner (valid bearer token → non-anonymous Identity with id="owner"), test_get_identity_raises_on_expired_token (token with past expHTTPException 401), test_get_identity_raises_on_wrong_key (token signed with different secret → 401), test_get_identity_raises_on_garbage (random string as token value → 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 (matching username + password → True), test_verify_credentials_false_wrong_password (wrong password → False), test_verify_credentials_false_wrong_username (wrong username → False); run pytest api/tests/unit/test_jwt_auth.py and confirm all 10 fail with ImportError or AttributeError (not-yet-implemented)

JWTAuthProvider implementation

  • T007 Create api/app/auth/jwt_provider.py with JWTAuthProvider(AuthProvider): constructor takes secret_key: str, expiry_seconds: int, owner_username: str, owner_password: str; implement create_token() -> str using jwt.encode({"sub": "owner", "iat": now, "exp": now + expiry_seconds}, secret_key, algorithm="HS256"); implement verify_credentials(username, password) -> bool using secrets.compare_digest; implement get_identity(authorization: str | None) -> Identity — parse "Bearer <token>" (raise 401 unauthorized if missing or wrong prefix), decode with jwt.decode() (raise 401 on ExpiredSignatureError, InvalidTokenError, or any exception), return Identity(id="owner", anonymous=False) on success; run pytest api/tests/unit/test_jwt_auth.py and confirm all 10 pass; then run pytest api/ to confirm no regressions

Checkpoint: JWTAuthProvider is fully implemented and tested. Login endpoint, protected-endpoint guard, and conftest fixtures can now be built.


Phase 3: User Story 1 — Log In and Receive a Token (Priority: P1) 🎯 MVP

Goal: The owner can log in with username and password and receive a bearer token. The Angular SPA has a working login page backed by a real API endpoint.

Independent Test: POST {"username": "owner", "password": "correct"} to /api/v1/auth/token. Confirm a 200 response with a non-empty access_token. Then open the browser, enter credentials in the login form, and confirm navigation to the library. Subsequent protected requests in the browser include the token.

Tests for User Story 1 API (write FIRST — must FAIL before T009) ⚠️

  • T008 [US1] Write 5 integration tests in api/tests/integration/test_auth.py for the (not-yet-existing) POST /api/v1/auth/token endpoint: test_login_success (POST valid creds → 200, response has access_token as 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); these tests should use a fixture with the JWTAuthProvider override; run pytest api/tests/integration/test_auth.py and confirm all 5 fail with 404 (route not yet registered)

Implementation for User Story 1 (API)

  • T009 [US1] In api/app/dependencies.py, add get_jwt_auth() -> JWTAuthProvider — a typed dependency that returns the same JWTAuthProvider instance as get_auth() but with the concrete type, so the auth router can call verify_credentials() and create_token() without a downcast (the login endpoint is inherently tied to token issuance and is replaced wholesale in Phase 3, so it is correct for it to depend on the concrete type rather than the AuthProvider abstraction). Then create api/app/routers/auth.py: define LoginRequest Pydantic model (username: str, password: str), define TokenResponse Pydantic model (access_token: str, token_type: str = "bearer", expires_in: int), add POST /auth/token route that injects auth: JWTAuthProvider = Depends(get_jwt_auth) — calls auth.verify_credentials(username, password), raises HTTPException(401, {"detail": "Invalid credentials", "code": "invalid_credentials"}) on failure, calls auth.create_token() and returns TokenResponse on success; complete T005's conftest.py jwt_auth_provider fixture import now that the module exists

  • T010 [US1] Update api/app/dependencies.py: in get_auth(), replace NoOpAuthProvider() with JWTAuthProvider(secret_key=s.jwt_secret_key, expiry_seconds=s.jwt_expiry_seconds, owner_username=s.owner_username, owner_password=s.owner_password) (loading settings via get_settings()); the existing client fixture in conftest.py still overrides get_auth with NoOpAuthProvider, so all existing tests remain unaffected

  • T011 [US1] Update api/app/main.py: import auth router from app.routers.auth and register it with app.include_router(auth.router, prefix="/api/v1"); run pytest api/tests/integration/test_auth.py and confirm all 5 tests pass; run pytest api/ and confirm no regressions

Tests for User Story 1 (Angular — write FIRST — must FAIL before T014) ⚠️

  • T012 [P] [US1] Write 3 unit tests in ui/src/app/auth/auth.service.spec.ts for the (not-yet-existing) AuthService: test_login_stores_token (mock HttpClient POST returning {access_token: "tok"}, verify sessionStorage.getItem("auth_token") === "tok" after login() completes), test_isAuthenticated_true_when_token_present (set token in sessionStorage, assert isAuthenticated() returns true), test_isAuthenticated_false_when_no_token (clear sessionStorage, assert isAuthenticated() returns false); run ng test and confirm all 3 fail with Cannot find module or similar. Note: logout tests belong to US4 and are written in T025.

  • T013 [P] [US1] Write 4 unit tests in ui/src/app/login/login.component.spec.ts for the (not-yet-existing) LoginComponent: test_submit_calls_auth_service_login (spy on AuthService.login, fill form, submit, verify login called with correct username and password), test_navigates_to_library_on_success (mock AuthService.login returning of(void 0), submit, verify Router.navigate called with ['/']), test_shows_error_on_401 (mock AuthService.login throwing HttpErrorResponse with status 401, submit, verify error message element is visible in the template), test_shows_validation_error_on_empty_fields (disable browser-native validation via novalidate, leave username and password blank, click submit, verify no HttpClient.post call was made and a validation error element is visible in the DOM); run ng test and confirm all 4 fail

Implementation for User Story 1 (Angular)

  • T014 [P] [US1] Create ui/src/app/auth/auth.service.ts: TOKEN_KEY = 'auth_token'; login(username: string, password: string): Observable<void> — POST /api/v1/auth/token, pipe tap(res => sessionStorage.setItem(this.TOKEN_KEY, res.access_token)), map to void; logout(): voidsessionStorage.removeItem(this.TOKEN_KEY); getToken(): string | nullsessionStorage.getItem(this.TOKEN_KEY); isAuthenticated(): booleanthis.getToken() !== null; decorate with @Injectable({ providedIn: 'root' })

  • T015 [P] [US1] Create ui/src/app/login/login.component.ts (standalone component, route /login): reactive form with username (required) and password (required) validators; onSubmit() calls AuthService.login(), sets loading = true while in-flight, on success reads returnUrl query param (default '/') and calls router.navigateByUrl(returnUrl), on error sets errorMessage = 'Invalid username or password'; template (login.component.html) includes a form with username input, password input, submit button (disabled while loading), and an error paragraph (*ngIf="errorMessage")

  • T016 [US1] Update ui/src/app/app.routes.ts: add { path: 'login', loadComponent: () => import('./login/login.component').then(m => m.LoginComponent) } before the wildcard route; run ng test and confirm T012 and T013 tests now pass; run ng build to confirm no build errors

Checkpoint: POST /api/v1/auth/token works end-to-end. Angular login form posts credentials, stores token, navigates to library. All 12 new tests (5 API + 3 Angular service + 4 Angular login) pass.


Phase 4: User Story 2 — Protected Write Actions Require Authentication (Priority: P1)

Goal: Upload, delete, and tag-update endpoints reject unauthenticated requests with a 401. Angular automatically attaches the stored token to all requests.

Independent Test: Without logging in, attempt POST /api/v1/images — confirm 401 with code unauthorized. Log in, then upload again — confirm 200/201. In the browser, verify that after login the upload form submits successfully.

Tests for User Story 2 API (write FIRST — must FAIL before T019) ⚠️

  • T017 [US2] Add 6 integration tests using the authed_client fixture across existing test files: in api/tests/integration/test_upload.py add test_upload_without_token_returns_401 (POST with no Authorization header → 401, code unauthorized) and test_upload_with_valid_token_succeeds (POST with Authorization: Bearer <token> → 200/201); in api/tests/integration/test_delete.py add test_delete_without_token_returns_401 (DELETE with no token → 401) and test_delete_with_valid_token_succeeds (DELETE with valid token → 204); add test_patch_tags_without_token_returns_401 (PATCH /images/{id}/tags with no token → 401) and test_patch_tags_with_valid_token_succeeds (PATCH with valid token → 200) to api/tests/integration/test_upload.py (or a new test_protected.py); run these 6 tests and confirm they all fail (currently return 200/204 without auth, or fixture not yet usable)

Implementation for User Story 2 (API)

  • T018 [US2] Add require_auth async dependency to api/app/dependencies.py: async def require_auth(authorization: str | None = Header(None, alias="Authorization"), auth: AuthProvider = Depends(get_auth)) -> Identity — calls await auth.get_identity(authorization), raises HTTPException(401, {"detail": "Authentication required", "code": "unauthorized"}) if identity.anonymous is True, otherwise returns Identity

  • T019 [US2] In api/app/routers/images.py: add _: Identity = Depends(require_auth) parameter to upload_image(), delete_image(), and update_image_tags(); also remove the existing auth: AuthProvider = Depends(get_auth) from upload_image() (it was injected but never called — require_auth now subsumes it); add from app.dependencies import require_auth and from app.auth.provider import Identity to imports; run pytest api/tests/integration/ and confirm all 6 new protected-endpoint tests pass and all pre-existing tests (which use the client fixture with NoOpAuthProvider override) still pass

Tests for User Story 2 (Angular — write FIRST — must FAIL before T021) ⚠️

  • T020 [US2] Write 3 unit tests in ui/src/app/auth/auth.interceptor.spec.ts for the (not-yet-existing) authInterceptor: test_adds_auth_header_when_authenticated (configure TestBed with authInterceptor, spy AuthService.getToken() returning "test-token", make any HTTP request via HttpClient, verify the outgoing request in HttpTestingController has header Authorization: Bearer test-token), test_no_auth_header_when_not_authenticated (spy AuthService.getToken() returning null, make HTTP request, verify Authorization header is absent), test_interceptor_redirects_to_login_on_401 (spy AuthService.getToken() returning "test-token", flush the HTTP response with status 401, spy AuthService.logout() and Router.navigate, verify logout() was called and router.navigate(['/login']) was called); run ng test and confirm all 3 fail

Implementation for User Story 2 (Angular)

  • T021 [US2] Create ui/src/app/auth/auth.interceptor.ts as a functional interceptor that handles both outbound token injection and inbound 401 responses: export const authInterceptor: HttpInterceptorFn = (req, next) => { const auth = inject(AuthService); const router = inject(Router); const token = auth.getToken(); if (token) { req = req.clone({ setHeaders: { Authorization: \Bearer ${token}` } }); } return next(req).pipe(catchError(err => { if (err instanceof HttpErrorResponse && err.status === 401) { auth.logout(); router.navigate(['/login']); } return throwError(() => err); })); }; import catchError, throwErrorfromrxjs/operatorsandHttpErrorResponsefrom@angular/common/http`

  • T022 [US2] Update ui/src/app/app.config.ts: change provideHttpClient() to provideHttpClient(withInterceptors([authInterceptor])); add the necessary imports; run ng test and confirm T020's 3 tests now pass; run ng build to confirm no build errors; run pytest api/ to confirm all API tests still pass

Checkpoint: Upload, delete, and tag-update reject unauthenticated requests. The Angular interceptor attaches the token on outbound requests and redirects to /login on any 401 response (covering expired mid-session tokens). All 9 new tests (6 API + 3 Angular) pass.


Phase 5: User Story 3 — Public Read Access (Priority: P1)

Goal: All read endpoints remain accessible without any credential. Verify no 401 regression was introduced by the changes in US2.

Independent Test: Without providing any Authorization header, call GET /api/v1/images, GET /api/v1/images/{id}, GET /api/v1/images/{id}/file, GET /api/v1/images/{id}/thumbnail, and GET /api/v1/tags. All must return 200.

Tests for User Story 3 (write FIRST — must FAIL before T024) ⚠️

  • T023 [US3] Add 5 regression integration tests using the authed_client fixture (which uses JWTAuthProvider but no Authorization header in the request) in a new api/tests/integration/test_public_access.py: test_list_images_without_token_is_200 (GET /api/v1/images with no auth header → 200), test_get_image_without_token_is_200 (upload image first using authed_client with token, then GET /api/v1/images/{id} with no auth header → 200), test_serve_file_without_token_is_200 (GET /api/v1/images/{id}/file with no auth header → 200), test_serve_thumbnail_without_token_is_200 (GET /api/v1/images/{id}/thumbnail with no auth header → 200), test_list_tags_without_token_is_200 (GET /api/v1/tags with no auth header → 200); run these tests and confirm they all fail (they will fail until T019 is complete because the authed_client fixture may not yet be fully wired — or they may pass if the fixture is ready, in which case document as already-green)

Verification for User Story 3

  • T024 [US3] Run pytest api/tests/integration/test_public_access.py and confirm all 5 pass; run pytest api/ -v to confirm the full API suite passes without regressions; document the passing test count

Checkpoint: All public read endpoints confirmed accessible without a token. No 401 regression introduced by the protected-write changes.


Phase 6: User Story 4 — Log Out (Priority: P2)

Goal: The owner can end their session. After logout, the token is gone from the browser and the upload page redirects to login.

Independent Test: Log in, verify the upload page is accessible. Click the logout control. Verify the application navigates to /login. Navigate directly to /upload — confirm redirect to /login.

Tests for User Story 4 (write FIRST — must FAIL before T027) ⚠️

  • T025 [P] [US4] Add 2 unit tests to ui/src/app/auth/auth.service.spec.ts: test_logout_removes_token_from_storage (set a token in sessionStorage, call logout(), confirm sessionStorage.getItem("auth_token") is null), test_isAuthenticated_false_after_logout (set token, call logout(), confirm isAuthenticated() returns false); these tests cover logout behaviour which belongs to US4 and was intentionally excluded from T012; logout() is implemented in T014 so these tests should pass immediately — confirm they pass before proceeding

  • T026 [P] [US4] Write 1 unit test in a new ui/src/app/auth/auth.guard.spec.ts for the (not-yet-existing) authGuard: test_redirects_to_login_when_not_authenticated — configure TestBed with provideRouter([]) and provideLocationMocks() (standalone Angular 17+ pattern; do NOT use the deprecated RouterTestingModule), spy AuthService.isAuthenticated() returning false, execute the guard function directly with a mock ActivatedRouteSnapshot and RouterStateSnapshot with url = '/upload', assert the returned value is a UrlTree whose toString() starts with /login; run ng test and confirm it fails

Implementation for User Story 4

  • T027 [P] [US4] Create ui/src/app/auth/auth.guard.ts as a functional CanActivateFn: inject AuthService and Router; if auth.isAuthenticated() return true; else return router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } })

  • T028 [P] [US4] Update ui/src/app/app.routes.ts: add canActivate: [authGuard] to the /upload route entry; add import for authGuard; add CanActivateFn guard to the route object

  • T029 [P] [US4] Update ui/src/app/detail/detail.component.ts: inject AuthService as public auth: AuthService; in the template, wrap the tag-edit input block and the delete button with *ngIf="auth.isAuthenticated()" so they are hidden for unauthenticated visitors; the image display and read-only tag chips remain visible to all

  • T030 [US4] Add a logout link/button to the application shell (ui/src/app/app.component.ts and its template): inject AuthService and Router; add onLogout() method that calls auth.logout() then router.navigate(['/login']); render the button only when auth.isAuthenticated() is true; run ng test and confirm T025 and T026 tests pass; run ng build to confirm no build errors

Checkpoint: Logout works. Upload page is guarded. Detail page hides write controls for unauthenticated visitors. All 3 new Angular tests (2 service + 1 guard) pass.


Phase 7: Polish & Cross-Cutting Concerns

Purpose: Environment documentation, final linting, and complete test run.

  • T031 [P] Update .env.example: add four new variables with comments:

    # Owner credentials and JWT signing secret
    JWT_SECRET_KEY=change-me-to-a-long-random-string
    JWT_EXPIRY_SECONDS=86400
    OWNER_USERNAME=owner
    OWNER_PASSWORD=change-me
    
  • T032 [P] Run ~/.local/bin/ruff check api/app/auth/jwt_provider.py api/app/routers/auth.py api/app/dependencies.py api/app/config.py api/app/routers/images.py and ruff format --check on the same files; fix any lint or formatting violations

  • T033 Run pytest api/ -v and confirm all tests pass; record final count (expected: ~57 existing + ~18 new ≈ 75 total)

  • T034 Run ng test inside the UI container (or locally) and confirm all Angular unit tests pass; run ng build and confirm the Angular build succeeds with no errors

  • T035 End-to-end smoke test: docker compose up, open the browser, verify: (a) the library loads without login, (b) navigating to /upload redirects to /login, (c) logging in navigates to the library, (d) uploading an image succeeds, (e) logging out redirects to /login, (f) attempting /upload again redirects to /login


Dependencies & Execution Order

Phase Dependencies

  • Setup (Phase 1): No dependencies — start immediately
  • Foundational (Phase 2): Depends on Phase 1 complete (PyJWT must be installed before tests can import JWTAuthProvider)
  • US1 (Phase 3): Depends on Phase 2 complete (JWTAuthProvider must exist before login endpoint tests reference it)
  • US2 (Phase 4): Depends on Phase 3 complete (Angular interceptor needs AuthService; API require_auth needs JWTAuthProvider)
  • US3 (Phase 5): Depends on Phase 4 complete (require_auth must be wired before public-access regression tests are meaningful)
  • US4 (Phase 6): Depends on Phase 3 complete (AuthService.logout() may already be implemented in T014; guard and route changes depend on login route existing from T016)
  • Polish (Phase 7): Depends on all feature phases complete

Within Each Phase

  • T006 (write failing tests) MUST precede T007 (implement JWTAuthProvider)
  • T008 (write failing API auth tests) MUST precede T009 (implement login route)
  • T012 and T013 (write failing Angular tests) MUST precede T014 and T015
  • T017 (write failing 401 tests) MUST precede T018 + T019
  • T020 (write failing interceptor tests) MUST precede T021
  • T023 (write failing public-access tests) MUST precede T024
  • T025 and T026 (write failing US4 tests) MUST precede T027T030

Parallel Opportunities (within phases)

  • T003 and T004 can run in parallel (different files in api/app/auth/)
  • T009, T010, T011 are sequential (dependencies.py → auth.py → main.py)
  • T012 and T013 can run in parallel (different spec files)
  • T014 and T015 can run in parallel after T012 and T013 (different component files)
  • T020 MUST precede T021 (TDD: confirm 3 tests fail before implementing interceptor)
  • T025 and T026 can run in parallel (different spec files)
  • T027, T028, T029 can run in parallel (different files: guard, routes, detail component)
  • T031 and T032 can run in parallel

Parallel Example: Phase 1 (Setup)

# T003 and T004 touch different files — run together:
Task: "Update AuthProvider interface signature in api/app/auth/provider.py"
Task: "Update NoOpAuthProvider signature in api/app/auth/noop.py"

Parallel Example: Phase 3 / US1 (Angular)

# T012 and T013 touch different spec files — run together:
Task: "Write 3 failing AuthService unit tests in ui/src/app/auth/auth.service.spec.ts"
Task: "Write 4 failing LoginComponent unit tests in ui/src/app/login/login.component.spec.ts"

# T014 and T015 touch different source files — run together after T012/T013:
Task: "Create AuthService in ui/src/app/auth/auth.service.ts"
Task: "Create LoginComponent in ui/src/app/login/login.component.ts"

Parallel Example: Phase 4 / US2 (Angular)

# T020 MUST precede T021 (TDD). Within US2 they are sequential.
# T020 can run in parallel with other US2 API tasks (T017, T018, T019 touch different files):
Task: "Write 3 failing interceptor tests in ui/src/app/auth/auth.interceptor.spec.ts"  # T020
# (after T020 confirms failing):
Task: "Create authInterceptor in ui/src/app/auth/auth.interceptor.ts"  # T021

Parallel Example: Phase 6 / US4

# T027, T028, T029 touch different files — run together:
Task: "Create authGuard in ui/src/app/auth/auth.guard.ts"
Task: "Add canActivate guard to /upload route in ui/src/app/app.routes.ts"
Task: "Conditionally show write controls in ui/src/app/detail/detail.component.ts"

Implementation Strategy

MVP (User Stories 1 + 2 + 3 — minimum shippable auth)

All three P1 stories are interdependent: login (US1) enables write-protection (US2), and write-protection must not break public reads (US3). Complete phases in order:

  1. Phase 1: Setup (T001T005)
  2. Phase 2: Foundational JWT provider (T006T007)
  3. Phase 3: US1 Login API + Angular (T008T016)
  4. Phase 4: US2 Protected writes API + Angular interceptor (T017T022)
  5. Phase 5: US3 Public-read regression (T023T024)
  6. STOP and VALIDATE: Login, upload (authenticated), and public browse all work

Incremental add-on: Logout (US4)

Once MVP is validated, add Phase 6 (T025T030) to complete the session lifecycle. This is independently addable without revisiting previous phases.

Total tasks: 35 (T001T035)


Notes

  • [P] tasks touch different files and have no mutual dependencies within their phase
  • T006, T008, T012, T013, T017, T020, T023, T025, T026 are all "write failing test" steps — always confirm failure before implementing
  • The client fixture in conftest.py uses NoOpAuthProvider and MUST NOT be changed — all existing tests depend on it passing without a token
  • The authed_client fixture returns (client, valid_token) — tests choose whether to include the token, enabling both 401 and success scenarios from the same fixture
  • The authInterceptor attaches the token unconditionally to all requests; the API silently ignores the Authorization header on public endpoints — no URL matching needed in the interceptor
  • Logout in the UI invalidates the client-side session only; the JWT technically remains valid until its exp (acceptable for a single-user local app with no token revocation)