@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.
- 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 +293 -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 +2 -2
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +68 -0
- package/src/lib/env.ts +10 -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 +198 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- 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/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 +131 -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 +54 -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,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
|
+
});
|