@openwop/openwop-conformance 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +342 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +20 -4
- package/schemas/run-event.schema.json +2 -1
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +107 -0
- package/src/lib/env.ts +37 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +222 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +59 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- package/vitest.config.ts +5 -1
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-free unit tests for the synthetic OIDC issuer harness.
|
|
3
|
+
*
|
|
4
|
+
* The harness is real cryptographic code (RS256 + ES256 JWS signing,
|
|
5
|
+
* JWKS export, JWT compact serialization). If the signing or encoding
|
|
6
|
+
* is wrong, every scenario that uses the harness silently misreports —
|
|
7
|
+
* the OIDC validation scenarios soft-skip behavior portions when the
|
|
8
|
+
* host doesn't trust the harness, so a malformed token would simply
|
|
9
|
+
* cause the host to reject and the test to "pass" via soft-skip path.
|
|
10
|
+
*
|
|
11
|
+
* These unit tests round-trip every token through `node:crypto.createVerify`
|
|
12
|
+
* to confirm the harness output is parseable by an independent verifier.
|
|
13
|
+
* Run server-free; doesn't depend on OPENWOP_BASE_URL.
|
|
14
|
+
*
|
|
15
|
+
* @see conformance/src/lib/oidc-issuer.ts
|
|
16
|
+
* @see RFCS/0010-auth-profile-conformance.md §E
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { createPublicKey, createVerify, type JsonWebKey } from 'node:crypto';
|
|
21
|
+
import { createSyntheticOIDCIssuer } from './oidc-issuer.js';
|
|
22
|
+
|
|
23
|
+
function base64UrlDecode(input: string): Buffer {
|
|
24
|
+
const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4);
|
|
25
|
+
const padded = input + '='.repeat(pad);
|
|
26
|
+
const std = padded.replace(/-/g, '+').replace(/_/g, '/');
|
|
27
|
+
return Buffer.from(std, 'base64');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function decodeJwt(token: string): {
|
|
31
|
+
header: Record<string, unknown>;
|
|
32
|
+
payload: Record<string, unknown>;
|
|
33
|
+
signature: Buffer;
|
|
34
|
+
signingInput: string;
|
|
35
|
+
} {
|
|
36
|
+
const parts = token.split('.');
|
|
37
|
+
if (parts.length !== 3) throw new Error(`malformed JWT: ${parts.length} segments`);
|
|
38
|
+
const [h, p, s] = parts;
|
|
39
|
+
return {
|
|
40
|
+
header: JSON.parse(base64UrlDecode(h).toString('utf8')) as Record<string, unknown>,
|
|
41
|
+
payload: JSON.parse(base64UrlDecode(p).toString('utf8')) as Record<string, unknown>,
|
|
42
|
+
signature: base64UrlDecode(s),
|
|
43
|
+
signingInput: `${h}.${p}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function verifyToken(
|
|
48
|
+
token: string,
|
|
49
|
+
jwksJson: string,
|
|
50
|
+
algorithm: 'RS256' | 'ES256',
|
|
51
|
+
): boolean {
|
|
52
|
+
const decoded = decodeJwt(token);
|
|
53
|
+
const jwks = JSON.parse(jwksJson) as { keys: JsonWebKey[] };
|
|
54
|
+
const kid = decoded.header.kid;
|
|
55
|
+
const key = jwks.keys.find((k) => k.kid === kid);
|
|
56
|
+
if (!key) throw new Error(`no JWKS key matches kid=${String(kid)}`);
|
|
57
|
+
|
|
58
|
+
const publicKey = createPublicKey({ key, format: 'jwk' });
|
|
59
|
+
const digest = algorithm === 'RS256' ? 'RSA-SHA256' : 'SHA256';
|
|
60
|
+
const verifier = createVerify(digest);
|
|
61
|
+
verifier.update(decoded.signingInput);
|
|
62
|
+
verifier.end();
|
|
63
|
+
return verifier.verify(
|
|
64
|
+
algorithm === 'RS256'
|
|
65
|
+
? publicKey
|
|
66
|
+
: { key: publicKey, dsaEncoding: 'ieee-p1363' },
|
|
67
|
+
decoded.signature,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('oidc-issuer: harness construction', () => {
|
|
72
|
+
it('requires issuer and audience', () => {
|
|
73
|
+
expect(() =>
|
|
74
|
+
createSyntheticOIDCIssuer({ issuer: '', audience: 'openwop' }),
|
|
75
|
+
).toThrow(/issuer and audience/);
|
|
76
|
+
expect(() =>
|
|
77
|
+
createSyntheticOIDCIssuer({ issuer: 'https://x', audience: '' }),
|
|
78
|
+
).toThrow(/issuer and audience/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('defaults to RS256 + canonical keyId', () => {
|
|
82
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
83
|
+
issuer: 'https://harness.example',
|
|
84
|
+
audience: 'openwop',
|
|
85
|
+
});
|
|
86
|
+
expect(issuer.algorithm).toBe('RS256');
|
|
87
|
+
expect(issuer.keyId).toBe('openwop-conformance-key-1');
|
|
88
|
+
expect(issuer.issuer).toBe('https://harness.example');
|
|
89
|
+
expect(issuer.audience).toBe('openwop');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects unsupported algorithm at runtime (defensive)', () => {
|
|
93
|
+
expect(() =>
|
|
94
|
+
createSyntheticOIDCIssuer({
|
|
95
|
+
issuer: 'https://x',
|
|
96
|
+
audience: 'y',
|
|
97
|
+
algorithm: 'HS256',
|
|
98
|
+
}),
|
|
99
|
+
).toThrow(/unsupported algorithm/);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('oidc-issuer: JWKS + discovery shape', () => {
|
|
104
|
+
it('publishes a well-formed JWKS for RS256', () => {
|
|
105
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
106
|
+
issuer: 'https://harness.example',
|
|
107
|
+
audience: 'openwop',
|
|
108
|
+
});
|
|
109
|
+
const jwks = JSON.parse(issuer.jwksJson) as { keys: JsonWebKey[] };
|
|
110
|
+
expect(Array.isArray(jwks.keys)).toBe(true);
|
|
111
|
+
expect(jwks.keys.length).toBe(1);
|
|
112
|
+
const key = jwks.keys[0];
|
|
113
|
+
expect(key.kty).toBe('RSA');
|
|
114
|
+
expect(key.alg).toBe('RS256');
|
|
115
|
+
expect(key.use).toBe('sig');
|
|
116
|
+
expect(key.kid).toBe(issuer.keyId);
|
|
117
|
+
// RSA JWK MUST have n (modulus) and e (exponent).
|
|
118
|
+
expect(typeof key.n).toBe('string');
|
|
119
|
+
expect(typeof key.e).toBe('string');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('publishes a well-formed JWKS for ES256', () => {
|
|
123
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
124
|
+
issuer: 'https://harness.example',
|
|
125
|
+
audience: 'openwop',
|
|
126
|
+
algorithm: 'ES256',
|
|
127
|
+
});
|
|
128
|
+
const jwks = JSON.parse(issuer.jwksJson) as { keys: JsonWebKey[] };
|
|
129
|
+
const key = jwks.keys[0];
|
|
130
|
+
expect(key.kty).toBe('EC');
|
|
131
|
+
expect(key.alg).toBe('ES256');
|
|
132
|
+
expect(key.crv).toBe('P-256');
|
|
133
|
+
expect(typeof key.x).toBe('string');
|
|
134
|
+
expect(typeof key.y).toBe('string');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('publishes OIDC discovery doc with correct shape', () => {
|
|
138
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
139
|
+
issuer: 'https://harness.example/oauth',
|
|
140
|
+
audience: 'openwop',
|
|
141
|
+
});
|
|
142
|
+
const disco = JSON.parse(issuer.discoveryJson) as {
|
|
143
|
+
issuer: string;
|
|
144
|
+
jwks_uri: string;
|
|
145
|
+
response_types_supported: string[];
|
|
146
|
+
subject_types_supported: string[];
|
|
147
|
+
id_token_signing_alg_values_supported: string[];
|
|
148
|
+
};
|
|
149
|
+
expect(disco.issuer).toBe('https://harness.example/oauth');
|
|
150
|
+
expect(disco.jwks_uri).toBe('https://harness.example/oauth/.well-known/jwks.json');
|
|
151
|
+
expect(disco.id_token_signing_alg_values_supported).toContain('RS256');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('discovery doc strips trailing slash before appending jwks path', () => {
|
|
155
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
156
|
+
issuer: 'https://harness.example/',
|
|
157
|
+
audience: 'openwop',
|
|
158
|
+
});
|
|
159
|
+
const disco = JSON.parse(issuer.discoveryJson) as { jwks_uri: string };
|
|
160
|
+
expect(disco.jwks_uri).toBe('https://harness.example/.well-known/jwks.json');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('oidc-issuer: mint defaults', () => {
|
|
165
|
+
it('fills iss / aud / iat / exp when not supplied', () => {
|
|
166
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
167
|
+
issuer: 'https://harness.example',
|
|
168
|
+
audience: 'openwop',
|
|
169
|
+
});
|
|
170
|
+
const before = Math.floor(Date.now() / 1000);
|
|
171
|
+
const { claims } = issuer.mint({ sub: 'test-sub' });
|
|
172
|
+
const after = Math.floor(Date.now() / 1000);
|
|
173
|
+
|
|
174
|
+
expect(claims.iss).toBe('https://harness.example');
|
|
175
|
+
expect(claims.aud).toBe('openwop');
|
|
176
|
+
expect(claims.sub).toBe('test-sub');
|
|
177
|
+
expect(typeof claims.iat).toBe('number');
|
|
178
|
+
expect(typeof claims.exp).toBe('number');
|
|
179
|
+
expect(claims.iat).toBeGreaterThanOrEqual(before);
|
|
180
|
+
expect(claims.iat).toBeLessThanOrEqual(after);
|
|
181
|
+
// Default lifetime is 300s.
|
|
182
|
+
expect((claims.exp as number) - (claims.iat as number)).toBe(300);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('caller claims override defaults', () => {
|
|
186
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
187
|
+
issuer: 'https://harness.example',
|
|
188
|
+
audience: 'openwop',
|
|
189
|
+
});
|
|
190
|
+
const { claims } = issuer.mint({
|
|
191
|
+
iss: 'override-issuer',
|
|
192
|
+
aud: 'override-audience',
|
|
193
|
+
sub: 'test-sub',
|
|
194
|
+
});
|
|
195
|
+
expect(claims.iss).toBe('override-issuer');
|
|
196
|
+
expect(claims.aud).toBe('override-audience');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('negative expiresInSeconds mints already-expired token', () => {
|
|
200
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
201
|
+
issuer: 'https://harness.example',
|
|
202
|
+
audience: 'openwop',
|
|
203
|
+
});
|
|
204
|
+
const now = Math.floor(Date.now() / 1000);
|
|
205
|
+
const { claims } = issuer.mint(
|
|
206
|
+
{ sub: 'test-sub' },
|
|
207
|
+
{ expiresInSeconds: -3600 },
|
|
208
|
+
);
|
|
209
|
+
expect((claims.exp as number) < now).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('oidc-issuer: signature round-trip', () => {
|
|
214
|
+
it('RS256 token verifies against published JWKS', () => {
|
|
215
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
216
|
+
issuer: 'https://harness.example',
|
|
217
|
+
audience: 'openwop',
|
|
218
|
+
algorithm: 'RS256',
|
|
219
|
+
});
|
|
220
|
+
const { token } = issuer.mint({ sub: 'test-sub' });
|
|
221
|
+
const verified = verifyToken(token, issuer.jwksJson, 'RS256');
|
|
222
|
+
expect(verified).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('ES256 token verifies against published JWKS', () => {
|
|
226
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
227
|
+
issuer: 'https://harness.example',
|
|
228
|
+
audience: 'openwop',
|
|
229
|
+
algorithm: 'ES256',
|
|
230
|
+
});
|
|
231
|
+
const { token } = issuer.mint({ sub: 'test-sub' });
|
|
232
|
+
const verified = verifyToken(token, issuer.jwksJson, 'ES256');
|
|
233
|
+
expect(verified).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('header alg matches issuer algorithm by default', () => {
|
|
237
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
238
|
+
issuer: 'https://harness.example',
|
|
239
|
+
audience: 'openwop',
|
|
240
|
+
algorithm: 'ES256',
|
|
241
|
+
});
|
|
242
|
+
const { token } = issuer.mint({ sub: 'test-sub' });
|
|
243
|
+
const decoded = decodeJwt(token);
|
|
244
|
+
expect(decoded.header.alg).toBe('ES256');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('mint opts.algorithm override appears in header (alg-spoof scenario)', () => {
|
|
248
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
249
|
+
issuer: 'https://harness.example',
|
|
250
|
+
audience: 'openwop',
|
|
251
|
+
algorithm: 'RS256',
|
|
252
|
+
});
|
|
253
|
+
const { token } = issuer.mint(
|
|
254
|
+
{ sub: 'test-sub' },
|
|
255
|
+
{ algorithm: 'HS256' },
|
|
256
|
+
);
|
|
257
|
+
const decoded = decodeJwt(token);
|
|
258
|
+
expect(decoded.header.alg).toBe('HS256');
|
|
259
|
+
// The signature is still RS256-bytes (the harness doesn't actually
|
|
260
|
+
// honor the alg override for the signature itself — that's the spoof:
|
|
261
|
+
// the header lies, the bytes don't match). Verification with RS256
|
|
262
|
+
// succeeds, which is the test scenario's correct behavior: it lets
|
|
263
|
+
// the OAuth2-CC negative-case scenario assert the host rejects
|
|
264
|
+
// because the header claims HS256 outside supportedAlgorithms.
|
|
265
|
+
const verified = verifyToken(
|
|
266
|
+
// Pull alg from header for verification — but the verify path
|
|
267
|
+
// is RS256 because that's the actual key. Re-decode and verify
|
|
268
|
+
// by extracting the alg-from-issuer rather than alg-from-header.
|
|
269
|
+
token,
|
|
270
|
+
issuer.jwksJson,
|
|
271
|
+
'RS256',
|
|
272
|
+
);
|
|
273
|
+
expect(verified).toBe(true);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('keyId override sets header.kid without changing signing key (unknown-kid scenario)', () => {
|
|
277
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
278
|
+
issuer: 'https://harness.example',
|
|
279
|
+
audience: 'openwop',
|
|
280
|
+
});
|
|
281
|
+
const { token } = issuer.mint(
|
|
282
|
+
{ sub: 'test-sub' },
|
|
283
|
+
{ keyId: 'never-published-kid' },
|
|
284
|
+
);
|
|
285
|
+
const decoded = decodeJwt(token);
|
|
286
|
+
expect(decoded.header.kid).toBe('never-published-kid');
|
|
287
|
+
// The JWKS doesn't publish this kid; verifyToken throws.
|
|
288
|
+
expect(() => verifyToken(token, issuer.jwksJson, 'RS256')).toThrow(/no JWKS key/);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe('oidc-issuer: key rotation', () => {
|
|
293
|
+
it('rotateKey() changes the published keyId', () => {
|
|
294
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
295
|
+
issuer: 'https://harness.example',
|
|
296
|
+
audience: 'openwop',
|
|
297
|
+
});
|
|
298
|
+
const firstKid = issuer.keyId;
|
|
299
|
+
issuer.rotateKey();
|
|
300
|
+
expect(issuer.keyId).not.toBe(firstKid);
|
|
301
|
+
expect(issuer.keyId).toBe('openwop-conformance-key-2');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('tokens minted before rotation no longer verify against new JWKS', () => {
|
|
305
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
306
|
+
issuer: 'https://harness.example',
|
|
307
|
+
audience: 'openwop',
|
|
308
|
+
});
|
|
309
|
+
const beforeRotation = issuer.mint({ sub: 'test-sub' });
|
|
310
|
+
issuer.rotateKey();
|
|
311
|
+
// The JWKS now publishes a different key. The old token's header
|
|
312
|
+
// kid still references the pre-rotation kid, which isn't published.
|
|
313
|
+
expect(() =>
|
|
314
|
+
verifyToken(beforeRotation.token, issuer.jwksJson, 'RS256'),
|
|
315
|
+
).toThrow(/no JWKS key/);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('tokens minted after rotation verify against new JWKS', () => {
|
|
319
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
320
|
+
issuer: 'https://harness.example',
|
|
321
|
+
audience: 'openwop',
|
|
322
|
+
});
|
|
323
|
+
issuer.rotateKey();
|
|
324
|
+
const afterRotation = issuer.mint({ sub: 'test-sub' });
|
|
325
|
+
const verified = verifyToken(afterRotation.token, issuer.jwksJson, 'RS256');
|
|
326
|
+
expect(verified).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic OIDC issuer for conformance scenarios.
|
|
3
|
+
*
|
|
4
|
+
* Implements the harness specified in RFC 0010 §E. Mints signed JWTs
|
|
5
|
+
* (RS256 or ES256) and exposes the JWKS + OIDC discovery document a
|
|
6
|
+
* trusting host fetches to verify them. Hermetic — uses only node:crypto
|
|
7
|
+
* stdlib; no npm dependencies.
|
|
8
|
+
*
|
|
9
|
+
* Scope: this harness is a wire-shape probe. It is NOT a real OIDC
|
|
10
|
+
* provider — there is no authorization endpoint, no userinfo endpoint,
|
|
11
|
+
* no refresh-token machinery. The conformance suite uses it to mint
|
|
12
|
+
* tokens with controlled claims (valid sub, wrong aud, expired exp,
|
|
13
|
+
* unknown kid, etc.) and assert the host's validation behavior.
|
|
14
|
+
*
|
|
15
|
+
* @see RFCS/0010-auth-profile-conformance.md §E
|
|
16
|
+
* @see spec/v1/auth-profiles.md §`openwop-auth-oidc-user-bearer`
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createSign,
|
|
21
|
+
generateKeyPairSync,
|
|
22
|
+
type KeyObject,
|
|
23
|
+
} from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
export type JwsAlgorithm = 'RS256' | 'ES256';
|
|
26
|
+
|
|
27
|
+
export interface SyntheticOIDCIssuerOptions {
|
|
28
|
+
/** Base issuer URL. Caller is responsible for binding an HTTP server
|
|
29
|
+
* at this URL if end-to-end host validation is required. The harness
|
|
30
|
+
* itself does not bind any port. */
|
|
31
|
+
readonly issuer: string;
|
|
32
|
+
/** Default audience used by `mint()` when claims don't supply one. */
|
|
33
|
+
readonly audience: string;
|
|
34
|
+
/** JWS algorithm. Default RS256 (widest interop). Accepts `string` so
|
|
35
|
+
* conformance tests can exercise the runtime rejection path for
|
|
36
|
+
* unsupported algorithms; the constructor validates at runtime. */
|
|
37
|
+
readonly algorithm?: JwsAlgorithm | (string & {});
|
|
38
|
+
/** Initial key id published in JWKS. Default `openwop-conformance-key-1`. */
|
|
39
|
+
readonly keyId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MintOptions {
|
|
43
|
+
/** Override the JWT lifetime. Default 300 seconds. Set < 0 to mint
|
|
44
|
+
* an already-expired token. */
|
|
45
|
+
readonly expiresInSeconds?: number;
|
|
46
|
+
/** Override the `kid` placed in the JWT header. Defaults to the
|
|
47
|
+
* issuer's current `keyId`. Setting to a value not published in the
|
|
48
|
+
* issuer's JWKS produces a token that signature-verifies internally
|
|
49
|
+
* but is rejected by hosts because the kid cannot be resolved. */
|
|
50
|
+
readonly keyId?: string;
|
|
51
|
+
/** Override the JWS algorithm header (`alg`). Defaults to the
|
|
52
|
+
* issuer's algorithm. Setting to a value not in the host's
|
|
53
|
+
* `supportedAlgorithms` produces an algorithm-rejected token. */
|
|
54
|
+
readonly algorithm?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MintedToken {
|
|
58
|
+
/** Compact-serialized JWT (`<header>.<payload>.<signature>`). */
|
|
59
|
+
readonly token: string;
|
|
60
|
+
/** The fully-resolved claim set that was signed. */
|
|
61
|
+
readonly claims: Readonly<Record<string, unknown>>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SyntheticOIDCIssuer {
|
|
65
|
+
readonly issuer: string;
|
|
66
|
+
readonly audience: string;
|
|
67
|
+
readonly algorithm: JwsAlgorithm;
|
|
68
|
+
/** Current key id (used by default in `mint()` and published in JWKS). */
|
|
69
|
+
readonly keyId: string;
|
|
70
|
+
/** JWKS document the host fetches at `$issuer/.well-known/jwks.json`. */
|
|
71
|
+
readonly jwksJson: string;
|
|
72
|
+
/** OIDC discovery document the host fetches at
|
|
73
|
+
* `$issuer/.well-known/openid-configuration`. */
|
|
74
|
+
readonly discoveryJson: string;
|
|
75
|
+
|
|
76
|
+
/** Mint a signed JWT with the supplied claims. Claims not supplied
|
|
77
|
+
* are filled with defaults: `iss` (this issuer), `aud` (this
|
|
78
|
+
* audience), `iat` (now), `exp` (now + 300s). Pass `iss`/`aud`/`exp`
|
|
79
|
+
* explicitly to override. */
|
|
80
|
+
mint(claims: Readonly<Record<string, unknown>>, opts?: MintOptions): MintedToken;
|
|
81
|
+
|
|
82
|
+
/** Replace the current keypair with a freshly-generated one and
|
|
83
|
+
* advance the `keyId`. Tokens minted before rotation are no longer
|
|
84
|
+
* verifiable against the published JWKS — the underlying key is
|
|
85
|
+
* discarded. Use to model post-rotation revocation in tests. */
|
|
86
|
+
rotateKey(): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface KeyMaterial {
|
|
90
|
+
readonly keyId: string;
|
|
91
|
+
readonly publicKey: KeyObject;
|
|
92
|
+
readonly privateKey: KeyObject;
|
|
93
|
+
/** Cached JWK form of the public key (with `kid`/`alg`/`use` mixed in). */
|
|
94
|
+
readonly publicJwk: Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function base64UrlEncode(input: Buffer | string): string {
|
|
98
|
+
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
|
|
99
|
+
return buf
|
|
100
|
+
.toString('base64')
|
|
101
|
+
.replace(/\+/g, '-')
|
|
102
|
+
.replace(/\//g, '_')
|
|
103
|
+
.replace(/=+$/, '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isJwsAlgorithm(x: string): x is JwsAlgorithm {
|
|
107
|
+
return x === 'RS256' || x === 'ES256';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function generateKeyMaterial(algorithm: JwsAlgorithm, keyId: string): KeyMaterial {
|
|
111
|
+
const { publicKey, privateKey } =
|
|
112
|
+
algorithm === 'RS256'
|
|
113
|
+
? generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
114
|
+
: generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
115
|
+
|
|
116
|
+
// node:crypto exports public keys directly to JWK form (Node ≥ 16).
|
|
117
|
+
// The return type is `JsonWebKey` from the global DOM lib; we widen
|
|
118
|
+
// to a structural record so we can spread + mix in OIDC-flavored
|
|
119
|
+
// fields (`kid`, `alg`, `use`) without further assertions.
|
|
120
|
+
const baseJwk: Record<string, unknown> = publicKey.export({ format: 'jwk' });
|
|
121
|
+
const publicJwk: Record<string, unknown> = {
|
|
122
|
+
...baseJwk,
|
|
123
|
+
alg: algorithm,
|
|
124
|
+
use: 'sig',
|
|
125
|
+
kid: keyId,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return { keyId, publicKey, privateKey, publicJwk };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function signCompact(
|
|
132
|
+
algorithm: JwsAlgorithm,
|
|
133
|
+
privateKey: KeyObject,
|
|
134
|
+
signingInput: string,
|
|
135
|
+
): string {
|
|
136
|
+
// Node's createSign uses the algorithm name to pick the digest:
|
|
137
|
+
// RSA-SHA256 → RSASSA-PKCS1-v1_5 with SHA-256 (RS256)
|
|
138
|
+
// SHA256 → ECDSA with SHA-256 (ES256)
|
|
139
|
+
// For ES256, JWS REQUIRES the IEEE P1363 (R||S) signature format,
|
|
140
|
+
// not the default DER. Node ≥ 17 supports `dsaEncoding: 'ieee-p1363'`
|
|
141
|
+
// on createSign to produce P1363 directly.
|
|
142
|
+
const digest = algorithm === 'RS256' ? 'RSA-SHA256' : 'SHA256';
|
|
143
|
+
const signer = createSign(digest);
|
|
144
|
+
signer.update(signingInput);
|
|
145
|
+
signer.end();
|
|
146
|
+
const signature = signer.sign(
|
|
147
|
+
algorithm === 'RS256'
|
|
148
|
+
? privateKey
|
|
149
|
+
: { key: privateKey, dsaEncoding: 'ieee-p1363' },
|
|
150
|
+
);
|
|
151
|
+
return base64UrlEncode(signature);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createSyntheticOIDCIssuer(
|
|
155
|
+
opts: SyntheticOIDCIssuerOptions,
|
|
156
|
+
): SyntheticOIDCIssuer {
|
|
157
|
+
const requested = opts.algorithm ?? 'RS256';
|
|
158
|
+
if (!isJwsAlgorithm(requested)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`[oidc-issuer] unsupported algorithm: ${String(requested)} (only RS256 and ES256 are supported)`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const algorithm: JwsAlgorithm = requested;
|
|
164
|
+
|
|
165
|
+
if (!opts.issuer || !opts.audience) {
|
|
166
|
+
throw new Error('[oidc-issuer] issuer and audience are required');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let rotationCounter = 1;
|
|
170
|
+
let material = generateKeyMaterial(
|
|
171
|
+
algorithm,
|
|
172
|
+
opts.keyId ?? `openwop-conformance-key-${rotationCounter}`,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
get issuer() {
|
|
177
|
+
return opts.issuer;
|
|
178
|
+
},
|
|
179
|
+
get audience() {
|
|
180
|
+
return opts.audience;
|
|
181
|
+
},
|
|
182
|
+
get algorithm() {
|
|
183
|
+
return algorithm;
|
|
184
|
+
},
|
|
185
|
+
get keyId() {
|
|
186
|
+
return material.keyId;
|
|
187
|
+
},
|
|
188
|
+
get jwksJson() {
|
|
189
|
+
return JSON.stringify({ keys: [material.publicJwk] });
|
|
190
|
+
},
|
|
191
|
+
get discoveryJson() {
|
|
192
|
+
return JSON.stringify({
|
|
193
|
+
issuer: opts.issuer,
|
|
194
|
+
jwks_uri: `${opts.issuer.replace(/\/$/, '')}/.well-known/jwks.json`,
|
|
195
|
+
response_types_supported: ['id_token'],
|
|
196
|
+
subject_types_supported: ['public'],
|
|
197
|
+
id_token_signing_alg_values_supported: [algorithm],
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
mint(
|
|
202
|
+
claims: Readonly<Record<string, unknown>>,
|
|
203
|
+
mintOpts: MintOptions = {},
|
|
204
|
+
): MintedToken {
|
|
205
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
206
|
+
const expiresInSeconds = mintOpts.expiresInSeconds ?? 300;
|
|
207
|
+
|
|
208
|
+
const resolvedClaims: Record<string, unknown> = {
|
|
209
|
+
iss: opts.issuer,
|
|
210
|
+
aud: opts.audience,
|
|
211
|
+
iat: nowSeconds,
|
|
212
|
+
exp: nowSeconds + expiresInSeconds,
|
|
213
|
+
...claims, // caller's claims win on collision (sub, aud override, etc.)
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const header = {
|
|
217
|
+
alg: mintOpts.algorithm ?? algorithm,
|
|
218
|
+
typ: 'JWT',
|
|
219
|
+
kid: mintOpts.keyId ?? material.keyId,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
223
|
+
const payloadB64 = base64UrlEncode(JSON.stringify(resolvedClaims));
|
|
224
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
225
|
+
const signatureB64 = signCompact(algorithm, material.privateKey, signingInput);
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
token: `${signingInput}.${signatureB64}`,
|
|
229
|
+
claims: resolvedClaims,
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
rotateKey(): void {
|
|
234
|
+
rotationCounter += 1;
|
|
235
|
+
material = generateKeyMaterial(
|
|
236
|
+
algorithm,
|
|
237
|
+
`openwop-conformance-key-${rotationCounter}`,
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|