@openwop/openwop-conformance 1.0.0 → 1.1.0

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 (80) 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 +293 -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 +2 -2
  19. package/schemas/security-advisory.schema.json +109 -0
  20. package/src/lib/a2a-fake-peer.ts +143 -56
  21. package/src/lib/behavior-gate.ts +68 -0
  22. package/src/lib/env.ts +10 -0
  23. package/src/lib/grpc-framing.test.ts +96 -0
  24. package/src/lib/grpc-framing.ts +76 -0
  25. package/src/lib/oidc-issuer.test.ts +328 -0
  26. package/src/lib/oidc-issuer.ts +241 -0
  27. package/src/lib/otel-collector-grpc.test.ts +191 -0
  28. package/src/lib/otel-collector.test.ts +303 -0
  29. package/src/lib/otel-collector.ts +318 -14
  30. package/src/lib/otlp-protobuf.test.ts +461 -0
  31. package/src/lib/otlp-protobuf.ts +529 -0
  32. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  33. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  34. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  37. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  38. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  39. package/src/scenarios/agentMetadata.test.ts +1 -0
  40. package/src/scenarios/agentPackExport.test.ts +1 -0
  41. package/src/scenarios/agentPackInstall.test.ts +1 -0
  42. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  43. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  44. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  45. package/src/scenarios/auth-mtls.test.ts +274 -0
  46. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  47. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  48. package/src/scenarios/bulk-cancel.test.ts +111 -0
  49. package/src/scenarios/configurable-schema.test.ts +48 -0
  50. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  51. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  52. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  53. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  54. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  55. package/src/scenarios/discovery.test.ts +183 -0
  56. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  57. package/src/scenarios/idempotency.test.ts +6 -0
  58. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  59. package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
  60. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  61. package/src/scenarios/metric-emission.test.ts +113 -0
  62. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  63. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  64. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  65. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  66. package/src/scenarios/pause-resume.test.ts +119 -0
  67. package/src/scenarios/production-backpressure.test.ts +342 -0
  68. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  69. package/src/scenarios/registry-public.test.ts +131 -0
  70. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  71. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  72. package/src/scenarios/restart-during-run.test.ts +177 -0
  73. package/src/scenarios/spec-corpus-validity.test.ts +54 -26
  74. package/src/scenarios/staleClaim.test.ts +3 -0
  75. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  76. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  77. package/src/scenarios/webhook-negative.test.ts +90 -0
  78. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  79. package/src/setup.ts +25 -1
  80. package/vitest.config.ts +5 -1
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Behavior-gate helper for capability-gated conformance scenarios.
3
+ *
4
+ * Some scenarios in `conformance/src/scenarios/` validate optional profiles
5
+ * — audit-log integrity, rate-limit envelope, multi-region idempotency,
6
+ * `configurableSchema`, webhook signature versioning, pause/resume, etc.
7
+ * When a host doesn't advertise the profile, those scenarios have two
8
+ * defensible modes:
9
+ *
10
+ * - **Default (skip):** log a warning and return early. The suite still
11
+ * passes overall, reflecting what the host has implemented. This is
12
+ * what `@openwop/openwop-conformance` runs by default so a v1.0-only
13
+ * host doesn't suddenly fail the suite when new optional profiles ship.
14
+ *
15
+ * - **Behavior-required (fail):** set `OPENWOP_REQUIRE_BEHAVIOR=true` to
16
+ * turn missing advertisements into hard failures. A "passing" run with
17
+ * this flag means the host advertises every optional profile AND every
18
+ * scenario exercises real behavior — useful for hosts that want to
19
+ * claim full coverage in `INTEROP-MATRIX.md`.
20
+ *
21
+ * Usage:
22
+ *
23
+ * ```ts
24
+ * import { behaviorGate } from '../lib/behavior-gate.js';
25
+ *
26
+ * it('host that claims the profile advertises the right fields', async () => {
27
+ * const advertised = await isProfileAdvertised();
28
+ * if (!behaviorGate('openwop-audit-log-integrity', advertised)) {
29
+ * return; // skipped in default mode; FAIL'd in strict mode
30
+ * }
31
+ *
32
+ * // ... assertions ...
33
+ * });
34
+ * ```
35
+ *
36
+ * In strict mode, `behaviorGate` throws an assertion error with a citation
37
+ * to the relevant spec section so the failure message is self-explanatory.
38
+ */
39
+
40
+ import { expect } from 'vitest';
41
+ import { loadEnv } from './env.js';
42
+
43
+ /**
44
+ * Returns true if the scenario should proceed with assertions (advertised),
45
+ * false if the scenario should `return` early (default-mode skip). In strict
46
+ * mode (`OPENWOP_REQUIRE_BEHAVIOR=true`), throws if not advertised — so the
47
+ * caller never actually receives `false` in that mode.
48
+ */
49
+ export function behaviorGate(profileName: string, advertised: boolean): boolean {
50
+ if (advertised) return true;
51
+
52
+ const env = loadEnv();
53
+ if (env.requireBehavior) {
54
+ expect(
55
+ advertised,
56
+ `OPENWOP_REQUIRE_BEHAVIOR=true: host MUST advertise the ${profileName} profile for this scenario to run. ` +
57
+ `See conformance/coverage.md §"Capability-gated scenarios".`,
58
+ ).toBe(true);
59
+ // expect.toBe(true) throws; we won't reach here.
60
+ }
61
+
62
+ // Default-mode soft-skip.
63
+ // eslint-disable-next-line no-console
64
+ console.warn(
65
+ `[${profileName}] profile not advertised; skipping (set OPENWOP_REQUIRE_BEHAVIOR=true to fail)`,
66
+ );
67
+ return false;
68
+ }
package/src/lib/env.ts CHANGED
@@ -8,6 +8,14 @@
8
8
  * Optional (cosmetic — surfaced in failure messages):
