Files
kme_content_adapter/specs/001-oidc-proxy-script/tasks.md

366 lines
21 KiB
Markdown

# 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/<proxy-path>` 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/<proxy-path>` — 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