# Tasks: OIDC Proxy Script Authentication **Feature**: `001-oidc-proxy-script` **Input**: `specs/001-oidc-proxy-script/` — spec.md, plan.md, data-model.md, research.md, contracts/, quickstart.md **Prerequisites**: Node.js 18+, `npm install` already run, Redis available at default port **TDD**: Tests are written **before** each implementation task per the project constitution (plan.md §Constitution Check III). Confirm each test **fails** before writing the implementation that makes it pass. ## Format: `[ID] [P?] [Story?] Description` - **[P]**: Parallelisable — different files, no dependencies on incomplete tasks in the same phase - **[US1/2/3]**: Maps to User Story in spec.md - All paths relative to repository root (`/Users/peter.morton/kme-content-adapter/`) --- ## Phase 1: Setup **Purpose**: Create the OIDC settings files and protect credentials from being committed. - [X] T001 Create `src/globalVariables/adapter_settings.json.example` — JSON object with the five required fields as placeholder strings: `tokenUrl`, `username`, `password`, `clientId`, `scope` (use `"https://auth.kme.example.com/protocol/openid-connect/token"`, `"service-account@example.com"`, `"changeme"`, `"kme-content-adapter"`, `"openid tags content_entitlements"`) - [X] T002 [P] Create `src/globalVariables/adapter_settings.json` by copying `adapter_settings.json.example` — replace placeholder values with real credentials (this file is gitignored and MUST NOT be committed; real values required for contract tests and manual smoke test) - [X] T003 [P] Add `src/globalVariables/adapter_settings.json` to `.gitignore` — append the line `src/globalVariables/adapter_settings.json`; confirm `src/globalVariables/adapter_settings.json.example` is **not** excluded; run `git status` to verify the `.json` file is untracked/ignored and the `.example` file is visible to git --- ## Phase 2: Foundational — Redis Client Wiring **Purpose**: `proxy.js` calls `redis.hSet()` and `redis.hGet()` (instance methods on a connected client). The current `src/server.js` injects the bare `redis` module, not a connected client. This must be fixed before any proxy.js code can run. **⚠️ CRITICAL**: No proxy.js work can be executed end-to-end until this phase is complete. - [X] T004 Update `src/server.js` — wire up a connected Redis client and inject it into `globalVMContext`: 1. Change `import redis from "redis"` → `import { createClient } from "redis"` 2. After the import block, add: `const redisClient = createClient(); await redisClient.connect();` (move inside `startServer()` before `loadGlobalVariables()`, since `startServer` is already `async`) 3. In `globalVMContext`, replace `redis` with `redis: redisClient` 4. Start the server (`npm start`) and confirm no Redis connection error in the startup log **Checkpoint**: `globalVMContext.redis` is now a live client with `hSet`/`hGet`. User story implementation can begin. --- ## Phase 3: User Story 1 — Successful Authenticated Request (Priority: P1) 🎯 MVP **Goal**: An inbound HTTP request produces `200 OK / Authorized` whether the token must be freshly fetched or served from the Redis cache. **Independent Test**: Send any request to the proxy endpoint; confirm `200 OK` with body `Authorized`. Verifiable with `curl http://localhost:3000/` after completing T002 with real credentials. ### Tests for User Story 1 (write first — must FAIL before T007) - [X] T005 [P] [US1] Create `tests/unit/proxy.test.js` — skeleton + two US1 tests using `node:test` and `node:vm`: 1. **`makeContext(t, overrides)`** helper: in-memory Redis fake (`_store = {}`; `hSet` and `hGet` backed by `_store[key:field]`, both `t.mock.fn()`); mock `axios.post` returning `{ data: { id_token: 'test-token', expires_in: 9_999_999_999 } }`; mock `res` with `writeHead` / `end` as `t.mock.fn()`, exposing `statusCode` and `body` getters; `adapter_settings` with all five fields set; inject `URLSearchParams`, `console` 2. **Test "cache miss → fresh fetch → 200 OK"**: empty Redis store, run `await script.runInContext(ctx)`, assert `res.statusCode === 200`, `res.body === 'Authorized'`, `axios.post.mock.calls.length === 1` 3. **Test "cache hit → no fetch → 200 OK"**: pre-seed `_store` with `'authorization:token' = 'cached-tok'` and `'authorization:expiry' = '9999999999'`, run script, assert `res.statusCode === 200`, `res.body === 'Authorized'`, `axios.post.mock.calls.length === 0` 4. Run `node --test tests/unit/proxy.test.js` — confirm both tests **fail** (`ReferenceError: proxy.js not found` is expected) - [X] T006 [P] [US1] Create `tests/contract/proxy-http.test.js` — one US1 contract test using `node:test`, `node:http`, `node:vm`, `node:fs`: 1. Spin up an `http.createServer` mock token endpoint that responds `200` with `JSON.stringify({ id_token: 'contract-token', expires_in: 9_999_999_999 })` on any POST 2. Build a VM context with real `URLSearchParams`, `console`, the mock `axios` pointed at the mock server URL (use a real `axios` instance), a real in-memory Redis fake, and `adapter_settings` pointing `tokenUrl` at the mock server 3. Compile `proxy.js` once via `new vm.Script(readFileSync(...))` and run in context 4. Assert `res.statusCode === 200` and `res.body === 'Authorized'`; assert `Content-Type` header set to `'text/plain'` 5. Run `node --test tests/contract/proxy-http.test.js` — confirm test **fails** (`proxy.js does not exist` is expected) ### Implementation for User Story 1 - [X] T007 [US1] Create `src/proxyScripts/proxy.js` — zero `import`/`export`; full async IIFE: ``` (async () => { try { // 1. Validate required adapter_settings fields // For each of ['tokenUrl','username','password','clientId','scope']: // if (!adapter_settings[field]) throw new Error('missing required field: ' + field) // 2. Read token cache from Redis // const token = await redis.hGet('authorization', 'token') // const expiry = parseFloat(await redis.hGet('authorization', 'expiry') ?? '0') // const isValid = token !== null && Date.now() / 1000 < expiry // 3. Cache HIT → respond immediately // if (isValid) { res.writeHead(200, {'Content-Type':'text/plain'}); res.end('Authorized'); return; } // 4. Cache MISS → fetch fresh token // const params = new URLSearchParams({ grant_type:'password', username, password, // client_id: clientId, scope }) // const response = await axios.post(tokenUrl, params, // { headers:{'Content-Type':'application/x-www-form-urlencoded'}, timeout: 5000 }) // const { id_token, expires_in } = response.data // if (!id_token) throw new Error('id_token missing from response') // if (!expires_in) throw new Error('expires_in missing from response') // 5. Write to Redis cache // await redis.hSet('authorization', 'token', id_token) // await redis.hSet('authorization', 'expiry', String(expires_in)) // 6. Respond success // res.writeHead(200, {'Content-Type':'text/plain'}); res.end('Authorized') } catch (err) { console.error({ message: 'Auth failed', error: err.message }) res.writeHead(401, {'Content-Type':'text/plain'}) res.end('Unauthorized: ' + err.message) } })() ``` Run `npm run test:unit` and `npm run test:contract` — T005 and T006 tests must pass. **Checkpoint**: US1 fully functional. `curl` the proxy endpoint → `200 OK / Authorized`. --- ## Phase 4: User Story 2 — Token Expiry and Refresh (Priority: P2) **Goal**: An expired cached token is automatically discarded and a fresh one fetched transparently; a still-valid token is never re-fetched. **Independent Test**: Pre-seed Redis `authorization` hash with an expiry timestamp in the past (e.g. `'1'`); send a request; confirm `200 OK / Authorized` is returned and a new token fetch occurred. ### Tests for User Story 2 (write first — must FAIL or explicitly verified against T007) - [X] T008 [US2] Add expiry tests to `tests/unit/proxy.test.js`: 1. **Test "expired token → re-fetch → 200 OK"**: pre-seed `_store` with `'authorization:token' = 'old-tok'` and `'authorization:expiry' = '1'` (epoch far in the past); run script; assert `axios.post.mock.calls.length === 1`, `res.statusCode === 200`, `res.body === 'Authorized'`; assert Redis `hSet` was called to write new token and expiry 2. **Test "future expiry → no re-fetch → 200 OK"**: pre-seed with valid future expiry `'9999999999'`; run script; assert `axios.post.mock.calls.length === 0` and `res.statusCode === 200` 3. Run `node --test tests/unit/proxy.test.js` — if both tests **pass already** (T007's `Date.now()/1000 < expiry` check covers them), record as verified; if either **fails**, proceed to T009 ### Implementation for User Story 2 - [X] T009 [US2] Verify expiry logic in `src/proxyScripts/proxy.js` — confirm `parseFloat(await redis.hGet('authorization', 'expiry') ?? '0')` and the validity check `token !== null && Date.now() / 1000 < expiry` are implemented exactly as specified; if T008 tests failed, correct the validity expression until both pass; run `npm run test:unit` to confirm green **Checkpoint**: US1 + US2 both independently functional and tested. --- ## Phase 5: User Story 3 — Authentication Failure Handling (Priority: P3) **Goal**: Any authentication failure (bad credentials, timeout, unreachable service, malformed response, missing config) produces `401 Unauthorized` with a descriptive message — never a crash or hang. **Independent Test**: Provide an invalid `tokenUrl` or wrong `password` in `adapter_settings`; send a request; confirm `401 Unauthorized` with body starting `Unauthorized: `. ### Tests for User Story 3 (write first — must FAIL before T012 error differentiation) - [X] T010 [P] [US3] Add failure unit tests to `tests/unit/proxy.test.js`: 1. **Test "HTTP 401 from token service"**: `axios.post` rejects with `{ response: { status: 401 } }`; assert `res.statusCode === 401`, `res.body === 'Unauthorized: HTTP 401'` 2. **Test "timeout (ECONNABORTED)"**: `axios.post` rejects with `{ code: 'ECONNABORTED' }`; assert `res.statusCode === 401`, `res.body === 'Unauthorized: token service timeout'` 3. **Test "timeout (ERR_CANCELED)"**: same as above but `{ code: 'ERR_CANCELED' }`; assert body `'Unauthorized: token service timeout'` 4. **Test "missing id_token in response"**: `axios.post` resolves with `{ data: { expires_in: 9999 } }` (no `id_token`); assert `res.statusCode === 401`, `res.body === 'Unauthorized: id_token missing from response'` 5. **Test "missing tokenUrl in adapter_settings"**: override `adapter_settings` with `tokenUrl: ''`; assert `res.statusCode === 401`, body matches `'Unauthorized: missing required field: tokenUrl'` 6. **Test "missing username"**: `username: undefined`; assert `401`, body `'Unauthorized: missing required field: username'` 7. Run `node --test tests/unit/proxy.test.js` — confirm failure tests FAIL (timeout test may fail because current catch sends `err.message` directly, not `'token service timeout'`) - [X] T011 [P] [US3] Add 401 contract test to `tests/contract/proxy-http.test.js`: 1. Mock token endpoint returns HTTP `401` with empty body 2. Run proxy in VM context pointing at mock server 3. Assert `res.statusCode === 401` and `res.body` matches `/^Unauthorized: /` 4. Run `node --test tests/contract/proxy-http.test.js` — confirm test behaviour (may already pass if catch sends `err.message`; verify exact body format) ### Implementation for User Story 3 - [X] T012 [US3] Extend catch block in `src/proxyScripts/proxy.js` — refine error message derivation: ``` } catch (err) { let message if (err.response) { message = 'HTTP ' + err.response.status } else if (err.code === 'ECONNABORTED' || err.code === 'ERR_CANCELED') { message = 'token service timeout' } else { message = err.message } console.error({ message: 'Auth failed', error: message }) res.writeHead(401, { 'Content-Type': 'text/plain' }) res.end('Unauthorized: ' + message) } ``` Confirm field-validation `throw new Error('missing required field: ...')` and `id_token`/`expires_in` absence throws are already caught by this same block (they have no `.response` and no `.code`, so `err.message` is used — correct). Run `npm run test:unit` and `npm run test:contract` — all T010 and T011 tests must pass. **Checkpoint**: US1 + US2 + US3 all independently functional and tested. --- ## Phase 6: Stampede Guard (FR-013 — Cross-Cutting) **Goal**: When two or more concurrent requests arrive with no valid cached token, exactly **one** token fetch is made to the OIDC service; all other requests queue on the same Promise and share the result. **Independent Test**: Fire two simultaneous `script.runInContext()` calls; assert `axios.post` was called exactly once and both responses are `200 / Authorized`. ### Test for Stampede Guard (write first) - [X] T013 [US1] Add concurrent-request unit test to `tests/unit/proxy.test.js`: 1. Create a **shared** `adapter_settings` object (no pre-seeded Redis token); slow down `axios.post` mock by 50 ms using `new Promise(resolve => setTimeout(resolve, 50))` before returning the token response 2. Fire two `script.runInContext()` calls with contexts that share the same `adapter_settings` reference (separate `res` objects for each): `const [r1, r2] = await Promise.all([run(ctx1), run(ctx2)])` 3. Assert `mockAxios.post.mock.calls.length === 1` (stampede guard prevented second fetch) 4. Assert both `res1.statusCode === 200` and `res2.statusCode === 200` 5. Run `node --test tests/unit/proxy.test.js` — confirm stampede test **fails** (currently two fetches are made) ### Implementation for Stampede Guard - [X] T014 [US1] Extend `src/proxyScripts/proxy.js` — add `_pendingFetch` guard between the cache-miss detection and the `axios.post` call: ``` // After isValid check returns false and before axios.post: // Queue on in-flight fetch if one is already running if (adapter_settings._pendingFetch !== null && typeof adapter_settings._pendingFetch?.then === 'function') { try { await adapter_settings._pendingFetch res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Authorized') } catch (err) { res.writeHead(401, { 'Content-Type': 'text/plain' }) res.end('Unauthorized: ' + (err.response ? 'HTTP ' + err.response.status : (err.code === 'ECONNABORTED' || err.code === 'ERR_CANCELED') ? 'token service timeout' : err.message)) } return } // This invocation wins the race — build and share the fetch promise adapter_settings._pendingFetch = (async () => { // ... axios.post + Redis hSet ... })() try { await adapter_settings._pendingFetch res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Authorized') } catch (err) { // ... 401 as above ... } finally { adapter_settings._pendingFetch = null } ``` Use duck-type check (`typeof .then === 'function'`) — **not** `instanceof Promise` (fails across V8 realms per research.md R-002). Run `npm run test:unit` — T013 stampede test must pass. **Checkpoint**: All user stories complete and independently tested. Full `npm test` should be green. --- ## Final Phase: Polish & Verification - [X] T015 [P] Static analysis of `src/proxyScripts/proxy.js` — run the following; all must return zero matches: ```bash grep -n 'import\|export' src/proxyScripts/proxy.js # FR-009 grep -n 'process\.env\|config\b\|global\.config' src/proxyScripts/proxy.js # FR-010 ``` If any match is found, remove the offending line and re-run the full test suite. - [X] T016 [P] Verify `.gitignore` and file tracking — run: ```bash git status src/globalVariables/ ``` Confirm `adapter_settings.json` shows as **ignored** (not staged, not untracked in output) and `adapter_settings.json.example` is **tracked** (shows in `git ls-files src/globalVariables/`). If `adapter_settings.json` is untracked (not ignored), verify T003 was applied to the correct `.gitignore` path. - [X] T017 Run full test suite `npm test` — all unit and contract tests pass with zero failures; if any test fails, fix the root cause in `src/proxyScripts/proxy.js` or the relevant test file before marking complete - [ ] T018 Validate quickstart — follow `specs/001-oidc-proxy-script/quickstart.md`: 1. `npm start` — confirm structured JSON startup log includes `"Loaded global data: adapter_settings"` with keys `["tokenUrl","username","password","clientId","scope"]` 2. `curl -v http://localhost:3000/` — confirm `HTTP/1.1 200 OK` and body `Authorized` 3. Send a second request — confirm adapter log shows **no** new `axios.post` call (cache hit) 4. If response is `401`, check credentials in `adapter_settings.json` and Redis connectivity --- ## Dependencies & Execution Order ### Phase Dependencies ``` Phase 1 (T001-T003) ──────────────────────────► no dependencies; start immediately Phase 2 (T004) ──── after T001 ──────────► BLOCKS all proxy.js execution Phase 3 (T005-T007) ──── after T001, T004 ────► US1 MVP; T005 ∥ T006 before T007 Phase 4 (T008-T009) ──── after T007 ──────────► US2 expiry; adds to test file from T005 Phase 5 (T010-T012) ──── after T007 ──────────► US3 failures; T010 ∥ T011 before T012 Phase 6 (T013-T014) ──── after T007 ──────────► Stampede guard; T013 before T014 Final Phase (T015-T018) ── after T014 ──────────► Polish; T015 ∥ T016 before T017 → T018 ``` ### User Story Dependencies | Story | Depends on | Independent of | |-------|-----------|---------------| | **US1** (P1, T005-T007) | Phase 2 complete | US2, US3 | | **US2** (P2, T008-T009) | T007 complete | US3 (fully independent) | | **US3** (P3, T010-T012) | T007 complete | US2 (fully independent) | | **Stampede** (T013-T014) | T007 complete | US2, US3 | Phases 4, 5, and 6 can all begin in parallel once T007 is merged (they add to different test blocks; use separate git worktrees or feature branches if pairing). ### Within Each Phase - Tests **before** implementation (constitutional requirement) - Test files in same story can be written in parallel (T005 ∥ T006, T010 ∥ T011) - Implementation tasks within a phase are sequential (single `proxy.js` file) --- ## Parallel Execution Examples ### Phase 3 (US1): Tests in parallel ``` Task A: T005 — Write tests/unit/proxy.test.js (makeContext + cache-hit/miss tests) Task B: T006 — Write tests/contract/proxy-http.test.js (200 OK contract test) ↓ both confirm FAIL Task C: T007 — Implement src/proxyScripts/proxy.js (makes both pass) ``` ### Phase 5 (US3): Tests in parallel ``` Task A: T010 — Add unit failure tests to tests/unit/proxy.test.js Task B: T011 — Add 401 contract test to tests/contract/proxy-http.test.js ↓ both confirm FAIL (or partial pass) Task C: T012 — Extend proxy.js catch block (makes both pass) ``` ### Final Phase: Polish in parallel ``` Task A: T015 — Static analysis (grep check) Task B: T016 — .gitignore verification ↓ both must clear before Task C: T017 — npm test (full suite) ↓ Task D: T018 — Quickstart smoke test ``` --- ## Implementation Strategy ### MVP (User Story 1 Only) 1. Complete **Phase 1** (T001-T003): Setup files 2. Complete **Phase 2** (T004): Wire Redis client in server.js 3. Complete **Phase 3** (T005-T007): US1 tests + proxy.js core implementation 4. **STOP and VALIDATE**: `curl` the proxy endpoint → `200 OK / Authorized` 5. Run `npm run test:unit && npm run test:contract` — US1 green 6. Deploy / demo if ready ### Incremental Delivery 1. MVP above → US1 shipped 2. Add **Phase 4** (T008-T009) → expiry/refresh tested → US2 shipped 3. Add **Phase 5** (T010-T012) → failure handling tested → US3 shipped 4. Add **Phase 6** (T013-T014) → stampede guard tested → concurrent safety shipped 5. **Final Phase** (T015-T018) → static analysis + smoke test → feature complete --- ## Notes - **proxy.js has zero `import`/`export`** — all dependencies arrive via `vm.createContext()`; tested by T015 grep - **Redis stores token + expiry** (hash key `authorization`, fields `token` + `expiry`); `adapter_settings._pendingFetch` holds the stampede guard (Promises cannot be serialised to Redis) - **`expires_in` is an absolute Unix epoch timestamp** (not a relative duration); validity: `Date.now() / 1000 < expiry` - **All auth failures → `401 Unauthorized`**, never a crash (SC-004); top-level catch in async IIFE handles every error path - **`t.mock.fn()`** from `node:test` provides call-count assertions without any external test framework - **Duck-type stampede check** (`typeof .then === 'function'`) is required — `instanceof Promise` fails across V8 realms (research.md R-002) - Commit after each task or logical group; each checkpoint is a safe stopping point