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

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.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)
  • 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.

  • 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)

  • 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)
  • 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

  • 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)

  • 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

  • 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)

  • 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')
  • 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

  • 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)

  • 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

  • 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

  • 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-010
    

    If any match is found, remove the offending line and re-run the full test suite.

  • T016 [P] Verify .gitignore and file tracking — run:

    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.

  • 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