@openwop/openwop-conformance 1.18.0 → 1.19.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 +19 -0
- package/README.md +2 -2
- package/api/.redocly.lint-ignore.yaml +22 -0
- package/api/openapi.yaml +13 -4
- package/coverage.md +2 -1
- package/dist/cli.js +235 -4
- package/dist/lib/paths.js +160 -0
- package/dist/lib/profiles.js +461 -0
- package/fixtures/conformance-agent-channel-dispatch.json +27 -0
- package/fixtures.md +15 -0
- package/package.json +1 -1
- package/schemas/README.md +1 -0
- package/schemas/capabilities.schema.json +5 -0
- package/schemas/conformance-certification-bundle.schema.json +86 -0
- package/src/cli.ts +268 -4
- package/src/lib/profiles.ts +85 -0
- package/src/scenarios/agent-channel-dispatch.test.ts +229 -0
- package/src/scenarios/auth-saml-profile.test.ts +51 -14
- package/src/scenarios/spec-corpus-validity.test.ts +183 -0
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* auth-saml-profile — RFC 0050: openwop-auth-saml profile.
|
|
3
3
|
*
|
|
4
|
-
* Status:
|
|
5
|
-
* `
|
|
4
|
+
* Status: ACTIVE. RFC 0050 (SAML / SCIM enterprise identity profiles) is
|
|
5
|
+
* `Active`. The profile is documented in `auth-profiles.md`
|
|
6
6
|
* §`openwop-auth-saml` and reserved in `capabilities.auth.profiles`.
|
|
7
7
|
*
|
|
8
8
|
* Capability shape runs unconditionally when the profile is advertised.
|
|
9
|
-
* The assertion-validation behavior
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
9
|
+
* The assertion-validation behavior is exercised over the host's live
|
|
10
|
+
* `auth/saml/validate` seam for the FULL §A variant set — 1 positive
|
|
11
|
+
* (valid, signed, in-window, non-wrapped → accepted) + 6 negatives
|
|
12
|
+
* (`alg:none`, unsigned, bad-signature, `NotOnOrAfter` expiry, `NotBefore`
|
|
13
|
+
* not-yet-valid, signature-wrapping → rejected). This behavioral leg is
|
|
14
|
+
* opt-in via `OPENWOP_TEST_SAML_IDP_URL` (an operator-supplied HTTP
|
|
15
|
+
* synthetic IdP serving the bundled `createSyntheticSamlIdp()` assertions),
|
|
16
|
+
* because it requires a live host ACS + an HTTP IdP the seam can resolve
|
|
17
|
+
* variants from — the in-process minter below is not a server. Follows the
|
|
14
18
|
* `auth-mtls.test.ts` opt-in precedent. Soft-skips otherwise.
|
|
15
19
|
*
|
|
16
20
|
* @see RFCS/0050-saml-scim-enterprise-identity-profiles.md
|
|
@@ -65,19 +69,52 @@ describe('auth-saml-profile: advertisement shape (RFC 0050)', () => {
|
|
|
65
69
|
describe('auth-saml-profile: assertion validation (RFC 0050 §A — opt-in)', () => {
|
|
66
70
|
const idpUrl = process.env.OPENWOP_TEST_SAML_IDP_URL;
|
|
67
71
|
|
|
68
|
-
|
|
72
|
+
// The host's real SAML ACS is driven over the `auth/saml/validate` seam for
|
|
73
|
+
// every variant the bundled synthetic IdP mints — the full RFC 0050 §A MUST
|
|
74
|
+
// list, not just one negative. The seam receives `{ idpUrl, variant }`,
|
|
75
|
+
// resolves `{ certificatePem, assertion }` of that variant from the
|
|
76
|
+
// operator-supplied synthetic IdP, runs it through the host's genuine
|
|
77
|
+
// validator, and answers 2xx (accepted) / non-2xx (rejected). This is the
|
|
78
|
+
// NON-VACUOUS behavioral leg: it exercises the host's ACS on the live wire,
|
|
79
|
+
// distinct from the in-process reference suite below (which proves the
|
|
80
|
+
// assertions are detectably malformed against the bundled oracle, not the
|
|
81
|
+
// host). All legs soft-skip until `OPENWOP_TEST_SAML_IDP_URL` is supplied.
|
|
82
|
+
const gated = (): boolean => idpUrl !== undefined && idpUrl.length > 0;
|
|
83
|
+
|
|
84
|
+
it('ACCEPTS a valid signed, in-window, non-wrapped assertion (synthetic IdP required)', async () => {
|
|
69
85
|
const profiles = await readProfiles();
|
|
70
86
|
if (profiles === null || !profiles.includes(SAML_PROFILE)) return; // capability-gated
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
// host's SAML ACS MUST be rejected with `unauthenticated`.
|
|
74
|
-
const res = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'alg-none' });
|
|
87
|
+
if (!gated()) return; // opt-in: synthetic-IdP harness not provided
|
|
88
|
+
const res = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant: 'valid' });
|
|
75
89
|
if (res.status === 404) return; // seam unwired
|
|
76
90
|
expect(
|
|
77
91
|
res.status,
|
|
78
|
-
driver.describe('RFC 0050 §A', '
|
|
79
|
-
).
|
|
92
|
+
driver.describe('RFC 0050 §A', 'a valid signed, in-window, non-wrapped SAML assertion MUST be accepted (2xx)'),
|
|
93
|
+
).toBeLessThan(400);
|
|
80
94
|
});
|
|
95
|
+
|
|
96
|
+
// The 6 negatives the RFC 0050 §A MUST list requires a host to reject. The
|
|
97
|
+
// signature-wrapping (XSW) case is the load-bearing security property — a
|
|
98
|
+
// host MUST bind the validated signature to the consumed assertion.
|
|
99
|
+
const negatives: ReadonlyArray<[Exclude<SamlVariant, 'valid'>, string]> = [
|
|
100
|
+
['alg-none', 'an `alg:none` SAML assertion MUST be rejected (non-2xx)'],
|
|
101
|
+
['unsigned', 'an unsigned SAML assertion MUST be rejected (non-2xx)'],
|
|
102
|
+
['bad-signature', 'a SAML assertion with an invalid signature MUST be rejected (non-2xx)'],
|
|
103
|
+
['expired', 'a SAML assertion past `NotOnOrAfter` MUST be rejected (non-2xx)'],
|
|
104
|
+
['not-yet-valid', 'a SAML assertion before `NotBefore` MUST be rejected (non-2xx)'],
|
|
105
|
+
['signature-wrapping', 'a signature-wrapped (XSW) SAML assertion MUST be rejected (non-2xx)'],
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
for (const [variant, requirement] of negatives) {
|
|
109
|
+
it(`REJECTS the ${variant} assertion over the seam (synthetic IdP required)`, async () => {
|
|
110
|
+
const profiles = await readProfiles();
|
|
111
|
+
if (profiles === null || !profiles.includes(SAML_PROFILE)) return; // capability-gated
|
|
112
|
+
if (!gated()) return; // opt-in: synthetic-IdP harness not provided
|
|
113
|
+
const res = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl, variant });
|
|
114
|
+
if (res.status === 404) return; // seam unwired
|
|
115
|
+
expect(res.status, driver.describe('RFC 0050 §A', requirement)).toBeGreaterThanOrEqual(400);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
81
118
|
});
|
|
82
119
|
|
|
83
120
|
describe('category: auth-saml synthetic-IdP reference suite (RFC 0050 §A)', () => {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
import { describe, it, expect } from 'vitest';
|
|
37
37
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
38
39
|
import { dirname, join, relative, resolve as pathResolve } from 'node:path';
|
|
39
40
|
import Ajv2020 from 'ajv/dist/2020.js';
|
|
40
41
|
import addFormats from 'ajv-formats';
|
|
@@ -53,6 +54,7 @@ import {
|
|
|
53
54
|
TYPESCRIPT_RUN_HELPERS_PATH,
|
|
54
55
|
V1_DIR,
|
|
55
56
|
} from '../lib/paths.js';
|
|
57
|
+
import { verifyBundle, PROFILE_FLOOR_SCENARIOS } from '../lib/profiles.js';
|
|
56
58
|
|
|
57
59
|
// Layout-aware paths come from `lib/paths.ts`. Three layouts:
|
|
58
60
|
// - Repo (github.com/openwop/openwop): schemas/api at repo root,
|
|
@@ -721,6 +723,51 @@ describe('spec-corpus: OpenAPI 3.1 spec is structurally valid', () => {
|
|
|
721
723
|
}
|
|
722
724
|
});
|
|
723
725
|
|
|
726
|
+
// ── Reserved-route disambiguation (RFC 0086/0087 + audit PR #495) ──────────
|
|
727
|
+
// The literal collection routes /v1/agents/roster and /v1/agents/org-chart
|
|
728
|
+
// share a prefix with the parameterized /v1/agents/{agentId}. The mitigation
|
|
729
|
+
// excludes the reserved literals from the {agentId} path param via a
|
|
730
|
+
// negative-lookahead pattern, AND the agent-manifest agentId pattern requires
|
|
731
|
+
// a dotted-tier form the bare literals can't satisfy. These guard both halves
|
|
732
|
+
// from silently regressing (the external standards-readiness audit asked the
|
|
733
|
+
// reserved-route mitigation be bound to a test).
|
|
734
|
+
it('declares both the literal /v1/agents/{roster,org-chart} routes and the {agentId} param route', () => {
|
|
735
|
+
const { raw } = readYamlHeader(openapiPath);
|
|
736
|
+
expect(raw).toContain('/v1/agents/{agentId}:');
|
|
737
|
+
expect(raw).toContain('/v1/agents/roster:');
|
|
738
|
+
expect(raw).toContain('/v1/agents/org-chart:');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('every /v1/agents/{agentId} param excludes the reserved literals (roster, org-chart)', () => {
|
|
742
|
+
const { raw } = readYamlHeader(openapiPath);
|
|
743
|
+
const paramRoutes = (raw.match(/\/v1\/agents\/\{agentId\}/g) ?? []).length;
|
|
744
|
+
const exclusions = (raw.match(/\(\?!roster\$\|org-chart\$\)/g) ?? []).length;
|
|
745
|
+
expect(paramRoutes, 'expected ≥2 /v1/agents/{agentId...} routes (base + /deployments)').toBeGreaterThanOrEqual(2);
|
|
746
|
+
expect(
|
|
747
|
+
exclusions,
|
|
748
|
+
'each /v1/agents/{agentId} param schema MUST exclude the reserved literals via a (?!roster$|org-chart$) lookahead',
|
|
749
|
+
).toBeGreaterThanOrEqual(2);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it('the reserved-literal exclusion pattern rejects roster/org-chart and accepts a real agentId', () => {
|
|
753
|
+
const re = /^(?!roster$|org-chart$).+$/;
|
|
754
|
+
expect(re.test('roster'), 'roster MUST NOT match the {agentId} param').toBe(false);
|
|
755
|
+
expect(re.test('org-chart'), 'org-chart MUST NOT match the {agentId} param').toBe(false);
|
|
756
|
+
expect(re.test('core.example.pack.agent')).toBe(true);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('the agent-manifest agentId pattern can never produce a reserved literal (defense in depth)', () => {
|
|
760
|
+
const manifest = readJson(join(SCHEMAS_DIR, 'agent-manifest.schema.json')) as {
|
|
761
|
+
properties?: { agentId?: { pattern?: string } };
|
|
762
|
+
};
|
|
763
|
+
const pattern = manifest.properties?.agentId?.pattern;
|
|
764
|
+
expect(typeof pattern, 'agent-manifest.schema.json MUST constrain agentId with a pattern').toBe('string');
|
|
765
|
+
const re = new RegExp(pattern as string);
|
|
766
|
+
expect(re.test('roster'), 'manifest agentId MUST NOT permit the reserved literal `roster`').toBe(false);
|
|
767
|
+
expect(re.test('org-chart'), 'manifest agentId MUST NOT permit the reserved literal `org-chart`').toBe(false);
|
|
768
|
+
expect(re.test('core.example.pack.agent')).toBe(true);
|
|
769
|
+
});
|
|
770
|
+
|
|
724
771
|
it('typed error specializations compose the canonical Error schema', () => {
|
|
725
772
|
const { raw } = readYamlHeader(openapiPath);
|
|
726
773
|
|
|
@@ -1351,3 +1398,139 @@ describe.skipIf(FIXTURES_DOC_PATH === null)('spec-corpus: fixtures.json catalog
|
|
|
1351
1398
|
}
|
|
1352
1399
|
});
|
|
1353
1400
|
});
|
|
1401
|
+
|
|
1402
|
+
// RFC 0089 — conformance certification bundle. The schema itself is compiled +
|
|
1403
|
+
// $id-checked by the "JSON Schemas compile under Ajv2020" block above; here we
|
|
1404
|
+
// assert a sample bundle validates AND that the §B binding rule (verifyBundle)
|
|
1405
|
+
// correctly accepts a valid claim and rejects both a not-derivable claim and a
|
|
1406
|
+
// missing-floor-scenario one.
|
|
1407
|
+
describe('spec-corpus: RFC 0089 conformance certification bundle + binding rule', () => {
|
|
1408
|
+
// A discovery document that derives `openwop-core-standard`
|
|
1409
|
+
// (isCore ∧ isInterrupts ∧ a transport — supportedTransports omitted ⇒ rest).
|
|
1410
|
+
const coreStandardDiscovery = {
|
|
1411
|
+
protocolVersion: '1.0',
|
|
1412
|
+
supportedEnvelopes: ['final', 'clarification.request'],
|
|
1413
|
+
schemaVersions: { 'workflow-definition': '1.0' },
|
|
1414
|
+
limits: { clarificationRounds: 3, schemaRounds: 2, envelopesPerTurn: 8 },
|
|
1415
|
+
};
|
|
1416
|
+
const coreStandardFloorPassed = [
|
|
1417
|
+
...PROFILE_FLOOR_SCENARIOS['openwop-core-standard']!.required,
|
|
1418
|
+
'interrupt-resume.test.ts',
|
|
1419
|
+
];
|
|
1420
|
+
const sampleBundle = {
|
|
1421
|
+
bundleVersion: '1',
|
|
1422
|
+
generatedAt: '2026-06-02T00:00:00Z',
|
|
1423
|
+
generator: { name: '@openwop/openwop-conformance --certify', version: '1.18.1' },
|
|
1424
|
+
suite: { package: '@openwop/openwop-conformance', version: '1.18.1' },
|
|
1425
|
+
host: { name: 'openwop-host-sqlite', version: '1.0.0' },
|
|
1426
|
+
discovery: {
|
|
1427
|
+
url: 'https://example.test/.well-known/openwop',
|
|
1428
|
+
sha256: 'a'.repeat(64),
|
|
1429
|
+
document: coreStandardDiscovery,
|
|
1430
|
+
},
|
|
1431
|
+
claimedProfiles: ['openwop-core-standard'],
|
|
1432
|
+
results: {
|
|
1433
|
+
totals: { passed: coreStandardFloorPassed.length, failed: 0, skipped: 0, total: coreStandardFloorPassed.length },
|
|
1434
|
+
passed: coreStandardFloorPassed,
|
|
1435
|
+
failed: [],
|
|
1436
|
+
skipped: [],
|
|
1437
|
+
},
|
|
1438
|
+
};
|
|
1439
|
+
|
|
1440
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
1441
|
+
addFormats(ajv);
|
|
1442
|
+
const bundleSchema = readJson(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json')) as Record<string, unknown>;
|
|
1443
|
+
|
|
1444
|
+
it('a sample bundle validates against conformance-certification-bundle.schema.json', () => {
|
|
1445
|
+
const validate = ajv.compile(bundleSchema);
|
|
1446
|
+
const ok = validate(sampleBundle);
|
|
1447
|
+
expect(ok, JSON.stringify(validate.errors)).toBe(true);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
it('verifyBundle ACCEPTS a claim that is discovery-derivable AND floor-proven (§B)', () => {
|
|
1451
|
+
const r = verifyBundle(sampleBundle);
|
|
1452
|
+
expect(r.valid).toBe(true);
|
|
1453
|
+
expect(r.verdicts[0]?.derivable).toBe(true);
|
|
1454
|
+
expect(r.verdicts[0]?.floorProven).toBe(true);
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
it('verifyBundle REJECTS a profile its discovery document does not derive (§B(1))', () => {
|
|
1458
|
+
const notDerivable = {
|
|
1459
|
+
...sampleBundle,
|
|
1460
|
+
discovery: { ...sampleBundle.discovery, document: { ...coreStandardDiscovery, supportedEnvelopes: ['final'] } },
|
|
1461
|
+
};
|
|
1462
|
+
const r = verifyBundle(notDerivable);
|
|
1463
|
+
expect(r.valid).toBe(false);
|
|
1464
|
+
expect(r.verdicts[0]?.derivable).toBe(false);
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it('verifyBundle REJECTS a bundle missing a floor scenario (§B(2))', () => {
|
|
1468
|
+
const missingFloor = {
|
|
1469
|
+
...sampleBundle,
|
|
1470
|
+
results: { ...sampleBundle.results, passed: coreStandardFloorPassed.filter((s) => s !== 'auth.test.ts') },
|
|
1471
|
+
};
|
|
1472
|
+
const r = verifyBundle(missingFloor);
|
|
1473
|
+
expect(r.valid).toBe(false);
|
|
1474
|
+
expect(r.verdicts[0]?.floorProven).toBe(false);
|
|
1475
|
+
expect(r.verdicts[0]?.missingFloor).toContain('auth.test.ts');
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
// RFC 0089 — the committed REAL reference-host bundle, generated by
|
|
1480
|
+
// `openwop-conformance --certify` against the in-memory reference host
|
|
1481
|
+
// (examples/hosts/in-memory). This is the at-`Accepted` "reference host commits
|
|
1482
|
+
// a real generated bundle" evidence: it must (a) validate against the schema and
|
|
1483
|
+
// (b) pass the §B binding rule — every profile it CLAIMS must re-derive from its
|
|
1484
|
+
// own captured discovery document AND be floor-proven. The bundle lives in
|
|
1485
|
+
// `examples/`, which is NOT bundled into the published tarball, so this skips
|
|
1486
|
+
// cleanly under the published layout (V1_DIR === null).
|
|
1487
|
+
describe.skipIf(V1_DIR === null)('spec-corpus: RFC 0089 committed reference-host certification bundle', () => {
|
|
1488
|
+
const repoRoot = V1_DIR === null ? '' : pathResolve(V1_DIR, '..', '..');
|
|
1489
|
+
const bundlePath = join(repoRoot, 'examples', 'hosts', 'in-memory', 'certification-bundle.json');
|
|
1490
|
+
|
|
1491
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
1492
|
+
addFormats(ajv);
|
|
1493
|
+
const bundleSchema = readJson(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json')) as Record<
|
|
1494
|
+
string,
|
|
1495
|
+
unknown
|
|
1496
|
+
>;
|
|
1497
|
+
|
|
1498
|
+
it('the committed bundle file exists (generated by --certify)', () => {
|
|
1499
|
+
expect(existsSync(bundlePath), `expected a committed reference bundle at ${bundlePath}`).toBe(true);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
it('the committed reference bundle validates against the bundle schema (§A)', () => {
|
|
1503
|
+
const bundle = readJson(bundlePath);
|
|
1504
|
+
const validate = ajv.compile(bundleSchema);
|
|
1505
|
+
const ok = validate(bundle);
|
|
1506
|
+
expect(ok, JSON.stringify(validate.errors)).toBe(true);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
it('verifyBundle ACCEPTS the committed reference bundle — every claimed profile re-derives + is floor-proven (§B)', () => {
|
|
1510
|
+
const bundle = readJson(bundlePath) as Parameters<typeof verifyBundle>[0];
|
|
1511
|
+
const r = verifyBundle(bundle);
|
|
1512
|
+
// The host honestly claims ONLY profiles its discovery document derives, none
|
|
1513
|
+
// of which it fails a floor scenario for — so verifyBundle MUST accept it.
|
|
1514
|
+
const offending = r.verdicts.filter((v) => !v.valid);
|
|
1515
|
+
expect(
|
|
1516
|
+
r.valid,
|
|
1517
|
+
`verifyBundle rejected claimed profile(s): ${JSON.stringify(offending)}`,
|
|
1518
|
+
).toBe(true);
|
|
1519
|
+
});
|
|
1520
|
+
|
|
1521
|
+
it('discovery.sha256 is the canonical-JSON SHA-256 of the captured discovery.document', () => {
|
|
1522
|
+
const bundle = readJson(bundlePath) as {
|
|
1523
|
+
discovery: { sha256: string; document: unknown };
|
|
1524
|
+
};
|
|
1525
|
+
// Mirror the generator's canonical serialization (sorted keys at every level).
|
|
1526
|
+
const canonical = (value: unknown): string => {
|
|
1527
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
1528
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
1529
|
+
const obj = value as Record<string, unknown>;
|
|
1530
|
+
const keys = Object.keys(obj).sort();
|
|
1531
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(',')}}`;
|
|
1532
|
+
};
|
|
1533
|
+
const recomputed = createHash('sha256').update(canonical(bundle.discovery.document)).digest('hex');
|
|
1534
|
+
expect(bundle.discovery.sha256).toBe(recomputed);
|
|
1535
|
+
});
|
|
1536
|
+
});
|