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

11 KiB
Raw Blame History

Research: OIDC Proxy Script Authentication

Feature: 001-oidc-proxy-script
Phase: 0 — Resolved unknowns
Date: 2025-07-17


R-001 — Token Cache Persistence in VM Sandbox

Unknown: How can proxy.js maintain a token cache that survives across requests when server.js creates a fresh vm.createContext() per request (resetting all bare let variables)?

Decision: Store the cache as a property on the adapter_settings object (adapter_settings._cache).

Rationale: server.js loads adapter_settings.json into globalVariableContext.adapter_settings once at startup. The per-request spread vm.createContext({ ...globalVariableContext }) copies the object reference — not a clone — into each sandbox. Any mutation to adapter_settings._cache modifies the same underlying heap object and is therefore visible to every subsequent request. Bare let/const variables at the top level of proxy.js do not persist; they are sandbox-local and are reset to undefined on every invocation.

// proxy.js — top of script (safe, no import/export)
// adapter_settings is the same JS object reference every invocation:
const _cache = adapter_settings._cache ||
  (adapter_settings._cache = { token: null, expiry: 0, pendingFetch: null });

Alternatives considered:

Alternative Why rejected
Bare let cachedToken in proxy.js Resets on every vm.createContext() invocation
Add dedicated _cache to globalVariableContext in server.js Correct but requires server.js modification; feature spec says no server.js changes needed
External Redis / file cache Introduces infrastructure dependency; spec assumption explicitly rules this out
Attach to axios or another injected object Correct mechanism but semantically wrong; adapter_settings is the natural owner

Verification: globalVariableContext is a module-level let in server.js (line 27). The spread in vm.createContext (lines 172177) copies each property value by reference for objects. adapter_settings is an object, so the sandbox and globalVariableContext share the same reference. Confirmed by reading src/server.js.


R-002 — Token Stampede Prevention (Promise Sharing)

Unknown: When multiple concurrent requests arrive while _cache.token is null, how do we ensure only one HTTP request is sent to the token service?

Decision: Store the in-flight fetch promise on _cache.pendingFetch. Subsequent requests detect a non-null pendingFetch (via duck-type check) and await the same promise. A finally block clears pendingFetch after settlement.

Rationale: The pendingFetch property is on the shared adapter_settings._cache object (same reference as R-001). All concurrent requests therefore see the same pending Promise.

Cross-realm safety: Each vm.createContext() creates a new V8 realm with its own Promise constructor. instanceof Promise checks fail across realms. The Promises/A+ thenable protocol (await and .then()) works via duck-typing and is cross-realm safe.

// Stampede guard — duck-type check, NOT instanceof
if (_cache.pendingFetch !== null &&
    typeof _cache.pendingFetch.then === 'function') {
  // Queue on the existing fetch — await is thenable-protocol safe across V8 realms
  try {
    await _cache.pendingFetch;
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Authorized');
  } catch (err) {
    res.writeHead(401, { 'Content-Type': 'text/plain' });
    res.end('Unauthorized: ' + err.message);
  }
  return;
}

// This invocation wins the race — build and share the fetch promise
_cache.pendingFetch = (async () => {
  // ... axios.post ...
  _cache.token = id_token;
  _cache.expiry = expires_in; // absolute Unix epoch seconds (FR-006)
})();

try {
  await _cache.pendingFetch;
  res.writeHead(200, { 'Content-Type': 'text/plain' });
  res.end('Authorized');
} catch (err) {
  _cache.token = null;
  _cache.expiry = 0;
  res.writeHead(401, { 'Content-Type': 'text/plain' });
  res.end('Unauthorized: ' + err.message);
} finally {
  _cache.pendingFetch = null; // clear after all awaiters have resolved/rejected
}

Why finally is safe for clearing pendingFetch: By the time finally runs, all callers that await-ed _cache.pendingFetch have already received the settled value. Setting pendingFetch = null only affects future requests arriving after settlement.

Alternatives considered:

Alternative Why rejected
instanceof Promise guard Fails across V8 realms — each sandbox has its own Promise constructor
Mutex / lock via integer flag More complex; promise sharing is idiomatic in async JS
Separate request queue (array + callbacks) Overkill; promise sharing achieves the same result with fewer lines

R-003 — Async Proxy Script in Synchronous runInContext

Unknown: server.js calls script.runInContext(context) synchronously without await. How can proxy.js do async work (HTTP call) without leaving unhandled promise rejections?

Decision: Wrap all proxy logic in a top-level immediately-invoked async function expression (IIFE). Catch all errors inside the IIFE and always send a response before the IIFE resolves.

