11 KiB
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 172–177) 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_inalready in the past on receipt →isTokenValid()returnsfalseimmediately → fresh token fetched (FR-006 compliance)expires_inis0or 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:
-
Unit tests (
tests/unit/proxy.test.js): Compileproxy.jsonce withnew vm.Script(). Each test creates a controlled fake context (mockaxios, controllable_cache, mockres). Useawait script.runInContext(ctx)to drive the async IIFE. Uset.mock.fn()for call-count assertions;t.mock.timersfor time-travel expiry tests. -
Contract tests (
tests/contract/proxy-http.test.js): Start an actual HTTP server with a mock token endpoint (usinghttp.createServer) and assert real HTTP responses. Validates end-to-end behaviour includingserver.jscontext 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