[Spec Kit] Implementation progress
This commit is contained in:
38
specs/001-oidc-proxy-script/checklists/requirements.md
Normal file
38
specs/001-oidc-proxy-script/checklists/requirements.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Specification Quality Checklist: OIDC Proxy Script Authentication
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2025-07-16
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
All checklist items pass. The spec is ready for `/speckit.plan` or `/speckit.clarify`.
|
||||
|
||||
Reviewer notes:
|
||||
- FR-009 through FR-011 reference specific file paths and injected dependencies. These are treated as architecture constraints (not implementation choices), as they are dictated by the VM sandbox execution model defined in the project constitution.
|
||||
- The `expires_in` interpretation (absolute epoch vs relative duration) is documented as an assumption based on the example value `1532618185`. If this assumption is incorrect, FR-006 should be updated accordingly before planning.
|
||||
113
specs/001-oidc-proxy-script/contracts/proxy-http.md
Normal file
113
specs/001-oidc-proxy-script/contracts/proxy-http.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Contract: Proxy HTTP Responses
|
||||
|
||||
**Feature**: 001-oidc-proxy-script
|
||||
**File**: `src/proxyScripts/proxy.js`
|
||||
**Endpoint**: Any path handled by the adapter (all requests delegated to proxy.js by server.js)
|
||||
**Date**: 2025-07-17
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`proxy.js` responds to every inbound HTTP request with exactly one of two outcomes:
|
||||
a success response (authentication succeeded) or an error response (authentication failed for
|
||||
any reason). The contract defines the exact shape of both outcomes.
|
||||
|
||||
---
|
||||
|
||||
## Success Response
|
||||
|
||||
**Trigger**: OIDC token successfully obtained (fresh fetch or valid cached token).
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain
|
||||
|
||||
Authorized
|
||||
```
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Status code | `200` |
|
||||
| Status text | `OK` |
|
||||
| `Content-Type` header | `text/plain` |
|
||||
| Body | Literal string `Authorized` (no trailing newline) |
|
||||
|
||||
**Acceptance test** (FR-007, SC-001, SC-002):
|
||||
|
||||
```javascript
|
||||
assert.strictEqual(res.statusCode, 200);
|
||||
assert.strictEqual(res.body, 'Authorized');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Response
|
||||
|
||||
**Trigger**: Any of the following (FR-008, SC-004):
|
||||
- Token service returns HTTP 4xx or 5xx
|
||||
- Token service is unreachable (network error)
|
||||
- Token request times out after 5 seconds (FR-014)
|
||||
- Token service response is missing `id_token` or `expires_in`
|
||||
- `adapter_settings` is missing a required field
|
||||
|
||||
```
|
||||
HTTP/1.1 401 Unauthorized
|
||||
Content-Type: text/plain
|
||||
|
||||
Unauthorized: <descriptive message>
|
||||
```
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Status code | `401` |
|
||||
| Status text | `Unauthorized` |
|
||||
| `Content-Type` header | `text/plain` |
|
||||
| Body prefix | `Unauthorized: ` (literal, followed by the error message) |
|
||||
| Body | Never empty; always includes a human-readable description |
|
||||
|
||||
**Example bodies by error cause**:
|
||||
|
||||
| Cause | Example body |
|
||||
|-------|-------------|
|
||||
| Invalid credentials (401 from token service) | `Unauthorized: HTTP 401` |
|
||||
| Token service unavailable | `Unauthorized: connect ECONNREFUSED 127.0.0.1:443` |
|
||||
| 5-second timeout | `Unauthorized: token service timeout` |
|
||||
| Response missing `id_token` | `Unauthorized: id_token missing from response` |
|
||||
| Response missing `expires_in` | `Unauthorized: expires_in missing from response` |
|
||||
| Missing `tokenUrl` in settings | `Unauthorized: missing required field: tokenUrl` |
|
||||
|
||||
**Acceptance test** (FR-008):
|
||||
|
||||
```javascript
|
||||
assert.strictEqual(res.statusCode, 401);
|
||||
assert.match(res.body, /^Unauthorized: .+/);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Invariants
|
||||
|
||||
These MUST hold for every request, regardless of outcome:
|
||||
|
||||
1. **One response per request**: `res.writeHead()` MUST be called exactly once;
|
||||
`res.end()` MUST be called exactly once.
|
||||
2. **Never 500**: `proxy.js` MUST NOT emit a 500 or leave the connection open. All
|
||||
errors, including unexpected runtime errors, MUST result in a `401` (not a crash or hang).
|
||||
3. **No imports/exports** (FR-009): The script MUST contain zero `import` or `export`
|
||||
statements — verified by static analysis.
|
||||
4. **No forbidden globals** (FR-010): No `config`, `global.config`, or `process.env`
|
||||
references — verified by static analysis.
|
||||
5. **Response within 5 seconds** (SC-001, FR-014): The HTTP timeout on the token POST
|
||||
is 5 000 ms. Combined with synchronous error handling, every request resolves within
|
||||
5 seconds under normal network conditions.
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- The proxy script does NOT validate the inbound request (method, path, headers, body).
|
||||
Its sole responsibility is OIDC authentication.
|
||||
- The response does NOT include the OIDC token in the body. The `200 OK / Authorized`
|
||||
body is sufficient to confirm authentication succeeded (spec assumption, line 106).
|
||||
- No `Authorization` response header is set. The adapter's caller does not require it.
|
||||
222
specs/001-oidc-proxy-script/contracts/vm-context.md
Normal file
222
specs/001-oidc-proxy-script/contracts/vm-context.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 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,
|
||||
});
|
||||
}
|
||||
```
|
||||
197
specs/001-oidc-proxy-script/data-model.md
Normal file
197
specs/001-oidc-proxy-script/data-model.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Data Model: OIDC Proxy Script Authentication
|
||||
|
||||
**Feature**: 001-oidc-proxy-script
|
||||
**Phase**: 1 — Design
|
||||
**Date**: 2025-07-17
|
||||
|
||||
---
|
||||
|
||||
## Entities
|
||||
|
||||
### 1. AdapterSettings
|
||||
|
||||
Persisted as `src/globalVariables/adapter_settings.json`. Loaded at server startup by
|
||||
`loadGlobalVariables()` and injected into every VM context as the `adapter_settings` variable.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `tokenUrl` | `string` | ✅ | Full HTTPS URL of the OIDC token endpoint |
|
||||
| `username` | `string` | ✅ | Resource owner username (ROPC grant) |
|
||||
| `password` | `string` | ✅ | Resource owner password |
|
||||
| `clientId` | `string` | ✅ | OAuth 2.0 client identifier |
|
||||
| `scope` | `string` | ✅ | Space-separated scopes, e.g. `"openid tags content_entitlements"` |
|
||||
|
||||
**JSON Schema**:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"required": ["tokenUrl", "username", "password", "clientId", "scope"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"tokenUrl": { "type": "string", "format": "uri" },
|
||||
"username": { "type": "string", "minLength": 1 },
|
||||
"password": { "type": "string", "minLength": 1 },
|
||||
"clientId": { "type": "string", "minLength": 1 },
|
||||
"scope": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example** (`src/globalVariables/adapter_settings.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"tokenUrl": "https://auth.kme.example.com/protocol/openid-connect/token",
|
||||
"username": "service-account@example.com",
|
||||
"password": "s3cr3t",
|
||||
"clientId": "kme-content-adapter",
|
||||
"scope": "openid tags content_entitlements"
|
||||
}
|
||||
```
|
||||
|
||||
> **Security**: This file MUST be excluded from version control (`.gitignore`).
|
||||
> Provide `src/globalVariables/adapter_settings.json.example` with placeholder values.
|
||||
|
||||
---
|
||||
|
||||
### 2. TokenCache (Redis-backed)
|
||||
|
||||
Persisted in Redis as a hash under the key `authorization`. Written by `proxy.js` after
|
||||
a successful token fetch; read on every request to check validity.
|
||||
|
||||
| Redis field | Type (stored) | Description |
|
||||
|-------------|--------------|-------------|
|
||||
| `token` | `string` | The cached `id_token` value |
|
||||
| `expiry` | `string` (numeric) | Absolute Unix epoch timestamp (seconds) from `expires_in`; `"0"` or absent means expired |
|
||||
|
||||
**Initialisation**: No pre-seeding required. `redis.hGet` returns `null` for absent fields,
|
||||
which proxy.js treats as expired.
|
||||
|
||||
**In-process stampede guard** (not in Redis):
|
||||
|
||||
```javascript
|
||||
// adapter_settings._pendingFetch — set by proxy.js at runtime
|
||||
adapter_settings._pendingFetch = fetchPromise; // set before fetch
|
||||
adapter_settings._pendingFetch = null; // cleared in finally block
|
||||
```
|
||||
|
||||
**Read pattern** (proxy.js):
|
||||
|
||||
```javascript
|
||||
const token = await redis.hGet('authorization', 'token');
|
||||
const expiry = parseFloat(await redis.hGet('authorization', 'expiry') ?? '0');
|
||||
const isValid = token !== null && Date.now() / 1000 < expiry;
|
||||
```
|
||||
|
||||
**Write pattern** (proxy.js, after successful fetch):
|
||||
|
||||
```javascript
|
||||
await redis.hSet('authorization', 'token', idToken);
|
||||
await redis.hSet('authorization', 'expiry', String(expiresIn));
|
||||
```
|
||||
|
||||
**State transitions**:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
┌────────────▼──────────────┐ fetch starts ┌──────────────────┐│
|
||||
│ EMPTY / EXPIRED │─────────────────────▶│ FETCHING ││
|
||||
│ token: null / expiry ≤ now│ │ pendingFetch: P ││
|
||||
└───────────────────────────┘ └──────┬───────────┘│
|
||||
▲ │ │
|
||||
│ token expires fetch settles│ │
|
||||
│ (Date.now()/1000 ≥ expiry) ┌───────────┴──────────┐ │
|
||||
│ │ │ │
|
||||
│ success │ failure │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────┐│
|
||||
│ │ VALID │ │ ERROR ││
|
||||
└──────────────────────│ token: "abc…" │ │ token: null ││
|
||||
│ expiry: 99999… │ │ expiry: 0 ││
|
||||
└──────────────────┘ └──────────────┘│
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
(back to EMPTY / EXPIRED)
|
||||
```
|
||||
|
||||
**Validity rule** (FR-006):
|
||||
|
||||
```javascript
|
||||
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. OidcTokenResponse (external — token service)
|
||||
|
||||
Returned by the OIDC token service in response to a successful POST. Only the fields used by
|
||||
`proxy.js` are listed; additional fields may be present and are ignored.
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id_token` | `string` | Bearer token to cache and use for authentication (FR-004) |
|
||||
| `expires_in` | `number` | Absolute Unix epoch timestamp (seconds) when token expires (FR-006) |
|
||||
|
||||
**Error response** (HTTP 4xx from token service): Not parsed; the HTTP status code alone is
|
||||
sufficient to trigger a 401 response to the caller.
|
||||
|
||||
---
|
||||
|
||||
### 4. ProxyResponse (outbound HTTP)
|
||||
|
||||
The HTTP response sent by `proxy.js` back to the caller via the injected `res` object.
|
||||
|
||||
| Scenario | Status | Content-Type | Body |
|
||||
|----------|--------|-------------|------|
|
||||
| Authentication success | `200 OK` | `text/plain` | `Authorized` |
|
||||
| Auth failure (any cause) | `401 Unauthorized` | `text/plain` | `Unauthorized: <message>` |
|
||||
|
||||
---
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
server.js
|
||||
│
|
||||
├── loadGlobalVariables()
|
||||
│ └── reads adapter_settings.json ──────────► AdapterSettings
|
||||
│
|
||||
├── redis (injected into VM context) ───────────► Redis Store
|
||||
│ └── hash: authorization
|
||||
│ ├── token
|
||||
│ └── expiry
|
||||
│
|
||||
└── vm.createContext({ ...globalVariableContext, redis, req, res })
|
||||
│
|
||||
└── script.runInContext(context) [proxy.js]
|
||||
│
|
||||
├── reads adapter_settings ─────────► AdapterSettings
|
||||
├── reads/writes redis ──────────────► Redis Store (token + expiry)
|
||||
├── sets adapter_settings._pendingFetch (stampede guard, in-process)
|
||||
├── POST tokenUrl ────────────────────► OIDC Token Service
|
||||
│ └── receives ─────────────────► OidcTokenResponse
|
||||
└── writes res ──────────────────────► ProxyResponse
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Rules
|
||||
|
||||
| Rule | Location | Behaviour on violation |
|
||||
|------|----------|----------------------|
|
||||
| `tokenUrl` present | proxy.js startup | 401 with `'missing required field: tokenUrl'` |
|
||||
| `username` present | proxy.js startup | 401 with `'missing required field: username'` |
|
||||
| `password` present | proxy.js startup | 401 with `'missing required field: password'` |
|
||||
| `clientId` present | proxy.js startup | 401 with `'missing required field: clientId'` |
|
||||
| `scope` present | proxy.js startup | 401 with `'missing required field: scope'` |
|
||||
| `id_token` present in response | proxy.js after fetch | 401 with `'id_token missing from response'` |
|
||||
| `expires_in` present in response | proxy.js after fetch | 401 with `'expires_in missing from response'` |
|
||||
| Redis `hSet` / `hGet` available | proxy.js on every request | `ReferenceError` (server.js must inject `redis`) |
|
||||
|
||||
> Validation errors are caught by the top-level `catch` block in proxy.js and result in a
|
||||
> `401 Unauthorized` response, never a process crash (SC-004).
|
||||
108
specs/001-oidc-proxy-script/plan.md
Normal file
108
specs/001-oidc-proxy-script/plan.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Implementation Plan: OIDC Proxy Script Authentication
|
||||
|
||||
**Branch**: `001-oidc-proxy-script` | **Date**: 2025-07-17 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `specs/001-oidc-proxy-script/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Create `src/globalVariables/adapter_settings.json` with OIDC credentials and implement
|
||||
`src/proxyScripts/proxy.js` — a zero-import/export Node.js VM-sandbox script that:
|
||||
|
||||
- Reads OIDC credentials exclusively from the injected `adapter_settings` context variable (FR-001)
|
||||
- POSTs to the configured `tokenUrl` with `application/x-www-form-urlencoded` body and a 5-second
|
||||
timeout (FR-003, FR-014)
|
||||
- Extracts the bearer token from the `id_token` field of the JSON response (FR-004)
|
||||
- Caches the token in **Redis** (`redis.hSet('authorization', 'token', ...)` / `redis.hSet('authorization', 'expiry', ...)`) using `expires_in` as an **absolute Unix epoch timestamp** (FR-005, FR-006)
|
||||
- Queues concurrent callers on a shared promise to prevent token-fetch stampedes (FR-013)
|
||||
- Returns `200 OK / Authorized` on success and `401 Unauthorized` with a descriptive message on any
|
||||
failure (FR-007, FR-008)
|
||||
|
||||
No modifications to `server.js` are required. The existing `loadGlobalVariables()` pattern
|
||||
automatically picks up `adapter_settings.json` and injects it as `adapter_settings` into every VM
|
||||
context.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Node.js 18+ (ES Modules; `"type": "module"` in package.json)
|
||||
**Primary Dependencies**: `axios` ^1.13.6, `uuid` ^13.0.0, `jsonwebtoken` ^9.0.3,
|
||||
`xmlbuilder2` ^4.0.3 — all already present in `package.json`; no new packages required
|
||||
**Storage**: Redis — token and expiry stored in hash key `authorization` via `redis.hSet`/`hGet`; `redis` is injected into the VM context as a global. An in-process `adapter_settings._pendingFetch` guards against stampede (Promises cannot be serialised to Redis).
|
||||
**Testing**: Node.js built-in `node:test` runner (`node --test tests/**/*.test.js`);
|
||||
`t.mock.fn()`, `t.mock.timers` for fakes; no external test framework needed
|
||||
**Target Platform**: Linux/macOS, long-running Node.js 18+ server process
|
||||
**Project Type**: HTTP proxy adapter — VM-sandboxed script (IVA Studio proxy script pattern)
|
||||
**Performance Goals**: Every request responds within 5 s (SC-001); zero token-service round-trips
|
||||
when a valid cached token exists (SC-002)
|
||||
**Constraints**: `proxy.js` MUST have zero `import`/`export` statements; MUST NOT reference
|
||||
`config`, `global.config`, or `process.env`; all dependencies injected via `vm.createContext()`
|
||||
**Scale/Scope**: Single-process, single-tenant; one OIDC token shared across all concurrent
|
||||
requests
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| **I. Monolithic Architecture** | ✅ PASS | All auth + cache logic in `proxy.js`; no helper extraction |
|
||||
| **I. Zero Imports/Exports** | ✅ PASS | `proxy.js` uses only VM-injected globals; zero `import`/`export` |
|
||||
| **I.0 Forbidden Globals** | ✅ PASS | No `config`, `global.config`, or `process.env` in `proxy.js` |
|
||||
| **I.I What MUST be in proxy.js** | ✅ PASS | Authentication, token cache, stampede queue all in `proxy.js` |
|
||||
| **I.II Allowed Separate Files** | ✅ PASS | Only adding `adapter_settings.json` to `src/globalVariables/` |
|
||||
| **I.IV Configuration** | ✅ PASS | Credentials in `src/globalVariables/adapter_settings.json`, not `config/default.json` |
|
||||
| **I.V VM Context Injection** | ✅ PASS | `adapter_settings` auto-loaded by existing `loadGlobalVariables()` — no server.js changes |
|
||||
| **II. API-First Design** | ✅ PASS | HTTP response contract and VM context contract documented before implementation |
|
||||
| **III. Test-First Development** | ✅ PASS | Test scenarios defined; tests written before implementation code |
|
||||
|
||||
**No violations. Complexity Tracking not required.**
|
||||
|
||||
### Post-Design Re-check
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| **Cache state isolation** | ✅ PASS | Token/expiry stored in Redis hash `authorization`; in-process `adapter_settings._pendingFetch` holds stampede guard (Promise not serialisable to Redis) |
|
||||
| **Cross-realm Promise** | ✅ PASS | Stampede guard uses duck-type check (`typeof .then === 'function'`) rather than `instanceof Promise`; `await` uses the Promises/A+ thenable protocol which is cross-realm safe |
|
||||
| **Async error containment** | ✅ PASS | `script.runInContext()` is not awaited by server.js; proxy.js top-level async IIFE catches all errors internally and always sends a response |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/001-oidc-proxy-script/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 — resolved unknowns
|
||||
├── data-model.md # Phase 1 — entities and state transitions
|
||||
├── quickstart.md # Phase 1 — setup and testing guide
|
||||
├── contracts/
|
||||
│ ├── proxy-http.md # HTTP response contract (success + error)
|
||||
│ └── vm-context.md # VM dependency injection contract
|
||||
└── tasks.md # Phase 2 — task list (/speckit.tasks, not created here)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
src/
|
||||
├── proxyScripts/
|
||||
│ └── proxy.js # NEW — OIDC authentication proxy (VM sandbox)
|
||||
├── globalVariables/
|
||||
│ └── adapter_settings.json # NEW — OIDC credentials and token endpoint
|
||||
├── logger.js # EXISTING — no changes
|
||||
└── server.js # EXISTING — no changes required
|
||||
|
||||
tests/
|
||||
├── unit/
|
||||
│ └── proxy.test.js # NEW — unit tests: cache, expiry, stampede, error paths
|
||||
└── contract/
|
||||
└── proxy-http.test.js # NEW — contract tests: HTTP 200/401 response shape
|
||||
```
|
||||
|
||||
**Structure Decision**: Single-project layout (Option 1). Two new source files, two new test
|
||||
files. No new directories (both `src/proxyScripts/` and `src/globalVariables/` already exist as
|
||||
defined directories in the constitution; `tests/unit/` and `tests/contract/` match the existing
|
||||
`package.json` test scripts).
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No constitution violations — this section is intentionally empty.
|
||||
196
specs/001-oidc-proxy-script/quickstart.md
Normal file
196
specs/001-oidc-proxy-script/quickstart.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Quickstart: OIDC Proxy Script Authentication
|
||||
|
||||
**Feature**: 001-oidc-proxy-script
|
||||
**Date**: 2025-07-17
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18.0.0 or later (`node --version`)
|
||||
- Access to a KME OIDC token service endpoint
|
||||
- Project dependencies installed (`npm install`)
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create `adapter_settings.json`
|
||||
|
||||
```bash
|
||||
cd /path/to/kme-content-adapter
|
||||
cp src/globalVariables/adapter_settings.json.example \
|
||||
src/globalVariables/adapter_settings.json
|
||||
```
|
||||
|
||||
Edit `src/globalVariables/adapter_settings.json` with your real credentials:
|
||||
|
||||
```json
|
||||
{
|
||||
"tokenUrl": "https://auth.kme.example.com/protocol/openid-connect/token",
|
||||
"username": "your-service-account@example.com",
|
||||
"password": "your-password",
|
||||
"clientId": "your-client-id",
|
||||
"scope": "openid tags content_entitlements"
|
||||
}
|
||||
```
|
||||
|
||||
> **Security**: `adapter_settings.json` is in `.gitignore`. Never commit credentials.
|
||||
> The `.example` file (with placeholder values) IS committed and serves as the template.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Start the Adapter
|
||||
|
||||
```bash
|
||||
npm start
|
||||
# or for development with auto-reload:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Expected startup log (structured JSON):
|
||||
|
||||
```json
|
||||
{"message": "Loaded global data: adapter_settings", "keys": ["tokenUrl","username","password","clientId","scope"]}
|
||||
{"message": "Loaded 1 global variables", "json": 1, "js": 0}
|
||||
{"message": "Configuration loaded", "port": 3000, "host": "0.0.0.0", ...}
|
||||
{"message": "Configuration validated successfully"}
|
||||
{"message": "Server listening", "port": 3000, "host": "0.0.0.0"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Send a Test Request
|
||||
|
||||
```bash
|
||||
curl -v http://localhost:3000/ProxyScript/run/67bca862210071627d32ef12/current/kmeAdapter
|
||||
```
|
||||
|
||||
**Expected response on first request** (token fetched from OIDC service):
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain
|
||||
|
||||
Authorized
|
||||
```
|
||||
|
||||
**Expected response on subsequent requests** (token served from cache):
|
||||
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/plain
|
||||
|
||||
Authorized
|
||||
```
|
||||
|
||||
No visible difference from the caller's perspective; the adapter log will show no new
|
||||
axios call for the second request (cache hit).
|
||||
|
||||
**Expected response with invalid credentials**:
|
||||
|
||||
```
|
||||
HTTP/1.1 401 Unauthorized
|
||||
Content-Type: text/plain
|
||||
|
||||
Unauthorized: HTTP 401
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Run Tests
|
||||
|
||||
### Unit tests (fast, no network, no server required)
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
# runs: node --test tests/unit/proxy.test.js
|
||||
```
|
||||
|
||||
Exercises: cache hit, cache miss, token expiry, stampede prevention, timeout handling,
|
||||
missing `id_token`, missing required fields, HTTP error from token service.
|
||||
|
||||
### Contract tests (starts real HTTP server with mock token endpoint)
|
||||
|
||||
```bash
|
||||
npm run test:contract
|
||||
# runs: node --test tests/contract/proxy-http.test.js
|
||||
```
|
||||
|
||||
Exercises: end-to-end `200 OK / Authorized`, end-to-end `401 Unauthorized`, verifies
|
||||
response headers and exact body strings.
|
||||
|
||||
### All tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
# runs: node --test tests/**/*.test.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
Inbound HTTP request
|
||||
│
|
||||
▼
|
||||
server.js (http.createServer)
|
||||
│ creates fresh vm.createContext({
|
||||
│ ...globalVMContext, ← axios, URLSearchParams, console, ...
|
||||
│ ...globalVariableContext, ← adapter_settings (from JSON)
|
||||
│ req, res ← fresh per request
|
||||
│ })
|
||||
│
|
||||
▼
|
||||
proxy.js (vm.Script, compiled once)
|
||||
│
|
||||
├─ reads adapter_settings._cache
|
||||
│ ├─ CACHE HIT: token valid → 200 OK / Authorized
|
||||
│ └─ CACHE MISS / EXPIRED:
|
||||
│ ├─ FETCHING (stampede guard): queue on _cache.pendingFetch → 200/401
|
||||
│ └─ NEW FETCH:
|
||||
│ POST tokenUrl (timeout: 5s)
|
||||
│ ├─ SUCCESS: cache token + expiry → 200 OK / Authorized
|
||||
│ └─ FAILURE: → 401 Unauthorized: <message>
|
||||
▼
|
||||
HTTP response to caller
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Status | Purpose |
|
||||
|------|--------|---------|
|
||||
| `src/globalVariables/adapter_settings.json` | **Create** | OIDC credentials (gitignored) |
|
||||
| `src/globalVariables/adapter_settings.json.example` | **Create** | Template with placeholder values |
|
||||
| `src/proxyScripts/proxy.js` | **Create** | OIDC authentication proxy (VM sandbox) |
|
||||
| `tests/unit/proxy.test.js` | **Create** | Unit tests (no network, no server) |
|
||||
| `tests/contract/proxy-http.test.js` | **Create** | HTTP response contract tests |
|
||||
| `src/server.js` | **No change** | Existing infrastructure; auto-loads adapter_settings.json |
|
||||
| `config/default.json` | **No change** | Infrastructure settings only (port, host, log level) |
|
||||
|
||||
---
|
||||
|
||||
## Token Lifecycle
|
||||
|
||||
| Phase | What happens |
|
||||
|-------|-------------|
|
||||
| **First request** | `_cache.token` is null → fresh token fetch → cache `id_token` + `expires_in` |
|
||||
| **Subsequent requests (valid token)** | `Date.now()/1000 < _cache.expiry` → return `Authorized` immediately, no network call |
|
||||
| **After token expiry** | `Date.now()/1000 ≥ _cache.expiry` → fresh token fetch (transparent to caller) |
|
||||
| **Concurrent requests during fetch** | All requests `await` the shared `_cache.pendingFetch` promise; only ONE HTTP call made |
|
||||
| **Auth failure** | Clear `_cache.token` and `_cache.expiry` → respond `401 Unauthorized: <reason>` |
|
||||
| **Timeout (5 s)** | axios `ECONNABORTED` error → treated as auth failure → `401 Unauthorized` |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|-------------|-----|
|
||||
| `401 Unauthorized: HTTP 401` | Wrong credentials | Check `username`, `password`, `clientId` in `adapter_settings.json` |
|
||||
| `401 Unauthorized: connect ECONNREFUSED` | Token service unreachable | Check `tokenUrl` is correct and reachable |
|
||||
| `401 Unauthorized: token service timeout` | Network slow or token service down | Verify connectivity; check token service health |
|
||||
| `401 Unauthorized: id_token missing from response` | Token service returns `access_token` only | Ensure `openid` is in `scope` and the service issues `id_token` |
|
||||
| Server fails to start with `adapter_settings` not found | JSON file missing | Run Step 1 above |
|
||||
| `SyntaxError` in proxy.js at startup | `import` or `export` statement in proxy.js | Remove all `import`/`export` — proxy.js must be pure VM sandbox code |
|
||||
280
specs/001-oidc-proxy-script/research.md
Normal file
280
specs/001-oidc-proxy-script/research.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# 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.
|
||||
|
||||
```javascript
|
||||
// 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.
|
||||
|
||||
```javascript
|
||||
// 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.
|
||||
|
||||
```javascript
|
||||
// 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.
|
||||
|
||||
```javascript
|
||||
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).
|
||||
|
||||
```javascript
|
||||
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.
|
||||
|
||||
```javascript
|
||||
// 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`
|
||||
110
specs/001-oidc-proxy-script/spec.md
Normal file
110
specs/001-oidc-proxy-script/spec.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Feature Specification: OIDC Proxy Script Authentication
|
||||
|
||||
**Feature Branch**: `001-oidc-proxy-script`
|
||||
**Created**: 2025-07-16
|
||||
**Status**: Clarified
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Successful Authenticated Request (Priority: P1)
|
||||
|
||||
A system operator or integration consumer sends an HTTP request through the kme-content-adapter. The proxy script transparently authenticates against the KME OIDC token service using stored credentials and, upon success, returns a confirmation response to the caller.
|
||||
|
||||
**Why this priority**: This is the core behaviour of the feature — without successful authentication, the proxy script provides no value. All other stories depend on this flow working correctly.
|
||||
|
||||
**Independent Test**: Can be tested in isolation by sending any HTTP request to the adapter's proxy endpoint and confirming a `200 OK` response with an "Authorized" body is returned, verifiable without any downstream system interaction.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the adapter is running and `adapter_settings.json` contains valid credentials and a reachable token URL, **When** an HTTP request arrives at the proxy endpoint, **Then** the script obtains a valid OIDC token and responds with HTTP `200 OK` and the body `Authorized`.
|
||||
2. **Given** a valid OIDC token has already been cached and has not expired, **When** a subsequent HTTP request arrives, **Then** the script reuses the cached token without making a new authentication request, and responds with HTTP `200 OK` and the body `Authorized`.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Token Expiry and Refresh (Priority: P2)
|
||||
|
||||
The system manages token lifetime transparently. When a previously cached token has expired, the proxy script automatically obtains a fresh token before responding.
|
||||
|
||||
**Why this priority**: Without expiry handling, the proxy fails silently after the token lifetime ends, causing all requests to break until the adapter is restarted.
|
||||
|
||||
**Independent Test**: Can be tested by simulating a cached token with a past expiry time and confirming the script fetches a new token and still returns `200 OK`.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a cached token whose expiry time has passed, **When** a new HTTP request arrives, **Then** the script discards the expired token, fetches a fresh one from the token service, and responds with HTTP `200 OK` and the body `Authorized`.
|
||||
2. **Given** a cached token that is still valid, **When** checking expiry, **Then** no new token request is made to the token service.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Authentication Failure Handling (Priority: P3)
|
||||
|
||||
If the token service rejects the credentials or is unreachable, the proxy script communicates the failure clearly to the caller rather than hanging or returning a misleading success.
|
||||
|
||||
**Why this priority**: Proper error surfacing prevents silent failures that are difficult to diagnose in production.
|
||||
|
||||
**Independent Test**: Can be tested by providing invalid credentials in `adapter_settings.json` and confirming the proxy returns an appropriate HTTP error response.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the credentials in `adapter_settings.json` are invalid, **When** an HTTP request arrives, **Then** the proxy script responds with HTTP `401 Unauthorized` and an error message without crashing the adapter process.
|
||||
2. **Given** the token service URL is unreachable, **When** an HTTP request arrives, **Then** the proxy script responds with HTTP `401 Unauthorized` and a descriptive error message.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when `adapter_settings.json` is missing the `tokenUrl`, `username`, or `password` fields?
|
||||
- How does the system handle a token service response that omits the `id_token` field?
|
||||
- **[RESOLVED]** If `expires_in` is already in the past on arrival, treat as expired and fetch a fresh token immediately.
|
||||
- **[RESOLVED]** When two concurrent requests arrive while no valid token is cached (token stampede), only one token fetch is made; all others queue and share the result.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The proxy script MUST read `tokenUrl`, `username`, `password`, `clientId`, and `scope` exclusively from the `adapter_settings` injected variable (sourced from `src/globalVariables/adapter_settings.json`).
|
||||
- **FR-002**: `src/globalVariables/adapter_settings.json` MUST contain the fields `tokenUrl`, `username`, `password`, `clientId`, and `scope`.
|
||||
- **FR-003**: The proxy script MUST authenticate by sending a `POST` request to the configured `tokenUrl` with a `Content-Type: application/x-www-form-urlencoded` body containing `grant_type=password`, `username`, `password`, `client_id`, and `scope`.
|
||||
- **FR-004**: The proxy script MUST extract the bearer token from the `id_token` field of the token service's JSON response.
|
||||
- **FR-005**: The proxy script MUST cache the obtained token in Redis using `redis.hSet('authorization', 'token', token)` and `redis.hSet('authorization', 'expiry', String(expires_in))`, and reuse it for subsequent requests until it expires.
|
||||
- **FR-006**: The proxy script MUST determine token expiry by reading `redis.hGet('authorization', 'expiry')` and comparing to `Date.now() / 1000`. If the stored expiry is already in the past on read, the token MUST be treated as expired and a fresh token fetched immediately.
|
||||
- **FR-007**: The proxy script MUST respond with HTTP `200 OK` and the plain-text body `Authorized` when authentication succeeds.
|
||||
- **FR-008**: The proxy script MUST respond with HTTP `401 Unauthorized` and a descriptive plain-text message when authentication fails (invalid credentials, unreachable token service, or malformed response).
|
||||
- **FR-009**: The proxy script file (`src/proxyScripts/proxy.js`) MUST contain zero `import` or `export` statements, as it executes inside a Node.js VM sandbox.
|
||||
- **FR-010**: The proxy script MUST NOT reference `config`, `global.config`, or `process.env` for any configuration or credential values.
|
||||
- **FR-011**: The proxy script MUST use only dependencies injected via the VM context: `axios`, `console`, `crypto`, `jwt`, `uuidv4`, `xmlBuilder`, `URLSearchParams`, `URL`, and `redis`.
|
||||
- **FR-012**: `req` and `res` must be treated as the injected Node.js HTTP request and response objects; no other I/O mechanism may be used.
|
||||
- **FR-013**: When two or more concurrent requests arrive while no valid token is cached, only one token fetch request MUST be made to the token service; all other requests MUST queue and share the result of that single fetch.
|
||||
- **FR-014**: The token POST request to the OIDC service MUST apply a 5-second HTTP timeout; a timeout error MUST be treated as an authentication failure (FR-008).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **adapter_settings**: Configuration object loaded from `src/globalVariables/adapter_settings.json` and injected into the VM context. Contains `tokenUrl`, `username`, `password`, `clientId`, and `scope`.
|
||||
- **OIDC Token**: A short-lived bearer token issued by the KME OIDC token service. Identified by its `id_token` field; lifetime communicated via `expires_in` (Unix epoch seconds).
|
||||
- **Token Cache**: Token and expiry stored in Redis under the hash key `authorization` (fields `token` and `expiry`). An in-process `pendingFetch` property on `adapter_settings` guards against stampede within the single adapter process (Promises cannot be serialised to Redis).
|
||||
- **Proxy Request/Response**: The Node.js `req` and `res` HTTP objects injected into the VM, representing the inbound caller and the outbound reply.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Every inbound request to the proxy endpoint receives a response (success or error) within 5 seconds under normal network conditions.
|
||||
- **SC-002**: After the first successful authentication, all subsequent requests with a valid cached token complete without contacting the token service, reducing per-request authentication overhead to zero network round-trips.
|
||||
- **SC-003**: Token refresh occurs automatically with no manual intervention required when a cached token expires; the caller receives `200 OK` transparently.
|
||||
- **SC-004**: 100% of authentication failures (bad credentials, network errors, malformed responses) result in HTTP `401 Unauthorized` rather than an unhandled exception or process crash.
|
||||
- **SC-005**: The proxy script introduces no new global state, file system access, or environment variable reads — verifiable by static inspection of the file (zero import/export/process.env/config references).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The KME OIDC token service's `expires_in` field represents an **absolute Unix epoch timestamp in seconds** (not a relative duration in seconds), as implied by the example value `1532618185`. Expiry check: `Date.now() / 1000 < expires_in`. If the value is already past on receipt, the token is immediately considered expired.
|
||||
- Token and expiry are persisted in Redis (`redis.hSet / hGet` on hash key `authorization`), surviving adapter restarts and shared across any future processes.
|
||||
- The in-process `pendingFetch` stampede guard is retained on `adapter_settings` because Promises cannot be serialised to Redis; this is appropriate for the current single-process deployment.
|
||||
- Concurrent requests during a token fetch are queued; only one fetch is in-flight at any time (no stampede).
|
||||
- Authentication failures return HTTP `401 Unauthorized` with a plain-text error body.
|
||||
- The token POST request carries a 5-second timeout.
|
||||
- The adapter process remains long-running; module-level variable caching is therefore effective across multiple requests without requiring an external cache.
|
||||
- Only one token is needed at a time; there is no multi-tenant or per-user token requirement for this proxy script.
|
||||
- The `scope` value `openid tags content_entitlements` is fixed and not expected to vary per request.
|
||||
- The caller of the proxy endpoint does not require the actual OIDC token in the response body; the `200 OK / Authorized` reply is sufficient to confirm authentication succeeded.
|
||||
- Error responses should be plain text to keep the script simple; no structured error body format is required.
|
||||
- The VM context is always initialised with all listed dependencies (`axios`, `console`, `crypto`, `jwt`, `uuidv4`, `xmlBuilder`, `URLSearchParams`, `URL`) before the script executes.
|
||||
365
specs/001-oidc-proxy-script/tasks.md
Normal file
365
specs/001-oidc-proxy-script/tasks.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# 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.
|
||||
|
||||
- [X] 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"`)
|
||||
- [X] 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)
|
||||
- [X] 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.
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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'`)
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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)
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] 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
|
||||
|
||||
- [X] T015 [P] Static analysis of `src/proxyScripts/proxy.js` — run the following; all must return zero matches:
|
||||
```bash
|
||||
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.
|
||||
|
||||
- [X] T016 [P] Verify `.gitignore` and file tracking — run:
|
||||
```bash
|
||||
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.
|
||||
|
||||
- [X] 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
|
||||
Reference in New Issue
Block a user