[Spec Kit] Implementation progress

This commit is contained in:
2026-04-22 19:41:14 -05:00
parent 24cfd85ac2
commit 110c2e961b
61 changed files with 5371 additions and 38 deletions

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