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>
28 KiB
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 (US1–US4)
- 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.8to[project.dependencies]inapi/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-settingsBaseSettings):jwt_secret_key: str(required — no default, startup fails if absent),jwt_expiry_seconds: int = 86400,owner_username: str(required),owner_password: str(required); confirmget_settings()still loads from env vars via the existingSettingsConfigDict -
T003 [P] Update
api/app/auth/provider.py: changeget_identity(self)toget_identity(self, authorization: str | None) -> Identity; this is a breaking interface change that will cause theNoOpAuthProviderto fail type-checking until T004 is done -
T004 [P] Update
api/app/auth/noop.py: match the newget_identity(self, authorization: str | None) -> Identitysignature; the implementation still returns_ANONYMOUSand ignoresauthorization; runpytest 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 T007comment block where thejwt_auth_providerandauthed_clientfixtures will live — do not add the import ofJWTAuthProvideryet (it does not exist until T007 and would breakpytest api/if imported now); the existingclientfixture (withNoOpAuthProvider) must remain unchanged. After T007 is done, complete this task: addjwt_auth_providerfixture that constructs aJWTAuthProviderwith test credentials, andauthed_clientfixture that overridesget_authwith that provider and yields(client, valid_token)wherevalid_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.pyfor 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-anonymousIdentitywithid="owner"),test_get_identity_raises_on_expired_token(token with pastexp→HTTPException401),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); runpytest api/tests/unit/test_jwt_auth.pyand confirm all 10 fail withImportErrororAttributeError(not-yet-implemented)
JWTAuthProvider implementation
- T007 Create
api/app/auth/jwt_provider.pywithJWTAuthProvider(AuthProvider): constructor takessecret_key: str,expiry_seconds: int,owner_username: str,owner_password: str; implementcreate_token() -> strusingjwt.encode({"sub": "owner", "iat": now, "exp": now + expiry_seconds}, secret_key, algorithm="HS256"); implementverify_credentials(username, password) -> boolusingsecrets.compare_digest; implementget_identity(authorization: str | None) -> Identity— parse"Bearer <token>"(raise 401unauthorizedif missing or wrong prefix), decode withjwt.decode()(raise 401 onExpiredSignatureError,InvalidTokenError, or any exception), returnIdentity(id="owner", anonymous=False)on success; runpytest api/tests/unit/test_jwt_auth.pyand confirm all 10 pass; then runpytest 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.pyfor the (not-yet-existing)POST /api/v1/auth/tokenendpoint:test_login_success(POST valid creds → 200, response hasaccess_tokenas non-empty string,token_type="bearer",expires_in > 0),test_login_wrong_password(correct username, wrong password → 401, codeinvalid_credentials),test_login_wrong_username(wrong username → 401, codeinvalid_credentials),test_login_missing_password(body{"username": "x"}→ 422),test_login_missing_username(body{"password": "x"}→ 422); these tests should use a fixture with theJWTAuthProvideroverride; runpytest api/tests/integration/test_auth.pyand confirm all 5 fail with404(route not yet registered)
Implementation for User Story 1 (API)
-
T009 [US1] In
api/app/dependencies.py, addget_jwt_auth() -> JWTAuthProvider— a typed dependency that returns the sameJWTAuthProviderinstance asget_auth()but with the concrete type, so the auth router can callverify_credentials()andcreate_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 theAuthProviderabstraction). Then createapi/app/routers/auth.py: defineLoginRequestPydantic model (username: str,password: str), defineTokenResponsePydantic model (access_token: str,token_type: str = "bearer",expires_in: int), addPOST /auth/tokenroute that injectsauth: JWTAuthProvider = Depends(get_jwt_auth)— callsauth.verify_credentials(username, password), raisesHTTPException(401, {"detail": "Invalid credentials", "code": "invalid_credentials"})on failure, callsauth.create_token()and returnsTokenResponseon success; complete T005'sconftest.pyjwt_auth_providerfixture import now that the module exists -
T010 [US1] Update
api/app/dependencies.py: inget_auth(), replaceNoOpAuthProvider()withJWTAuthProvider(secret_key=s.jwt_secret_key, expiry_seconds=s.jwt_expiry_seconds, owner_username=s.owner_username, owner_password=s.owner_password)(loading settings viaget_settings()); the existingclientfixture inconftest.pystill overridesget_authwithNoOpAuthProvider, so all existing tests remain unaffected -
T011 [US1] Update
api/app/main.py: importauthrouter fromapp.routers.authand register it withapp.include_router(auth.router, prefix="/api/v1"); runpytest api/tests/integration/test_auth.pyand confirm all 5 tests pass; runpytest 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.tsfor the (not-yet-existing)AuthService:test_login_stores_token(mockHttpClientPOST returning{access_token: "tok"}, verifysessionStorage.getItem("auth_token") === "tok"afterlogin()completes),test_isAuthenticated_true_when_token_present(set token in sessionStorage, assertisAuthenticated()returns true),test_isAuthenticated_false_when_no_token(clear sessionStorage, assertisAuthenticated()returns false); runng testand confirm all 3 fail withCannot find moduleor 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.tsfor the (not-yet-existing)LoginComponent:test_submit_calls_auth_service_login(spy onAuthService.login, fill form, submit, verifylogincalled with correct username and password),test_navigates_to_library_on_success(mockAuthService.loginreturningof(void 0), submit, verifyRouter.navigatecalled with['/']),test_shows_error_on_401(mockAuthService.loginthrowingHttpErrorResponsewith status 401, submit, verify error message element is visible in the template),test_shows_validation_error_on_empty_fields(disable browser-native validation vianovalidate, leave username and password blank, click submit, verify noHttpClient.postcall was made and a validation error element is visible in the DOM); runng testand 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, pipetap(res => sessionStorage.setItem(this.TOKEN_KEY, res.access_token)), map to void;logout(): void—sessionStorage.removeItem(this.TOKEN_KEY);getToken(): string | null—sessionStorage.getItem(this.TOKEN_KEY);isAuthenticated(): boolean—this.getToken() !== null; decorate with@Injectable({ providedIn: 'root' }) -
T015 [P] [US1] Create
ui/src/app/login/login.component.ts(standalone component, route/login): reactive form withusername(required) andpassword(required) validators;onSubmit()callsAuthService.login(), setsloading = truewhile in-flight, on success readsreturnUrlquery param (default'/') and callsrouter.navigateByUrl(returnUrl), on error setserrorMessage = '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; runng testand confirm T012 and T013 tests now pass; runng buildto 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_clientfixture across existing test files: inapi/tests/integration/test_upload.pyaddtest_upload_without_token_returns_401(POST with noAuthorizationheader → 401, codeunauthorized) andtest_upload_with_valid_token_succeeds(POST withAuthorization: Bearer <token>→ 200/201); inapi/tests/integration/test_delete.pyaddtest_delete_without_token_returns_401(DELETE with no token → 401) andtest_delete_with_valid_token_succeeds(DELETE with valid token → 204); addtest_patch_tags_without_token_returns_401(PATCH/images/{id}/tagswith no token → 401) andtest_patch_tags_with_valid_token_succeeds(PATCH with valid token → 200) toapi/tests/integration/test_upload.py(or a newtest_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_authasync dependency toapi/app/dependencies.py:async def require_auth(authorization: str | None = Header(None, alias="Authorization"), auth: AuthProvider = Depends(get_auth)) -> Identity— callsawait auth.get_identity(authorization), raisesHTTPException(401, {"detail": "Authentication required", "code": "unauthorized"})ifidentity.anonymousis True, otherwise returnsIdentity -
T019 [US2] In
api/app/routers/images.py: add_: Identity = Depends(require_auth)parameter toupload_image(),delete_image(), andupdate_image_tags(); also remove the existingauth: AuthProvider = Depends(get_auth)fromupload_image()(it was injected but never called —require_authnow subsumes it); addfrom app.dependencies import require_authandfrom app.auth.provider import Identityto imports; runpytest api/tests/integration/and confirm all 6 new protected-endpoint tests pass and all pre-existing tests (which use theclientfixture withNoOpAuthProvideroverride) 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.tsfor the (not-yet-existing)authInterceptor:test_adds_auth_header_when_authenticated(configureTestBedwithauthInterceptor, spyAuthService.getToken()returning"test-token", make any HTTP request viaHttpClient, verify the outgoing request inHttpTestingControllerhas headerAuthorization: Bearer test-token),test_no_auth_header_when_not_authenticated(spyAuthService.getToken()returningnull, make HTTP request, verifyAuthorizationheader is absent),test_interceptor_redirects_to_login_on_401(spyAuthService.getToken()returning"test-token", flush the HTTP response with status 401, spyAuthService.logout()andRouter.navigate, verifylogout()was called androuter.navigate(['/login'])was called); runng testand confirm all 3 fail
Implementation for User Story 2 (Angular)
-
T021 [US2] Create
ui/src/app/auth/auth.interceptor.tsas 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); })); }; importcatchError,throwErrorfromrxjs/operatorsandHttpErrorResponsefrom@angular/common/http` -
T022 [US2] Update
ui/src/app/app.config.ts: changeprovideHttpClient()toprovideHttpClient(withInterceptors([authInterceptor])); add the necessary imports; runng testand confirm T020's 3 tests now pass; runng buildto confirm no build errors; runpytest 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_clientfixture (which usesJWTAuthProviderbut noAuthorizationheader in the request) in a newapi/tests/integration/test_public_access.py:test_list_images_without_token_is_200(GET/api/v1/imageswith no auth header → 200),test_get_image_without_token_is_200(upload image first usingauthed_clientwith token, then GET/api/v1/images/{id}with no auth header → 200),test_serve_file_without_token_is_200(GET/api/v1/images/{id}/filewith no auth header → 200),test_serve_thumbnail_without_token_is_200(GET/api/v1/images/{id}/thumbnailwith no auth header → 200),test_list_tags_without_token_is_200(GET/api/v1/tagswith no auth header → 200); run these tests and confirm they all fail (they will fail until T019 is complete because theauthed_clientfixture 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.pyand confirm all 5 pass; runpytest api/ -vto 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, calllogout(), confirmsessionStorage.getItem("auth_token")is null),test_isAuthenticated_false_after_logout(set token, calllogout(), confirmisAuthenticated()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.tsfor the (not-yet-existing)authGuard:test_redirects_to_login_when_not_authenticated— configureTestBedwithprovideRouter([])andprovideLocationMocks()(standalone Angular 17+ pattern; do NOT use the deprecatedRouterTestingModule), spyAuthService.isAuthenticated()returningfalse, execute the guard function directly with a mockActivatedRouteSnapshotandRouterStateSnapshotwithurl = '/upload', assert the returned value is aUrlTreewhosetoString()starts with/login; runng testand confirm it fails
Implementation for User Story 4
-
T027 [P] [US4] Create
ui/src/app/auth/auth.guard.tsas a functionalCanActivateFn: injectAuthServiceandRouter; ifauth.isAuthenticated()returntrue; else returnrouter.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }) -
T028 [P] [US4] Update
ui/src/app/app.routes.ts: addcanActivate: [authGuard]to the/uploadroute entry; add import forauthGuard; addCanActivateFnguard to the route object -
T029 [P] [US4] Update
ui/src/app/detail/detail.component.ts: injectAuthServiceaspublic 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.tsand its template): injectAuthServiceandRouter; addonLogout()method that callsauth.logout()thenrouter.navigate(['/login']); render the button only whenauth.isAuthenticated()is true; runng testand confirm T025 and T026 tests pass; runng buildto 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.pyandruff format --checkon the same files; fix any lint or formatting violations -
T033 Run
pytest api/ -vand confirm all tests pass; record final count (expected: ~57 existing + ~18 new ≈ 75 total) -
T034 Run
ng testinside the UI container (or locally) and confirm all Angular unit tests pass; runng buildand 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/uploadredirects to/login, (c) logging in navigates to the library, (d) uploading an image succeeds, (e) logging out redirects to/login, (f) attempting/uploadagain 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 (
JWTAuthProvidermust exist before login endpoint tests reference it) - US2 (Phase 4): Depends on Phase 3 complete (Angular interceptor needs
AuthService; APIrequire_authneedsJWTAuthProvider) - US3 (Phase 5): Depends on Phase 4 complete (
require_authmust 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 T027–T030
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:
- Phase 1: Setup (T001–T005)
- Phase 2: Foundational JWT provider (T006–T007)
- Phase 3: US1 Login API + Angular (T008–T016)
- Phase 4: US2 Protected writes API + Angular interceptor (T017–T022)
- Phase 5: US3 Public-read regression (T023–T024)
- STOP and VALIDATE: Login, upload (authenticated), and public browse all work
Incremental add-on: Logout (US4)
Once MVP is validated, add Phase 6 (T025–T030) to complete the session lifecycle. This is independently addable without revisiting previous phases.
Total tasks: 35 (T001–T035)
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
clientfixture inconftest.pyusesNoOpAuthProviderand MUST NOT be changed — all existing tests depend on it passing without a token - The
authed_clientfixture returns(client, valid_token)— tests choose whether to include the token, enabling both 401 and success scenarios from the same fixture - The
authInterceptorattaches the token unconditionally to all requests; the API silently ignores theAuthorizationheader 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)