Rationale: The async IIFE returns a Promise but server.js does not await it. The outer try/catch in server.js only catches synchronous throws. All async errors must be handled within proxy.js itself.

// proxy.js — entire body wrapped in async IIFE
(async () => {
  try {
    // ... token logic ...
    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);
  }
})();

Constraint: res.end() MUST be called in both the try and catch paths. If res.end() is not called, the HTTP connection hangs for the caller.


R-004 — Absolute Epoch Expiry Check

Unknown: The spec states expires_in is an absolute Unix epoch timestamp (not a relative duration). What is the correct expiry check?

Decision: Date.now() / 1000 < _cache.expiry — token is valid when current Unix time (seconds) is strictly less than the stored expires_in value.

Rationale: Confirmed in spec (line 99): "Expiry check: Date.now() / 1000 < expires_in". Example value 1532618185 is a Unix timestamp, not a duration.

function isTokenValid() {
  return _cache.token !== null && Date.now() / 1000 < _cache.expiry;
}

Edge cases:

  • expires_in already in the past on receipt → isTokenValid() returns false immediately → fresh token fetched (FR-006 compliance)
  • expires_in is 0 or negative → treated as expired
  • No safety buffer is applied; the spec does not require one

R-005 — axios OIDC POST Pattern

Decision: Pass URLSearchParams instance as body; set Content-Type header explicitly for clarity; use timeout: 5000 for the 5-second limit (FR-014).

const params = new URLSearchParams({
  grant_type: 'password',
  username:   adapter_settings.username,
  password:   adapter_settings.password,
  client_id:  adapter_settings.clientId,
  scope:      adapter_settings.scope,
});

const response = await axios.post(adapter_settings.tokenUrl, params, {
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  timeout: 5000,
});

Content-Type note: axios v1.x auto-detects URLSearchParams and sets application/x-www-form-urlencoded automatically. The explicit header is belt-and-suspenders for readability in the VM sandbox context.


R-006 — axios Error Differentiation

Decision: Treat all axios errors as authentication failures (FR-008). Derive the error message from the error type for clarity in the 401 body:

Error condition error.response error.code Message strategy
HTTP 4xx/5xx from token service Populated HTTP ${error.response.status}
Timeout (5 s) undefined 'ECONNABORTED' / 'ERR_CANCELED' 'token service timeout'
Network failure (DNS, TCP) undefined 'ERR_NETWORK' error.message
Missing id_token in response 'id_token missing from response'
Missing adapter_settings fields 'missing required field: <name>'

All cases route to the same 401 response path, satisfying FR-008 and SC-004.


R-007 — Testing VM-Sandboxed Code with node:test

Decision: Two test layers, no external test framework:

  1. Unit tests (tests/unit/proxy.test.js): Compile proxy.js once with new vm.Script(). Each test creates a controlled fake context (mock axios, controllable _cache, mock res). Use await script.runInContext(ctx) to drive the async IIFE. Use t.mock.fn() for call-count assertions; t.mock.timers for time-travel expiry tests.

  2. Contract tests (tests/contract/proxy-http.test.js): Start an actual HTTP server with a mock token endpoint (using http.createServer) and assert real HTTP responses. Validates end-to-end behaviour including server.js context injection.

Key insight: Because proxy.js receives all dependencies via the VM context object, dependency injection IS the test seam. No module-level mocking (jest.mock equivalent) is needed or available — the context object serves the same role.

// tests/unit/proxy.test.js — shared setup pattern
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const proxyCode = readFileSync(
  join(__dirname, '../../src/proxyScripts/proxy.js'), 'utf-8'
);
const script = new vm.Script(proxyCode, { filename: 'proxy.js' });

function makeContext(t, overrides = {}) {
  const _cache = { token: null, expiry: 0, pendingFetch: null };
  let statusCode = null;
  let body = '';
  const res = {
    writeHead: t.mock.fn((code) => { statusCode = code; }),
    end:       t.mock.fn((b = '') => { body += b; }),
    get statusCode() { return statusCode; },
    get body()       { return body; },
  };
  return vm.createContext({
    URLSearchParams,
    URL,
    console,
    axios: {
      post: t.mock.fn(async () => ({
        data: { id_token: 'test-token', expires_in: 9_999_999_999 }
      }))
    },
    adapter_settings: {
      tokenUrl:  'https://auth.example.com/token',
      username:  'user',
      password:  'pass',
      clientId:  'client',
      scope:     'openid',
      _cache,
    },
    req: { url: '/proxy', method: 'GET', headers: {} },
    res,
    ...overrides,
  });
}

Run command: node --test tests/unit/proxy.test.js