223 lines
7.1 KiB
Markdown
223 lines
7.1 KiB
Markdown
# Contract: VM Context Dependencies
|
|
|
|
**Feature**: 001-oidc-proxy-script
|
|
**File**: `src/proxyScripts/proxy.js`
|
|
**Injector**: `src/server.js` via `vm.createContext()`
|
|
**Date**: 2025-07-17
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
`proxy.js` runs in a Node.js VM sandbox. It has **zero access** to the Node.js module system
|
|
(`require`, `import`). All dependencies are injected by `server.js` through the
|
|
`vm.createContext()` object. This document specifies exactly what `proxy.js` depends on and
|
|
what `server.js` must provide.
|
|
|
|
---
|
|
|
|
## Required Context Variables
|
|
|
|
These MUST be present in every `vm.createContext()` call. Their absence will cause a
|
|
runtime `ReferenceError` inside the sandbox.
|
|
|
|
### `adapter_settings` — OIDC Configuration
|
|
|
|
Loaded from `src/globalVariables/adapter_settings.json` by `loadGlobalVariables()`.
|
|
|
|
| Property | Type | Required | Used for |
|
|
|----------|------|----------|---------|
|
|
| `tokenUrl` | `string` | ✅ | OIDC token endpoint URL (POST target) |
|
|
| `username` | `string` | ✅ | ROPC grant — resource owner username |
|
|
| `password` | `string` | ✅ | ROPC grant — resource owner password |
|
|
| `clientId` | `string` | ✅ | OAuth 2.0 client_id parameter |
|
|
| `scope` | `string` | ✅ | OAuth 2.0 scope parameter |
|
|
| `_pendingFetch` | `Promise \| null \| undefined` | — | In-flight stampede guard, initialised by proxy.js at runtime |
|
|
|
|
The `_pendingFetch` property is NOT in the JSON file; proxy.js adds it at runtime. Token and expiry are stored in Redis, not on this object.
|
|
|
|
### `redis` — Redis Client
|
|
|
|
Injected as a global. Used for persistent token caching across VM invocations.
|
|
|
|
| Method used | Signature | Purpose |
|
|
|------------|-----------|---------|
|
|
| `redis.hSet(key, field, value)` | `Promise<number>` | Store token or expiry in hash |
|
|
| `redis.hGet(key, field)` | `Promise<string \| null>` | Read token or expiry from hash |
|
|
|
|
Usage pattern in proxy.js:
|
|
|
|
```javascript
|
|
// Write
|
|
await redis.hSet('authorization', 'token', idToken);
|
|
await redis.hSet('authorization', 'expiry', String(expiresIn));
|
|
|
|
// Read
|
|
const token = await redis.hGet('authorization', 'token');
|
|
const expiry = parseFloat(await redis.hGet('authorization', 'expiry') ?? '0');
|
|
```
|
|
|
|
### `axios` — HTTP Client
|
|
|
|
Standard `axios` instance from the `axios` npm package (v1.x).
|
|
|
|
| Method used | Signature | Purpose |
|
|
|------------|-----------|---------|
|
|
| `axios.post(url, data, config)` | `Promise<AxiosResponse>` | POST to token endpoint |
|
|
|
|
Config properties used by proxy.js:
|
|
|
|
```javascript
|
|
{
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
timeout: 5000 // milliseconds — FR-014
|
|
}
|
|
```
|
|
|
|
### `URLSearchParams` — Form Body Builder
|
|
|
|
Global Web API (Node.js 18+ built-in).
|
|
|
|
| Usage | Purpose |
|
|
|-------|---------|
|
|
| `new URLSearchParams({ ... })` | Build `application/x-www-form-urlencoded` body |
|
|
|
|
### `console` — Structured Logger
|
|
|
|
Custom logger from `src/logger.js` (injected as `console` so proxy.js uses it transparently).
|
|
|
|
| Method | Used when |
|
|
|--------|----------|
|
|
| `console.error({ message, error })` | Authentication failure or unexpected error |
|
|
|
|
### `req` — HTTP Request Object
|
|
|
|
Node.js `http.IncomingMessage`, fresh per request.
|
|
|
|
| Property | Type | Usage |
|
|
|----------|------|-------|
|
|
| *(none)* | — | `proxy.js` does not read any request properties in this feature; `req` is present by convention |
|
|
|
|
### `res` — HTTP Response Object
|
|
|
|
Node.js `http.ServerResponse`, fresh per request.
|
|
|
|
| Method | Signature | Called when |
|
|
|--------|-----------|-------------|
|
|
| `res.writeHead(statusCode, headers)` | `void` | Before sending body |
|
|
| `res.end(body)` | `void` | Send response body and close |
|
|
|
|
---
|
|
|
|
## Optional / Unused Context Variables
|
|
|
|
These are injected by `server.js` (`globalVMContext`) and are available to proxy.js but
|
|
**not used** by this feature's implementation:
|
|
|
|
| Variable | Type | Reason unused |
|
|
|----------|------|--------------|
|
|
| `URL` | Web API | URL parsing not needed |
|
|
| `crypto` | Web Crypto API | No UUID or crypto ops in this script |
|
|
| `jwt` | jsonwebtoken | No JWT signing/verification needed |
|
|
| `uuidv4` | uuid function | No request-ID generation needed |
|
|
| `xmlBuilder` | xmlbuilder2 | No XML output |
|
|
|
|
---
|
|
|
|
## Injection Pattern (server.js)
|
|
|
|
```javascript
|
|
// Static VM context (compiled once at startup)
|
|
const globalVMContext = {
|
|
URLSearchParams, // ← used by proxy.js
|
|
URL,
|
|
console: logger, // ← used by proxy.js
|
|
crypto,
|
|
axios, // ← used by proxy.js
|
|
uuidv4,
|
|
jwt,
|
|
xmlBuilder,
|
|
redis, // ← used by proxy.js (token cache)
|
|
};
|
|
|
|
// Dynamic data (loaded from src/globalVariables/ at startup)
|
|
let globalVariableContext = {};
|
|
loadGlobalVariables(); // populates globalVariableContext.adapter_settings
|
|
|
|
// Per-request context (fresh sandbox each time)
|
|
const context = vm.createContext({
|
|
...globalVMContext, // spread — object refs, not clones
|
|
...globalVariableContext, // includes adapter_settings ← used by proxy.js
|
|
req, // fresh per request
|
|
res, // fresh per request
|
|
});
|
|
script.runInContext(context);
|
|
```
|
|
|
|
---
|
|
|
|
## State Ownership
|
|
|
|
| State | Lives on | Lifetime | Owned by |
|
|
|-------|----------|---------|---------|
|
|
| OIDC credentials | `adapter_settings` (JSON properties) | Process lifetime | `server.js` (loads from file) |
|
|
| Cached token | Redis hash `authorization`, field `token` | Until expiry / Redis flush | `proxy.js` (writes on fetch) |
|
|
| Token expiry | Redis hash `authorization`, field `expiry` | Until expiry / Redis flush | `proxy.js` (writes on fetch) |
|
|
| In-flight fetch promise | `adapter_settings._pendingFetch` (runtime property) | Duration of one fetch | `proxy.js` |
|
|
|
|
**Key invariant**: `adapter_settings` is the *same JS object reference* in every
|
|
`vm.createContext()` call. The `_pendingFetch` property written by `proxy.js` persists
|
|
across requests for stampede guarding. Token data is read from and written to Redis,
|
|
making it durable across adapter restarts.
|
|
|
|
---
|
|
|
|
## Test Contract (Minimal Fake Context)
|
|
|
|
A test context satisfying this contract:
|
|
|
|
```javascript
|
|
// tests/unit/proxy.test.js
|
|
import vm from 'node:vm';
|
|
|
|
function makeContext(t, overrides = {}) {
|
|
// In-memory Redis hash store fake
|
|
const _store = {};
|
|
const redis = {
|
|
hSet: t.mock.fn(async (key, field, value) => {
|
|
_store[`${key}:${field}`] = value;
|
|
return 1;
|
|
}),
|
|
hGet: t.mock.fn(async (key, field) => _store[`${key}:${field}`] ?? null),
|
|
};
|
|
|
|
let statusCode = null;
|
|
let body = '';
|
|
const res = {
|
|
writeHead: t.mock.fn((code) => { statusCode = code; }),
|
|
end: t.mock.fn((b = '') => { body += String(b); }),
|
|
get statusCode() { return statusCode; },
|
|
get body() { return body; },
|
|
};
|
|
const adapter_settings = {
|
|
tokenUrl: 'https://auth.example.com/token',
|
|
username: 'testuser', password: 'testpass',
|
|
clientId: 'test-client', scope: 'openid',
|
|
};
|
|
return vm.createContext({
|
|
URLSearchParams,
|
|
console,
|
|
axios: {
|
|
post: t.mock.fn(async () => ({
|
|
data: { id_token: 'mock-token', expires_in: 9_999_999_999 },
|
|
})),
|
|
},
|
|
redis,
|
|
adapter_settings,
|
|
req: { url: '/', method: 'GET', headers: {} },
|
|
res,
|
|
...overrides,
|
|
});
|
|
}
|
|
```
|