21 KiB
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.
- 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") - T002 [P] Create
src/globalVariables/adapter_settings.jsonby copyingadapter_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) - T003 [P] Add
src/globalVariables/adapter_settings.jsonto.gitignore— append the linesrc/globalVariables/adapter_settings.json; confirmsrc/globalVariables/adapter_settings.json.exampleis not excluded; rungit statusto verify the.jsonfile is untracked/ignored and the.examplefile 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.
- T004 Update
src/server.js— wire up a connected Redis client and inject it intoglobalVMContext:- Change
import redis from "redis"→import { createClient } from "redis" - After the import block, add:
const redisClient = createClient(); await redisClient.connect();(move insidestartServer()beforeloadGlobalVariables(), sincestartServeris alreadyasync) - In
globalVMContext, replacerediswithredis: redisClient - Start the server (
npm start) and confirm no Redis connection error in the startup log
- Change
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)
-
T005 [P] [US1] Create
tests/unit/proxy.test.js— skeleton + two US1 tests usingnode:testandnode:vm:makeContext(t, overrides)helper: in-memory Redis fake (_store = {};hSetandhGetbacked by_store[key:field], botht.mock.fn()); mockaxios.postreturning{ data: { id_token: 'test-token', expires_in: 9_999_999_999 } }; mockreswithwriteHead/endast.mock.fn(), exposingstatusCodeandbodygetters;adapter_settingswith all five fields set; injectURLSearchParams,console- Test "cache miss → fresh fetch → 200 OK": empty Redis store, run
await script.runInContext(ctx), assertres.statusCode === 200,res.body === 'Authorized',axios.post.mock.calls.length === 1 - Test "cache hit → no fetch → 200 OK": pre-seed
_storewith'authorization:token' = 'cached-tok'and'authorization:expiry' = '9999999999', run script, assertres.statusCode === 200,res.body === 'Authorized',axios.post.mock.calls.length === 0 - Run
node --test tests/unit/proxy.test.js— confirm both tests fail (ReferenceError: proxy.js not foundis expected)
-
T006 [P] [US1] Create
tests/contract/proxy-http.test.js— one US1 contract test usingnode:test,node:http,node:vm,node:fs:- Spin up an
http.createServermock token endpoint that responds200withJSON.stringify({ id_token: 'contract-token', expires_in: 9_999_999_999 })on any POST - Build a VM context with real
URLSearchParams,console, the mockaxiospointed at the mock server URL (use a realaxiosinstance), a real in-memory Redis fake, andadapter_settingspointingtokenUrlat the mock server - Compile
proxy.jsonce vianew vm.Script(readFileSync(...))and run in context - Assert
res.statusCode === 200andres.body === 'Authorized'; assertContent-Typeheader set to'text/plain' - Run
node --test tests/contract/proxy-http.test.js— confirm test fails (proxy.js does not existis expected)
- Spin up an
Implementation for User Story 1
-
T007 [US1] Create
src/proxyScripts/proxy.js— zeroimport/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:unitandnpm 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)
- T008 [US2] Add expiry tests to
tests/unit/proxy.test.js:- Test "expired token → re-fetch → 200 OK": pre-seed
_storewith'authorization:token' = 'old-tok'and'authorization:expiry' = '1'(epoch far in the past); run script; assertaxios.post.mock.calls.length === 1,res.statusCode === 200,res.body === 'Authorized'; assert RedishSetwas called to write new token and expiry - Test "future expiry → no re-fetch → 200 OK": pre-seed with valid future expiry
'9999999999'; run script; assertaxios.post.mock.calls.length === 0andres.statusCode === 200 - Run
node --test tests/unit/proxy.test.js— if both tests pass already (T007'sDate.now()/1000 < expirycheck covers them), record as verified; if either fails, proceed to T009
- Test "expired token → re-fetch → 200 OK": pre-seed
Implementation for User Story 2
- T009 [US2] Verify expiry logic in
src/proxyScripts/proxy.js— confirmparseFloat(await redis.hGet('authorization', 'expiry') ?? '0')and the validity checktoken !== null && Date.now() / 1000 < expiryare implemented exactly as specified; if T008 tests failed, correct the validity expression until both pass; runnpm run test:unitto 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)
-
T010 [P] [US3] Add failure unit tests to
tests/unit/proxy.test.js:- Test "HTTP 401 from token service":
axios.postrejects with{ response: { status: 401 } }; assertres.statusCode === 401,res.body === 'Unauthorized: HTTP 401' - Test "timeout (ECONNABORTED)":
axios.postrejects with{ code: 'ECONNABORTED' }; assertres.statusCode === 401,res.body === 'Unauthorized: token service timeout' - Test "timeout (ERR_CANCELED)": same as above but
{ code: 'ERR_CANCELED' }; assert body'Unauthorized: token service timeout' - Test "missing id_token in response":
axios.postresolves with{ data: { expires_in: 9999 } }(noid_token); assertres.statusCode === 401,res.body === 'Unauthorized: id_token missing from response' - Test "missing tokenUrl in adapter_settings": override
adapter_settingswithtokenUrl: ''; assertres.statusCode === 401, body matches'Unauthorized: missing required field: tokenUrl' - Test "missing username":
username: undefined; assert401, body'Unauthorized: missing required field: username' - Run
node --test tests/unit/proxy.test.js— confirm failure tests FAIL (timeout test may fail because current catch sendserr.messagedirectly, not'token service timeout')
- Test "HTTP 401 from token service":
-
T011 [P] [US3] Add 401 contract test to
tests/contract/proxy-http.test.js:- Mock token endpoint returns HTTP
401with empty body - Run proxy in VM context pointing at mock server
- Assert
res.statusCode === 401andres.bodymatches/^Unauthorized: / - Run
node --test tests/contract/proxy-http.test.js— confirm test behaviour (may already pass if catch sendserr.message; verify exact body format)
- Mock token endpoint returns HTTP
Implementation for User Story 3
-
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: ...')andid_token/expires_inabsence throws are already caught by this same block (they have no.responseand no.code, soerr.messageis used — correct).
Runnpm run test:unitandnpm 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)
- T013 [US1] Add concurrent-request unit test to
tests/unit/proxy.test.js:- Create a shared
adapter_settingsobject (no pre-seeded Redis token); slow downaxios.postmock by 50 ms usingnew Promise(resolve => setTimeout(resolve, 50))before returning the token response - Fire two
script.runInContext()calls with contexts that share the sameadapter_settingsreference (separateresobjects for each):const [r1, r2] = await Promise.all([run(ctx1), run(ctx2)]) - Assert
mockAxios.post.mock.calls.length === 1(stampede guard prevented second fetch) - Assert both
res1.statusCode === 200andres2.statusCode === 200 - Run
node --test tests/unit/proxy.test.js— confirm stampede test fails (currently two fetches are made)
- Create a shared
Implementation for Stampede Guard
-
T014 [US1] Extend
src/proxyScripts/proxy.js— add_pendingFetchguard between the cache-miss detection and theaxios.postcall:// 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') — notinstanceof Promise(fails across V8 realms per research.md R-002).
Runnpm 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
-
T015 [P] Static analysis of
src/proxyScripts/proxy.js— run the following; all must return zero matches:grep -n 'import\|export' src/proxyScripts/proxy.js # FR-009 grep -n 'process\.env\|config\b\|global\.config' src/proxyScripts/proxy.js # FR-010If any match is found, remove the offending line and re-run the full test suite.
-
T016 [P] Verify
.gitignoreand file tracking — run:git status src/globalVariables/Confirm
adapter_settings.jsonshows as ignored (not staged, not untracked in output) andadapter_settings.json.exampleis tracked (shows ingit ls-files src/globalVariables/). Ifadapter_settings.jsonis untracked (not ignored), verify T003 was applied to the correct.gitignorepath. -
T017 Run full test suite
npm test— all unit and contract tests pass with zero failures; if any test fails, fix the root cause insrc/proxyScripts/proxy.jsor the relevant test file before marking complete -
T018 Validate quickstart — follow
specs/001-oidc-proxy-script/quickstart.md:npm start— confirm structured JSON startup log includes"Loaded global data: adapter_settings"with keys["tokenUrl","username","password","clientId","scope"]curl -v http://localhost:3000/<proxy-path>— confirmHTTP/1.1 200 OKand bodyAuthorized- Send a second request — confirm adapter log shows no new
axios.postcall (cache hit) - If response is
401, check credentials inadapter_settings.jsonand 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.jsfile)
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)
- Complete Phase 1 (T001-T003): Setup files
- Complete Phase 2 (T004): Wire Redis client in server.js
- Complete Phase 3 (T005-T007): US1 tests + proxy.js core implementation
- STOP and VALIDATE:
curlthe proxy endpoint →200 OK / Authorized - Run
npm run test:unit && npm run test:contract— US1 green - Deploy / demo if ready
Incremental Delivery
- MVP above → US1 shipped
- Add Phase 4 (T008-T009) → expiry/refresh tested → US2 shipped
- Add Phase 5 (T010-T012) → failure handling tested → US3 shipped
- Add Phase 6 (T013-T014) → stampede guard tested → concurrent safety shipped
- Final Phase (T015-T018) → static analysis + smoke test → feature complete
Notes
- proxy.js has zero
import/export— all dependencies arrive viavm.createContext(); tested by T015 grep - Redis stores token + expiry (hash key
authorization, fieldstoken+expiry);adapter_settings._pendingFetchholds the stampede guard (Promises cannot be serialised to Redis) expires_inis 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()fromnode:testprovides call-count assertions without any external test framework- Duck-type stampede check (
typeof .then === 'function') is required —instanceof Promisefails across V8 realms (research.md R-002) - Commit after each task or logical group; each checkpoint is a safe stopping point