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>
This commit is contained in:
322
specs/004-jwt-bearer-auth/tasks.md
Normal file
322
specs/004-jwt-bearer-auth/tasks.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# 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.
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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`
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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) ⚠️
|
||||
|
||||
- [X] 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 `exp` → `HTTPException` 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
|
||||
|
||||
- [X] 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) ⚠️
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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) ⚠️
|
||||
|
||||
- [X] 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.
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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(): void` — `sessionStorage.removeItem(this.TOKEN_KEY)`; `getToken(): string | null` — `sessionStorage.getItem(this.TOKEN_KEY)`; `isAuthenticated(): boolean` — `this.getToken() !== null`; decorate with `@Injectable({ providedIn: 'root' })`
|
||||
|
||||
- [X] 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"`)
|
||||
|
||||
- [X] 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) ⚠️
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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`
|
||||
|
||||
- [X] 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) ⚠️
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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`, `throwError` from `rxjs/operators` and `HttpErrorResponse` from `@angular/common/http`
|
||||
|
||||
- [X] 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) ⚠️
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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) ⚠️
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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 } })`
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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.
|
||||
|
||||
- [X] 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
|
||||
```
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] T033 Run `pytest api/ -v` and confirm all tests pass; record final count (expected: ~57 existing + ~18 new ≈ 75 total)
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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 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)
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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 (T001–T005)
|
||||
2. Phase 2: Foundational JWT provider (T006–T007)
|
||||
3. Phase 3: US1 Login API + Angular (T008–T016)
|
||||
4. Phase 4: US2 Protected writes API + Angular interceptor (T017–T022)
|
||||
5. Phase 5: US3 Public-read regression (T023–T024)
|
||||
6. **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 `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)
|
||||
Reference in New Issue
Block a user