9
9
  * OPENWOP_IMPLEMENTATION_NAME — e.g., "acme-openwop-server"
10
10
  * OPENWOP_IMPLEMENTATION_VERSION — e.g., "1.0"
11
+ *
12
+ * Optional (behavior-gate strictness):
13
+ * OPENWOP_REQUIRE_BEHAVIOR=true — capability-gated scenarios (audit-log
14
+ * integrity, rate-limit envelope, multi-region idempotency, etc.) FAIL
15
+ * instead of skipping when the host doesn't advertise the profile.
16
+ * Default is false — scenarios skip with a warning so default conformance
17
+ * runs cover what the host has implemented. See `lib/behavior-gate.ts`
18
+ * and `conformance/coverage.md` §"Capability-gated scenarios".
11
19
  */
12
20
 
13
21
  export interface ConformanceEnv {
@@ -15,6 +23,7 @@ export interface ConformanceEnv {
15
23
  readonly apiKey: string;
16
24
  readonly implementationName: string;
17
25
  readonly implementationVersion: string;
26
+ readonly requireBehavior: boolean;
18
27
  }
19
28
 
20
29
  let cached: ConformanceEnv | null = null;
@@ -44,6 +53,7 @@ export function loadEnv(): ConformanceEnv {
44
53
  apiKey,
45
54
  implementationName: process.env.OPENWOP_IMPLEMENTATION_NAME?.trim() ?? 'unknown',
46
55
  implementationVersion: process.env.OPENWOP_IMPLEMENTATION_VERSION?.trim() ?? 'unknown',
56
+ requireBehavior: process.env.OPENWOP_REQUIRE_BEHAVIOR === 'true',
47
57
  };
48
58
  return cached;
49
59
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Unit tests for `grpc-framing.ts` — gRPC HTTP/2 message framing.
3
+ *
4
+ * @see grpc-framing.ts
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { frameMessage, unframeMessages } from './grpc-framing.js';
9
+
10
+ describe('grpc-framing: frameMessage', () => {
11
+ it('prepends a 5-byte header to a single payload', () => {
12
+ const payload = new Uint8Array([0xab, 0xcd, 0xef]);
13
+ const framed = frameMessage(payload);
14
+ expect(framed.byteLength).toBe(8);
15
+ expect(framed[0]).toBe(0); // identity compression
16
+ expect(framed[1]).toBe(0);
17
+ expect(framed[2]).toBe(0);
18
+ expect(framed[3]).toBe(0);
19
+ expect(framed[4]).toBe(3); // length = 3
20
+ expect(framed[5]).toBe(0xab);
21
+ expect(framed[6]).toBe(0xcd);
22
+ expect(framed[7]).toBe(0xef);
23
+ });
24
+
25
+ it('frames a zero-length payload as a 5-byte header alone', () => {
26
+ const framed = frameMessage(new Uint8Array(0));
27
+ expect(framed.byteLength).toBe(5);
28
+ expect(framed[0]).toBe(0);
29
+ expect(framed[4]).toBe(0);
30
+ });
31
+
32
+ it('encodes lengths > 256 in big-endian order', () => {
33
+ const payload = new Uint8Array(300);
34
+ const framed = frameMessage(payload);
35
+ // length = 300 = 0x0000012C, big-endian: 00 00 01 2C
36
+ expect(framed[1]).toBe(0);
37
+ expect(framed[2]).toBe(0);
38
+ expect(framed[3]).toBe(1);
39
+ expect(framed[4]).toBe(0x2c);
40
+ });
41
+ });
42
+
43
+ describe('grpc-framing: unframeMessages', () => {
44
+ it('parses a single frame', () => {
45
+ const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
46
+ const framed = frameMessage(payload);
47
+ const messages = unframeMessages(framed);
48
+ expect(messages.length).toBe(1);
49
+ expect(Array.from(messages[0]!)).toEqual([0x01, 0x02, 0x03, 0x04]);
50
+ });
51
+
52
+ it('parses multiple concatenated frames', () => {
53
+ const a = frameMessage(new Uint8Array([0xaa, 0xab]));
54
+ const b = frameMessage(new Uint8Array([0xbb]));
55
+ const c = frameMessage(new Uint8Array([0xcc, 0xcd, 0xce, 0xcf]));
56
+ const combined = new Uint8Array(a.byteLength + b.byteLength + c.byteLength);
57
+ combined.set(a, 0);
58
+ combined.set(b, a.byteLength);
59
+ combined.set(c, a.byteLength + b.byteLength);
60
+ const messages = unframeMessages(combined);
61
+ expect(messages.length).toBe(3);
62
+ expect(Array.from(messages[0]!)).toEqual([0xaa, 0xab]);
63
+ expect(Array.from(messages[1]!)).toEqual([0xbb]);
64
+ expect(Array.from(messages[2]!)).toEqual([0xcc, 0xcd, 0xce, 0xcf]);
65
+ });
66
+
67
+ it('parses an empty buffer as zero frames', () => {
68
+ expect(unframeMessages(new Uint8Array(0))).toEqual([]);
69
+ });
70
+
71
+ it('throws on truncated header', () => {
72
+ // Only 3 bytes of a 5-byte header.
73
+ const buf = new Uint8Array([0, 0, 0]);
74
+ expect(() => unframeMessages(buf)).toThrow(/frame truncated/i);
75
+ });
76
+
77
+ it('throws on truncated payload', () => {
78
+ // 5-byte header declares 10 bytes of payload but only 4 follow.
79
+ const buf = new Uint8Array([0, 0, 0, 0, 10, 1, 2, 3, 4]);
80
+ expect(() => unframeMessages(buf)).toThrow(/payload truncated/i);
81
+ });
82
+
83
+ it('throws on unsupported compression flag', () => {
84
+ // Flag = 1 → compression negotiated, which we don't implement.
85
+ const buf = new Uint8Array([1, 0, 0, 0, 0]);
86
+ expect(() => unframeMessages(buf)).toThrow(/compression flag/i);
87
+ });
88
+
89
+ it('round-trips: frame → unframe yields original payload', () => {
90
+ const original = new Uint8Array([0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f]); // protobuf "hello"
91
+ const framed = frameMessage(original);
92
+ const unframed = unframeMessages(framed);
93
+ expect(unframed.length).toBe(1);
94
+ expect(Array.from(unframed[0]!)).toEqual(Array.from(original));
95
+ });
96
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * gRPC HTTP/2 message framing primitive — Track 11 OTLP/gRPC.
3
+ *
4
+ * gRPC over HTTP/2 wraps each protobuf message in a 5-byte frame
5
+ * prefix:
6
+ *
7
+ * ┌──────────┬───────────────────────┬─────────────────────┐
8
+ * │ 1 byte │ 4 bytes │ N bytes │
9
+ * │ flags │ length (big-endian) │ payload │
10
+ * └──────────┴───────────────────────┴─────────────────────┘
11
+ *
12
+ * `flags` is `0x00` for uncompressed (the only mode we implement).
13
+ * `0x01` indicates a per-message compression scheme negotiated via
14
+ * the `grpc-encoding` header; we reject this since the conformance
15
+ * collector advertises identity encoding only.
16
+ *
17
+ * Multiple frames MAY be concatenated in a stream; for OTLP Export
18
+ * unary calls the request and response both carry exactly one frame.
19
+ *
20
+ * Pure stdlib — no `@grpc/grpc-js` dependency. Matches the
21
+ * zero-new-deps stance of `otlp-protobuf.ts` (the hand-rolled
22
+ * decoder for the OTLP message bodies).
23
+ *
24
+ * @see https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
25
+ */
26
+
27
+ /** Length-prefix a single protobuf payload with the gRPC 5-byte frame. */
28
+ export function frameMessage(payload: Uint8Array): Uint8Array {
29
+ const out = new Uint8Array(5 + payload.byteLength);
30
+ out[0] = 0; // compression flag — identity
31
+ // Big-endian 32-bit length
32
+ out[1] = (payload.byteLength >>> 24) & 0xff;
33
+ out[2] = (payload.byteLength >>> 16) & 0xff;
34
+ out[3] = (payload.byteLength >>> 8) & 0xff;
35
+ out[4] = payload.byteLength & 0xff;
36
+ out.set(payload, 5);
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * Parse one or more gRPC-framed messages from a concatenated buffer.
42
+ * Throws if any frame uses a compression scheme we don't implement,
43
+ * or if the buffer truncates mid-frame.
44
+ */
45
+ export function unframeMessages(buf: Uint8Array): Uint8Array[] {
46
+ const out: Uint8Array[] = [];
47
+ let i = 0;
48
+ while (i < buf.byteLength) {
49
+ if (i + 5 > buf.byteLength) {
50
+ throw new Error(
51
+ `gRPC frame truncated: need 5-byte header at offset ${i}, have ${buf.byteLength - i}`,
52
+ );
53
+ }
54
+ const flag = buf[i]!;
55
+ if (flag !== 0) {
56
+ throw new Error(
57
+ `gRPC frame at offset ${i} has compression flag ${flag}; only identity (0) is supported`,
58
+ );
59
+ }
60
+ const len =
61
+ ((buf[i + 1]! << 24) >>> 0) |
62
+ ((buf[i + 2]! << 16) >>> 0) |
63
+ ((buf[i + 3]! << 8) >>> 0) |
64
+ buf[i + 4]!;
65
+ const payloadStart = i + 5;
66
+ const payloadEnd = payloadStart + len;
67
+ if (payloadEnd > buf.byteLength) {
68
+ throw new Error(
69
+ `gRPC frame payload truncated: declared length ${len} at offset ${i + 1}, but only ${buf.byteLength - payloadStart} bytes remain`,
70
+ );
71
+ }
72
+ out.push(buf.subarray(payloadStart, payloadEnd));
73
+ i = payloadEnd;
74
+ }
75
+ return out;
76
+ }
@@ -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
+ });