@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +342 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +20 -4
  19. package/schemas/run-event.schema.json +2 -1
  20. package/schemas/security-advisory.schema.json +109 -0
  21. package/src/lib/a2a-fake-peer.ts +143 -56
  22. package/src/lib/behavior-gate.ts +107 -0
  23. package/src/lib/env.ts +37 -0
  24. package/src/lib/grpc-framing.test.ts +96 -0
  25. package/src/lib/grpc-framing.ts +76 -0
  26. package/src/lib/oidc-issuer.test.ts +328 -0
  27. package/src/lib/oidc-issuer.ts +241 -0
  28. package/src/lib/otel-collector-grpc.test.ts +191 -0
  29. package/src/lib/otel-collector.test.ts +303 -0
  30. package/src/lib/otel-collector.ts +318 -14
  31. package/src/lib/otlp-protobuf.test.ts +461 -0
  32. package/src/lib/otlp-protobuf.ts +529 -0
  33. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  34. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  37. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  38. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  39. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  40. package/src/scenarios/agentMetadata.test.ts +1 -0
  41. package/src/scenarios/agentPackExport.test.ts +1 -0
  42. package/src/scenarios/agentPackInstall.test.ts +1 -0
  43. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  44. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  45. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  46. package/src/scenarios/auth-mtls.test.ts +274 -0
  47. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  48. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  49. package/src/scenarios/bulk-cancel.test.ts +111 -0
  50. package/src/scenarios/configurable-schema.test.ts +48 -0
  51. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  52. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  53. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  54. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  55. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  56. package/src/scenarios/discovery.test.ts +183 -0
  57. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  58. package/src/scenarios/idempotency.test.ts +6 -0
  59. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  60. package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
  61. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  62. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  63. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  64. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  65. package/src/scenarios/metric-emission.test.ts +113 -0
  66. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  67. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  68. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  69. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  70. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  71. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  72. package/src/scenarios/pause-resume.test.ts +119 -0
  73. package/src/scenarios/production-backpressure.test.ts +342 -0
  74. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  75. package/src/scenarios/registry-public.test.ts +222 -0
  76. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  77. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  78. package/src/scenarios/restart-during-run.test.ts +177 -0
  79. package/src/scenarios/spec-corpus-validity.test.ts +59 -26
  80. package/src/scenarios/staleClaim.test.ts +3 -0
  81. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  82. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  83. package/src/scenarios/webhook-negative.test.ts +90 -0
  84. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  85. package/src/setup.ts +25 -1
  86. 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
+ }