198 lines
8.9 KiB
Markdown
198 lines
8.9 KiB
Markdown
# 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).
|