@openwop/openwop-conformance 1.146.0 → 1.148.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 +18 -0
- package/coverage.md +2 -0
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/capabilities.schema.json +10 -2
- package/src/lib/capabilities-auth-subject-link.test.ts +75 -0
- package/src/lib/saml-idp.test.ts +83 -0
- package/src/lib/saml-idp.ts +76 -14
- package/src/scenarios/auth-subject-link-key-class.test.ts +88 -29
- package/src/scenarios/auth-subject-link.test.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [1.148.0] — 2026-09-01 — RFC 0163 `Draft → Active`: classes not attributes, schema conditional, fixture proof out of the scenario
|
|
4
|
+
|
|
5
|
+
No new scenario file (suite file count unchanged); published content changes, so the identity bump is a minor. Three things move.
|
|
6
|
+
|
|
7
|
+
**The enum names classes, not vendor attributes.** `capabilities.auth.subjectLinkKey` is now `{opaque-idp, configured-immutable}`; the `Draft` set `{opaque-idp, oid, immutable-id}` carried two Entra ID attribute names that the suite could not tell apart (its only check is membership), and the RFC's own gap register was already anticipating a fourth. `configured-immutable` absorbs both under the §A.1 conjunctive predicate. 1.147.0 was never published, so no consumer ever accepted the old members.
|
|
8
|
+
|
|
9
|
+
**The co-requirement is in the schema.** `capabilities.schema.json` gains `if subjectLinking:true then required [subjectLinkKey]` under `auth`. The `Draft` left this in prose and recorded the hole as its own risk R5 — a `subjectLinking:true` document with no key was schema-valid, so a host self-certifying against the bare schema read as passing. The conditional fires only on a flag no production host emits, which is a tightening of an unemitted shape, not a closure of a server-emitted one. Pinned server-free in `src/lib/capabilities-auth-subject-link.test.ts` (missing key rejected, `email` rejected, the two retired names rejected, both members accepted, absent/false unaffected).
|
|
10
|
+
|
|
11
|
+
**The fixture proves itself in `src/lib`, not in the scenario.** The four two-trust-root self-tests that 1.147.0 put inside `auth-subject-link-key-class.test.ts` moved to `src/lib/saml-idp.test.ts` (plus a sixth: a post-signing Issuer swap MUST NOT verify — the §B.1 "inside the signed element" requirement, witnessed rather than asserted). Inside the scenario they were the G8 shape exactly: on every host that does not advertise `subjectLinking`, the advertisement legs soft-skip `inapplicable`, the fixture legs pass with assertions, and the file resolves to `executed-pass` — a witnessed pass about a fixture, credited to a host the file never examined. `src/lib` tests run in the published suite and produce no ledger row, the same reason `oidc-issuer.test.ts` lives there. The RFC 0050 `auth-saml-profile.test.ts` reference-suite block has the same shape and is recorded as RFC 0163 gap G5 for its own PR, since moving it changes RFC 0050's recorded evidence.
|
|
12
|
+
|
|
13
|
+
Also: three `subject-link-*` invariants registered (RFC 0159 had a threat-model row and no invariant); `coverage.md` gains the two subject-link rows it was missing; `host-sample-test-seams.md` documents the `idpUrl`, `externalId`, and `link`/`linkKey` fields the two scenarios already sent and the `authenticated`/`linkedDenied` response the validate seam already returned; the "RFC 0048 §D owner-echo determinism" citation that RFC 0159, RFC 0163 and `auth-profiles.md` relied on is corrected — §D is "Cross-workspace isolation"; the echo is §C and the determinism is RFC 0006.
|
|
14
|
+
|
|
15
|
+
## [1.147.0] — 2026-09-01 — RFC 0163 §B two-trust-root fixture (gap G-fixture)
|
|
16
|
+
|
|
17
|
+
No new scenario file (suite file count unchanged): un-stubs the RFC 0163 §B same-IdP-trust-root behavioral leg in `auth-subject-link-key-class.test.ts`, which had been a phased `blocked` soft-skip pending the fixture. The bundled synthetic SAML IdP (`src/lib/saml-idp.ts`) is extended to carry a **distinct trust root** per instance: `createSyntheticSamlIdp({ entityID })` now stamps a **signed** `<saml:Issuer>` into every assertion (inside the signed canonical element, so it cannot be swapped post-signing), exposes `.entityID`, and adds `issuerOf()`. Two instances (distinct entityID + distinct signing key) model two independent IdPs that can mint an assertion for the **same** opaque subject id (an identifier collision) while remaining cryptographically and by-issuer distinguishable — the fixture RFC 0163 gap G-fixture called for.
|
|
18
|
+
|
|
19
|
+
The §B scenario now carries two forms: a **server-free reference suite** proving the fixture (executable now — distinct entityIDs, both IdPs mint the colliding subject, the assertions carry different signed Issuers, and IdP-A rejects IdP-B's cross-root assertion with `bad-signature`); and a **live black-box leg** over the host's SCIM+SAML seams — a same-IdP positive control (must link, which guards the negative against vacuity) paired with the §B.1 cross-IdP collision that MUST NOT link. The live leg is opt-in via `OPENWOP_TEST_SAML_IDP_URL` (IdP-A, also the SCIM lane's IdP) + `OPENWOP_TEST_SAML_IDP_URL_B` (IdP-B) + `OPENWOP_TEST_SCIM_URL`, soft-skipping (`inapplicable`) until a host wires the two-trust-root seam and gated on `capabilities.auth.subjectLinking`. Existing RFC 0050 SAML variant coverage is unaffected (the signed Issuer is threaded through sign+verify consistently; all 6 negatives + valid still pass). RFC 0163 stays `Draft`; this makes its §B leg executable-when-seamed without flipping its status.
|
|
20
|
+
|
|
3
21
|
## [1.81.0] — 2026-08-12 — RFC 0149 §D lifecycle coherence
|
|
4
22
|
|
|
5
23
|
Adds one scenario (suite 420 → 421): `rfc-lifecycle-coherence.test.ts`, three always-on server-free legs. §D asks the corpus generator to fail when an `Accepted` RFC "retains an unresolved acceptance blocker not explicitly carried". The obvious rule — every box ticked before `Accepted` — was measured and rejected: of 141 `Accepted` RFCs, 42% ticked all, 25% ticked none, 24% ticked some, so a blanket gate fails **69 RFCs on its first run**, and a gate that fires 69 times gets disabled rather than fixed. The follow-up hypothesis (that the partially-ticked RFCs were a blocker backlog) also failed on reading them: every trailing item in `0027`/`0040`/`0041` is deliberately unticked **and annotated with why**, which *is* §D's "explicitly carried", just in a parenthetical rather than a register row. **Shipped rule: annotated vs bare, not ticked vs unticked** — an unticked item with no explanation is indistinguishable from one nobody checked. Scoped to RFCs ≥ 0147 so it binds the cohort that proposed it, with a leg asserting exactly that; earlier RFCs are the dated record of a period when the convention did not exist, and the boundary is asserted so a new RFC cannot inherit the exemption. All 67 bare items across 0147–0156 were annotated to land it. The non-vacuity guard is load-bearing here because **the gate's passing state and its broken state look identical**. Sabotage-verified.
|
package/coverage.md
CHANGED
|
@@ -117,6 +117,8 @@ The scenario groups in the table below (one row per group; count the rows — th
|
|
|
117
117
|
| `authorization-fail-closed.test.ts` | `capabilities.authorization` (RFC 0049 §C) + `SECURITY/invariants.yaml` `authorization-fail-closed` | A (advertisement `failClosed===true` always; fail-closed MUST-NOT via optional `POST /v1/host/sample/authorization/decide` seam — an unseeded-role principal resolves `allowed:false`) | `host-pending` | Capability-gated on `authorization.supported`; behavioral probe soft-skips on 404. Scope-match + denial-audited scenarios deferred to a host. |
|
|
118
118
|
| `auth-saml-profile.test.ts` | `openwop-auth-saml` (RFC 0050, `auth-profiles.md` §`openwop-auth-saml`) | A+B (profile-advertisement shape always; **1-positive + 6-negative reference suite runs server-free** via the bundled synthetic IdP `conformance/src/lib/saml-idp.ts` — `alg:none`/unsigned/bad-sig/expired/not-yet-valid/wrapping; host-ACS validation opt-in via `OPENWOP_TEST_SAML_IDP_URL` + the `auth/saml/validate` seam) | host-pass (server-free reference) | Synthetic IdP bundled (`node:crypto`, no deps). Host-ACS pass is the remaining graduation gate. |
|
|
119
119
|
| `auth-scim-profile.test.ts` | `openwop-auth-scim` (RFC 0050, `auth-profiles.md` §`openwop-auth-scim`) | B (profile-advertisement shape always; SCIM user/group provisioning → principal/role roundtrip opt-in via `OPENWOP_TEST_SCIM_URL` + the `auth/scim/provision` seam) | `host-pending` | Behavior opt-in (operator-supplied SCIM endpoint); deactivate ⇒ subsequent-deny assertion deferred to a host. |
|
|
120
|
+
| `auth-subject-link.test.ts` | `openwop-auth-saml` + `openwop-auth-scim` with `capabilities.auth.subjectLinking: true` (RFC 0159, `auth-profiles.md` §Subject linking) | B (advertisement gate always — `subjectLinking:true` only alongside both profiles; cross-lane leaver deny + mutable-key negative opt-in via `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SCIM_URL` and the `auth/scim/provision` + `auth/saml/validate` seams) | host-pass (tier-1 in-process witness, openwop-app #3581) | Invariant `subject-link-leaver-deny`. No production host advertises `subjectLinking`. |
|
|
121
|
+
| `auth-subject-link-key-class.test.ts` | as above (RFC 0163) | B (`subjectLinkKey` ∈ `{opaque-idp, configured-immutable}` when `subjectLinking:true`, unaided; same-IdP control + cross-IdP collision negative opt-in via `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SAML_IDP_URL_B` + `OPENWOP_TEST_SCIM_URL`) | `host-pending` (gates RFC 0163 `Active → Accepted`) | Invariants `subject-link-key-class-declared`, `subject-link-same-trust-root`. Fixture self-test lives in `src/lib/saml-idp.test.ts`, not here (G8). |
|
|
120
122
|
| `runtime-requires-shape.test.ts` | `node-pack-manifest.schema.json` `$defs/Runtime.requires` (RFC 0076 §A, `node-packs.md` §"Runtime platform requirements") | Server-free (closed-vocabulary validation: 8 tokens validate; raw builtin `node:dns/promises` rejected → `invalid_manifest`; empty-array≡omission; `uniqueItems`) | host-pass (server-free) | Always runs; no host needed. The install-time GATE behavior is in `runtime-requires-install-gate.test.ts`. |
|
|
121
123
|
| `runtime-requires-install-gate.test.ts` | RFC 0076 §A install gate (no capability flag; seam `POST /v1/host/sample/packs/install-gate`) | A (install-grant; install-refuse → `pack_runtime_requirement_unmet { unmet, manifest, advice? }`; non-sandbox SHOULD-projection — all via the optional seam) | `host-pending` | Seam-gated; soft-skips on 404. First adopter: MyndHyve's install-time gate against `core.openwop.http` declaring `["net.dns","net.outbound"]`. |
|
|
122
124
|
| `safefetch-behavior.test.ts` | `capabilities.httpClient.safeFetch` (RFC 0076 §B, `host-capabilities.md` §host.http) + `SECURITY/invariants.yaml` `http-client-ssrf-guard` | A (SSRF block + DNS-rebinding defeat + `Connection: upgrade` refusal + tool-hooks audit-when-`prePostEvents`, via the optional `POST /v1/host/sample/http/safe-fetch` seam) | in-memory ✅ | **5/5 PASS against the in-memory reference host** (2026-05-29, `OPENWOP_REQUIRE_BEHAVIOR=true`); seam-gated, soft-skips on 404 elsewhere. Reuses the existing `http-client-ssrf-guard` invariant (no new invariant). Advertisement contract in `http-client-ssrf.test.ts`. §B → Accepted still awaits `core.openwop.http@2.0.0` consumer + non-steward adoption. |
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
|
|
3
|
-
"suiteVersion": "1.
|
|
4
|
-
"corpusCommit": "
|
|
3
|
+
"suiteVersion": "1.148.0",
|
|
4
|
+
"corpusCommit": "da881becd3482742b3d75028a08ac298d8cc8d48"
|
|
5
5
|
}
|
|
@@ -3697,8 +3697,8 @@
|
|
|
3697
3697
|
},
|
|
3698
3698
|
"subjectLinkKey": {
|
|
3699
3699
|
"type": "string",
|
|
3700
|
-
"enum": ["opaque-idp", "
|
|
3701
|
-
"description": "RFC 0163 (auth-profiles.md \u00a7Subject linking). The CLASS of opaque, IdP-stable, non-PII identifier this host joins the SAML and SCIM lanes on. A CLOSED enum of ALLOWED classes only: `opaque-idp` (SCIM `externalId` \u2194 persistent-format SAML `NameID`)
|
|
3700
|
+
"enum": ["opaque-idp", "configured-immutable"],
|
|
3701
|
+
"description": "RFC 0163 (auth-profiles.md \u00a7Subject linking). The CLASS of opaque, IdP-stable, non-PII identifier this host joins the SAML and SCIM lanes on. A CLOSED enum of ALLOWED classes only, naming classes rather than vendor attributes: `opaque-idp` (SCIM `externalId` \u2194 persistent-format SAML `NameID`) or `configured-immutable` (a host-configured attribute asserted by the same IdP on both lanes that is opaque, stable for the account lifetime, never reassigned, non-PII and not user- or operator-influenceable \u2014 e.g. a directory object id or an immutable-id claim; the attribute name stays host config, the class goes on the wire). Mutable/PII keys (email, userName, displayName) are INEXPRESSIBLE by construction \u2014 a conforming host cannot name one, which is the witness that converts RFC 0159 \u00a7A.2/\u00a7A.4's negative-existence prohibition into a positive advertisement. REQUIRED when `subjectLinking` is true: expressed BOTH as the auth-profiles.md \u00a7Subject linking MUST and as this block's `if`/`then` conditional, so a discovery document claiming linking without a class fails schema validation as well as the conformance scenario. Absent when `subjectLinking` is absent/false."
|
|
3702
3702
|
},
|
|
3703
3703
|
"rotation": {
|
|
3704
3704
|
"type": "object",
|
|
@@ -3862,6 +3862,14 @@
|
|
|
3862
3862
|
}
|
|
3863
3863
|
}
|
|
3864
3864
|
},
|
|
3865
|
+
"if": {
|
|
3866
|
+
"properties": { "subjectLinking": { "const": true } },
|
|
3867
|
+
"required": ["subjectLinking"]
|
|
3868
|
+
},
|
|
3869
|
+
"then": {
|
|
3870
|
+
"required": ["subjectLinkKey"],
|
|
3871
|
+
"$comment": "RFC 0163 \u00a7A.1 / \u00a7C: a host that claims subject linking MUST declare the class it joins on. A conditional required on a flag no host emitted when it landed is a tightening of an unemitted shape (COMPATIBILITY.md \u00a72.2), not closure of a server-emitted shape (\u00a72.1)."
|
|
3872
|
+
},
|
|
3865
3873
|
"additionalProperties": true
|
|
3866
3874
|
},
|
|
3867
3875
|
"discovery": {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-free pin of the RFC 0163 §C schema conditional in
|
|
3
|
+
* `schemas/capabilities.schema.json`:
|
|
4
|
+
*
|
|
5
|
+
* auth.subjectLinking: true ⇒ auth.subjectLinkKey required
|
|
6
|
+
* auth.subjectLinkKey ∈ { opaque-idp, configured-immutable }
|
|
7
|
+
*
|
|
8
|
+
* This checks the SCHEMA ARTIFACT, not any host, which is why it lives under
|
|
9
|
+
* `src/lib/` (no scenario ledger row — see `saml-idp.test.ts` for the G8
|
|
10
|
+
* reasoning). The host-facing assertion is the advertisement leg of
|
|
11
|
+
* `scenarios/auth-subject-link-key-class.test.ts`, gated on the flag.
|
|
12
|
+
*
|
|
13
|
+
* @see RFCS/0163-subject-linking-hardening.md §C
|
|
14
|
+
* @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
21
|
+
import addFormats from 'ajv-formats';
|
|
22
|
+
import { SCHEMAS_DIR } from './paths.js';
|
|
23
|
+
|
|
24
|
+
function compileCapabilities() {
|
|
25
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
26
|
+
addFormats(ajv);
|
|
27
|
+
for (const file of readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json'))) {
|
|
28
|
+
const s = JSON.parse(readFileSync(join(SCHEMAS_DIR, file), 'utf8')) as { $id?: string };
|
|
29
|
+
ajv.addSchema(s, file);
|
|
30
|
+
ajv.addSchema(s, `./${file}`);
|
|
31
|
+
}
|
|
32
|
+
// Validate the `auth` block in isolation: the conditional lives on that
|
|
33
|
+
// object, and a full discovery document would drag in every root-level
|
|
34
|
+
// required family (supportedEnvelopes, schemaVersions, limits, …) that has
|
|
35
|
+
// nothing to do with what this file pins.
|
|
36
|
+
return ajv.compile({ $ref: 'capabilities.schema.json#/properties/auth' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PROFILES = ['openwop-auth-saml', 'openwop-auth-scim'];
|
|
40
|
+
function doc(auth: Record<string, unknown>) {
|
|
41
|
+
return auth;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('capabilities.schema.json: auth.subjectLinkKey conditional (RFC 0163 §C)', () => {
|
|
45
|
+
const validate = compileCapabilities();
|
|
46
|
+
|
|
47
|
+
it('subjectLinking:true WITHOUT subjectLinkKey fails validation (§A.1 co-requirement is in the schema, not only prose)', () => {
|
|
48
|
+
const ok = validate(doc({ profiles: PROFILES, subjectLinking: true }));
|
|
49
|
+
expect(ok, JSON.stringify(validate.errors)).toBe(false);
|
|
50
|
+
expect(JSON.stringify(validate.errors)).toContain('subjectLinkKey');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('subjectLinking:true with a class member validates', () => {
|
|
54
|
+
for (const k of ['opaque-idp', 'configured-immutable']) {
|
|
55
|
+
const ok = validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: k }));
|
|
56
|
+
expect(ok, `${k}: ${JSON.stringify(validate.errors)}`).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('a mutable/PII key is INEXPRESSIBLE: "email" fails the enum', () => {
|
|
61
|
+
const ok = validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: 'email' }));
|
|
62
|
+
expect(ok).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('the retired Draft-era attribute names are not members (the enum names classes)', () => {
|
|
66
|
+
for (const k of ['oid', 'immutable-id']) {
|
|
67
|
+
expect(validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: k })), k).toBe(false);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('subjectLinking absent or false does not require a key (nothing de-conforms)', () => {
|
|
72
|
+
expect(validate(doc({ profiles: PROFILES }))).toBe(true);
|
|
73
|
+
expect(validate(doc({ profiles: PROFILES, subjectLinking: false }))).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-free unit tests for the synthetic SAML IdP harness — specifically its
|
|
3
|
+
* TWO-TRUST-ROOT behaviour (RFC 0163 §B, gap G-fixture, #1163).
|
|
4
|
+
*
|
|
5
|
+
* These assertions prove the FIXTURE, not any host: two instances with distinct
|
|
6
|
+
* `entityID`s and signing keys can mint an assertion for the SAME opaque
|
|
7
|
+
* subject id (an identifier collision) while remaining cryptographically and
|
|
8
|
+
* by-issuer distinguishable. That is what makes the §B same-IdP MUST
|
|
9
|
+
* witnessable — a host comparing the SAML Issuer to the SCIM connection's IdP
|
|
10
|
+
* entityID has a well-defined, causable negative.
|
|
11
|
+
*
|
|
12
|
+
* They live here rather than in `scenarios/auth-subject-link-key-class.test.ts`
|
|
13
|
+
* on purpose: a scenario file that passes fixture self-tests and soft-skips its
|
|
14
|
+
* host legs resolves to `executed-pass` in every host's certification bundle
|
|
15
|
+
* (conformance-certification.md gap G8), which would credit a host that never
|
|
16
|
+
* advertised subject linking with a witness about a fixture. `src/lib/` tests
|
|
17
|
+
* run in the published suite but produce no scenario ledger row, exactly like
|
|
18
|
+
* `oidc-issuer.test.ts`.
|
|
19
|
+
*
|
|
20
|
+
* @see conformance/src/lib/saml-idp.ts
|
|
21
|
+
* @see RFCS/0163-subject-linking-hardening.md §B
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect } from 'vitest';
|
|
25
|
+
import { createSyntheticSamlIdp, DEFAULT_SAML_ENTITY_ID } from './saml-idp.js';
|
|
26
|
+
|
|
27
|
+
const COLLIDING_SUBJECT = 'idp-op-8f3a';
|
|
28
|
+
|
|
29
|
+
describe('saml-idp: two-trust-root fixture (RFC 0163 §B)', () => {
|
|
30
|
+
const idpA = createSyntheticSamlIdp({ entityID: 'urn:openwop:conformance:idp-A' });
|
|
31
|
+
const idpB = createSyntheticSamlIdp({ entityID: 'urn:openwop:conformance:idp-B' });
|
|
32
|
+
|
|
33
|
+
it('a lone instance keeps the canonical default entityID (RFC 0050 behaviour unchanged)', () => {
|
|
34
|
+
const lone = createSyntheticSamlIdp();
|
|
35
|
+
expect(lone.entityID).toBe(DEFAULT_SAML_ENTITY_ID);
|
|
36
|
+
expect(lone.verify(lone.mint('valid')).valid).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('the two IdPs have distinct entityIDs and distinct signing keys (distinct trust roots)', () => {
|
|
40
|
+
expect(idpA.entityID).not.toBe(idpB.entityID);
|
|
41
|
+
expect(idpA.certificatePem).not.toBe(idpB.certificatePem);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('both IdPs can mint a valid assertion for the SAME colliding subject id', () => {
|
|
45
|
+
const a = idpA.verify(idpA.mint('valid', { subject: COLLIDING_SUBJECT }));
|
|
46
|
+
const b = idpB.verify(idpB.mint('valid', { subject: COLLIDING_SUBJECT }));
|
|
47
|
+
expect(a.valid, `IdP-A must accept its own assertion; got ${a.reason}`).toBe(true);
|
|
48
|
+
expect(b.valid, `IdP-B must accept its own assertion; got ${b.reason}`).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('the colliding assertions carry DIFFERENT signed Issuers (the trust-root discriminator)', () => {
|
|
52
|
+
const assertionA = idpA.mint('valid', { subject: COLLIDING_SUBJECT });
|
|
53
|
+
const assertionB = idpB.mint('valid', { subject: COLLIDING_SUBJECT });
|
|
54
|
+
expect(idpA.issuerOf(assertionA)).toBe('urn:openwop:conformance:idp-A');
|
|
55
|
+
expect(idpB.issuerOf(assertionB)).toBe('urn:openwop:conformance:idp-B');
|
|
56
|
+
expect(idpA.issuerOf(assertionA)).not.toBe(idpB.issuerOf(assertionB));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('IdP-A REJECTS an assertion minted by IdP-B for the same subject (cross-root does not verify)', () => {
|
|
60
|
+
// The crux of RFC 0163 §B: an identifier collision across two trust roots
|
|
61
|
+
// is NOT a link. IdP-B signs with a different key AND stamps a different
|
|
62
|
+
// Issuer, so IdP-A's validator refuses it — a match on the opaque subject
|
|
63
|
+
// string alone can never authorize across the roots.
|
|
64
|
+
const crossRoot = idpB.mint('valid', { subject: COLLIDING_SUBJECT });
|
|
65
|
+
const r = idpA.verify(crossRoot);
|
|
66
|
+
expect(r.valid, 'an assertion from a DIFFERENT trust root MUST NOT verify against this IdP').toBe(false);
|
|
67
|
+
expect(r.reason).toBe('bad-signature');
|
|
68
|
+
expect(idpA.issuerOf(crossRoot)).not.toBe(idpA.entityID);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('the Issuer is inside the signed element: swapping it post-signing breaks verification', () => {
|
|
72
|
+
// RFC 0163 §B.1 requires the trust-root identity to be signed so it cannot
|
|
73
|
+
// be rewritten to impersonate another root. Rewrite IdP-B's Issuer to
|
|
74
|
+
// IdP-A's entityID on an otherwise valid IdP-B assertion: the issuer now
|
|
75
|
+
// READS as A, but the signature was computed over B's canonical form.
|
|
76
|
+
const forged = idpB
|
|
77
|
+
.mint('valid', { subject: COLLIDING_SUBJECT })
|
|
78
|
+
.replace('<saml:Issuer>urn:openwop:conformance:idp-B</saml:Issuer>', '<saml:Issuer>urn:openwop:conformance:idp-A</saml:Issuer>');
|
|
79
|
+
expect(idpA.issuerOf(forged)).toBe(idpA.entityID);
|
|
80
|
+
expect(idpA.verify(forged).valid, 'a re-stamped Issuer MUST NOT verify').toBe(false);
|
|
81
|
+
expect(idpB.verify(forged).valid, 'the original root MUST NOT verify it either').toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
package/src/lib/saml-idp.ts
CHANGED
|
@@ -16,7 +16,23 @@
|
|
|
16
16
|
* `auth/saml/validate` test seam; sign/verify here are mutually consistent
|
|
17
17
|
* by construction (the harness owns both the serialization and the digest).
|
|
18
18
|
*
|
|
19
|
+
* ## Trust roots (RFC 0163 §B)
|
|
20
|
+
*
|
|
21
|
+
* Each instance carries a distinct `entityID` — the SAML `Issuer` it stamps
|
|
22
|
+
* into (and signs into) every assertion. Two instances with different
|
|
23
|
+
* `entityID`s model two **independent IdP trust roots**: they have distinct
|
|
24
|
+
* signing keys AND distinct issuers, so an assertion minted by IdP-B does not
|
|
25
|
+
* verify against IdP-A's certificate and its `Issuer` does not match IdP-A's
|
|
26
|
+
* entityID. This is the fixture the RFC 0163 §B same-IdP-trust-root behavioral
|
|
27
|
+
* leg needs: two IdPs can mint an assertion for the **same** opaque `subject`
|
|
28
|
+
* (an identifier collision) while remaining cryptographically and by-issuer
|
|
29
|
+
* distinguishable, so a host that joins the SAML and SCIM lanes MUST refuse to
|
|
30
|
+
* link across the two roots. `issuerOf()` exposes the (signed) `Issuer` so a
|
|
31
|
+
* scenario — or a host — can assert same-trust-root correspondence before
|
|
32
|
+
* forming a link.
|
|
33
|
+
*
|
|
19
34
|
* @see RFCS/0050-saml-scim-enterprise-identity-profiles.md §A
|
|
35
|
+
* @see RFCS/0163-subject-linking-hardening.md §B (same-IdP trust root)
|
|
20
36
|
* @see spec/v1/auth-profiles.md §`openwop-auth-saml`
|
|
21
37
|
*/
|
|
22
38
|
|
|
@@ -52,13 +68,39 @@ export interface SamlVerifyResult {
|
|
|
52
68
|
| 'malformed';
|
|
53
69
|
}
|
|
54
70
|
|
|
71
|
+
export interface SyntheticSamlIdpOptions {
|
|
72
|
+
/**
|
|
73
|
+
* This IdP's SAML `entityID`, stamped and signed into every assertion as the
|
|
74
|
+
* `<saml:Issuer>` and exposed as `.entityID`. Two instances with distinct
|
|
75
|
+
* `entityID`s model two independent trust roots (RFC 0163 §B). Defaults to a
|
|
76
|
+
* fixed canonical value so a lone instance behaves exactly as before.
|
|
77
|
+
*/
|
|
78
|
+
readonly entityID?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The default single-IdP entityID (unchanged behaviour for a lone instance). */
|
|
82
|
+
export const DEFAULT_SAML_ENTITY_ID = 'urn:openwop:conformance:idp';
|
|
83
|
+
|
|
55
84
|
export interface SyntheticSamlIdp {
|
|
56
85
|
/** PEM signing certificate (public key) the host configures to trust this IdP. */
|
|
57
86
|
readonly certificatePem: string;
|
|
87
|
+
/**
|
|
88
|
+
* This IdP's SAML `entityID` — the `<saml:Issuer>` it signs into every
|
|
89
|
+
* assertion, and the trust-root identity a host records for the connection.
|
|
90
|
+
* Distinct per instance (RFC 0163 §B).
|
|
91
|
+
*/
|
|
92
|
+
readonly entityID: string;
|
|
58
93
|
/** Mint a SAML assertion of the given variant. */
|
|
59
94
|
mint(variant: SamlVariant, opts?: { subject?: string }): string;
|
|
60
95
|
/** Validate an assertion per the RFC 0050 §A MUST list. */
|
|
61
96
|
verify(assertionXml: string): SamlVerifyResult;
|
|
97
|
+
/**
|
|
98
|
+
* The `Issuer` (entityID) carried by the consumed assertion, or `null` if the
|
|
99
|
+
* assertion is malformed. RFC 0163 §B: a host compares this against the SCIM
|
|
100
|
+
* connection's recorded IdP entityID and MUST NOT form a link across two
|
|
101
|
+
* distinct trust roots even when the opaque subject id collides.
|
|
102
|
+
*/
|
|
103
|
+
issuerOf(assertionXml: string): string | null;
|
|
62
104
|
}
|
|
63
105
|
|
|
64
106
|
const SIG_ALG_RSA_SHA256 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
|
|
@@ -69,17 +111,21 @@ function digest(input: string): string {
|
|
|
69
111
|
}
|
|
70
112
|
|
|
71
113
|
/** Deterministic canonical form of the signed element: the harness controls
|
|
72
|
-
* the exact byte string, so sign/verify agree without a full C14N stack.
|
|
73
|
-
|
|
114
|
+
* the exact byte string, so sign/verify agree without a full C14N stack. The
|
|
115
|
+
* `<saml:Issuer>` is inside the signed element (RFC 0163 §B: the trust-root
|
|
116
|
+
* identity is signed, so it cannot be swapped post-signing). */
|
|
117
|
+
function canonicalAssertion(id: string, issuer: string, subject: string, notBefore: string, notOnOrAfter: string): string {
|
|
74
118
|
return (
|
|
75
119
|
`<saml:Assertion ID="${id}" Version="2.0">` +
|
|
120
|
+
`<saml:Issuer>${issuer}</saml:Issuer>` +
|
|
76
121
|
`<saml:Conditions NotBefore="${notBefore}" NotOnOrAfter="${notOnOrAfter}"/>` +
|
|
77
122
|
`<saml:Subject><saml:NameID>${subject}</saml:NameID></saml:Subject>` +
|
|
78
123
|
`</saml:Assertion>`
|
|
79
124
|
);
|
|
80
125
|
}
|
|
81
126
|
|
|
82
|
-
export function createSyntheticSamlIdp(): SyntheticSamlIdp {
|
|
127
|
+
export function createSyntheticSamlIdp(opts?: SyntheticSamlIdpOptions): SyntheticSamlIdp {
|
|
128
|
+
const entityID = opts?.entityID ?? DEFAULT_SAML_ENTITY_ID;
|
|
83
129
|
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
84
130
|
const certificatePem = publicKey.export({ format: 'pem', type: 'spki' }).toString();
|
|
85
131
|
|
|
@@ -97,7 +143,7 @@ export function createSyntheticSamlIdp(): SyntheticSamlIdp {
|
|
|
97
143
|
refId: string; // the ID the <Reference> points at (≠ id ⇒ wrapping)
|
|
98
144
|
extraInjected?: string; // an unsigned injected assertion (wrapping attack)
|
|
99
145
|
}): string {
|
|
100
|
-
const inner = canonicalAssertion(parts.id, parts.subject, parts.notBefore, parts.notOnOrAfter);
|
|
146
|
+
const inner = canonicalAssertion(parts.id, entityID, parts.subject, parts.notBefore, parts.notOnOrAfter);
|
|
101
147
|
const sig =
|
|
102
148
|
parts.signatureValue === null
|
|
103
149
|
? ''
|
|
@@ -115,7 +161,7 @@ export function createSyntheticSamlIdp(): SyntheticSamlIdp {
|
|
|
115
161
|
const iso = (ms: number): string => new Date(ms).toISOString();
|
|
116
162
|
const past = iso(now - 3_600_000);
|
|
117
163
|
const future = iso(now + 3_600_000);
|
|
118
|
-
const canonical = canonicalAssertion(id, subject, past, future);
|
|
164
|
+
const canonical = canonicalAssertion(id, entityID, subject, past, future);
|
|
119
165
|
|
|
120
166
|
switch (variant) {
|
|
121
167
|
case 'valid':
|
|
@@ -127,11 +173,11 @@ export function createSyntheticSamlIdp(): SyntheticSamlIdp {
|
|
|
127
173
|
case 'bad-signature':
|
|
128
174
|
return envelope({ id, subject, notBefore: past, notOnOrAfter: future, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: Buffer.from('forged').toString('base64'), refId: id });
|
|
129
175
|
case 'expired': {
|
|
130
|
-
const c = canonicalAssertion(id, subject, iso(now - 7_200_000), past);
|
|
176
|
+
const c = canonicalAssertion(id, entityID, subject, iso(now - 7_200_000), past);
|
|
131
177
|
return envelope({ id, subject, notBefore: iso(now - 7_200_000), notOnOrAfter: past, sigAlg: SIG_ALG_RSA_SHA256, signatureValue: sign(c), refId: id });
|
|
132
178
|
}
|
|
133
179
|
case 'not-yet-valid': {
|
|
134
|
-
const c = canonicalAssertion(id, subject, future, iso(now + 7_200_000));
|
|
180
|
+
const c = canonicalAssertion(id, entityID, subject, future, iso(now + 7_200_000));
|
|
135
181
|
return envelope({ id, subject, notBefore: future, notOnOrAfter: iso(now + 7_200_000), sigAlg: SIG_ALG_RSA_SHA256, signatureValue: sign(c), refId: id });
|
|
136
182
|
}
|
|
137
183
|
case 'signature-wrapping': {
|
|
@@ -139,8 +185,8 @@ export function createSyntheticSamlIdp(): SyntheticSamlIdp {
|
|
|
139
185
|
// second, attacker-injected assertion with a different Subject is what
|
|
140
186
|
// a naive consumer reads. The signed element ≠ the consumed element.
|
|
141
187
|
const benign = 'a-benign';
|
|
142
|
-
const benignCanonical = canonicalAssertion(benign, subject, past, future);
|
|
143
|
-
const injected = canonicalAssertion(id, 'attacker@evil.example-opaque', past, future);
|
|
188
|
+
const benignCanonical = canonicalAssertion(benign, entityID, subject, past, future);
|
|
189
|
+
const injected = canonicalAssertion(id, entityID, 'attacker@evil.example-opaque', past, future);
|
|
144
190
|
const sig =
|
|
145
191
|
`<ds:Signature><ds:SignedInfo><ds:SignatureMethod Algorithm="${SIG_ALG_RSA_SHA256}"/>` +
|
|
146
192
|
`<ds:Reference URI="#${benign}"><ds:DigestValue>${digest(benignCanonical)}</ds:DigestValue></ds:Reference>` +
|
|
@@ -151,21 +197,37 @@ export function createSyntheticSamlIdp(): SyntheticSamlIdp {
|
|
|
151
197
|
}
|
|
152
198
|
}
|
|
153
199
|
|
|
200
|
+
/** Parse the consumed (first) assertion; `null` if it is malformed. */
|
|
201
|
+
function parseConsumed(assertionXml: string): { id: string; issuer: string; notBefore: string; notOnOrAfter: string; subject: string } | null {
|
|
202
|
+
// The consumed assertion is the FIRST <saml:Assertion> in the response.
|
|
203
|
+
const m = /<saml:Assertion ID="([^"]+)"[^>]*>[\s\S]*?<saml:Issuer>([^<]*)<\/saml:Issuer>[\s\S]*?<saml:Conditions NotBefore="([^"]+)" NotOnOrAfter="([^"]+)"\/>[\s\S]*?<saml:NameID>([^<]*)<\/saml:NameID>/.exec(assertionXml);
|
|
204
|
+
if (m === null) return null;
|
|
205
|
+
const [, id, issuer, notBefore, notOnOrAfter, subject] = m;
|
|
206
|
+
return { id, issuer, notBefore, notOnOrAfter, subject };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function issuerOf(assertionXml: string): string | null {
|
|
210
|
+
return parseConsumed(assertionXml)?.issuer ?? null;
|
|
211
|
+
}
|
|
212
|
+
|
|
154
213
|
function verify(assertionXml: string): SamlVerifyResult {
|
|
155
214
|
const sigAlg = /<ds:SignatureMethod Algorithm="([^"]+)"/.exec(assertionXml)?.[1];
|
|
156
215
|
const sigValue = /<ds:SignatureValue>([^<]*)<\/ds:SignatureValue>/.exec(assertionXml)?.[1];
|
|
157
216
|
const refId = /<ds:Reference URI="#([^"]+)"/.exec(assertionXml)?.[1];
|
|
158
|
-
|
|
159
|
-
const consumed = /<saml:Assertion ID="([^"]+)"[^>]*>[\s\S]*?<saml:Conditions NotBefore="([^"]+)" NotOnOrAfter="([^"]+)"\/>[\s\S]*?<saml:NameID>([^<]*)<\/saml:NameID>/.exec(assertionXml);
|
|
217
|
+
const consumed = parseConsumed(assertionXml);
|
|
160
218
|
if (consumed === null) return { valid: false, reason: 'malformed' };
|
|
161
|
-
const
|
|
219
|
+
const { id: consumedId, issuer, notBefore, notOnOrAfter, subject } = consumed;
|
|
162
220
|
|
|
163
221
|
if (sigValue === undefined || sigAlg === undefined) return { valid: false, reason: 'unsigned' };
|
|
164
222
|
if (sigAlg === SIG_ALG_NONE) return { valid: false, reason: 'alg-none' };
|
|
165
223
|
// Anti-wrapping: the signature MUST reference the consumed assertion.
|
|
166
224
|
if (refId !== consumedId) return { valid: false, reason: 'signature-wrapping' };
|
|
167
225
|
|
|
168
|
-
|
|
226
|
+
// The signed canonical includes the Issuer (RFC 0163 §B): an assertion from
|
|
227
|
+
// a DIFFERENT trust root carries a different Issuer AND is signed by a
|
|
228
|
+
// different key, so verification against THIS IdP's public key fails
|
|
229
|
+
// (`bad-signature`). The crypto separation IS the trust-root separation.
|
|
230
|
+
const canonical = canonicalAssertion(consumedId, issuer, subject, notBefore, notOnOrAfter);
|
|
169
231
|
const ok = createVerify('RSA-SHA256').update(canonical, 'utf8').verify(publicKey, sigValue, 'base64');
|
|
170
232
|
if (!ok) return { valid: false, reason: 'bad-signature' };
|
|
171
233
|
|
|
@@ -175,5 +237,5 @@ export function createSyntheticSamlIdp(): SyntheticSamlIdp {
|
|
|
175
237
|
return { valid: true, reason: null };
|
|
176
238
|
}
|
|
177
239
|
|
|
178
|
-
return { certificatePem, mint, verify };
|
|
240
|
+
return { certificatePem, entityID, mint, verify, issuerOf };
|
|
179
241
|
}
|
|
@@ -3,29 +3,38 @@
|
|
|
3
3
|
* hardening (the declarable, witnessable link-key class + the same-IdP
|
|
4
4
|
* trust-root MUST).
|
|
5
5
|
*
|
|
6
|
-
* Status:
|
|
7
|
-
* is `Accepted`). The obligations are documented in `auth-profiles.md`
|
|
6
|
+
* Status: ACTIVE. RFC 0163 is `Active` (additive follow-on to RFC 0159, which
|
|
7
|
+
* is `Accepted`); wire shape locked 2026-09-01. The obligations are documented in `auth-profiles.md`
|
|
8
8
|
* §"Subject linking (SAML ⟷ SCIM)" → "Link-key-class declaration and same-IdP
|
|
9
9
|
* trust root — RFC 0163", and are discoverable via
|
|
10
10
|
* `capabilities.auth.subjectLinkKey` (a closed enum) under the same
|
|
11
11
|
* `capabilities.auth.subjectLinking` opt-in as RFC 0159.
|
|
12
12
|
*
|
|
13
13
|
* TWO LEGS:
|
|
14
|
-
* (§A) ADVERTISEMENT —
|
|
15
|
-
* subjectLinking:true host MUST advertise a subjectLinkKey that is a
|
|
16
|
-
* member of the CLOSED enum {opaque-idp,
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* (§A) ADVERTISEMENT — a discovery read, runs whenever subjectLinking:true.
|
|
15
|
+
* A subjectLinking:true host MUST advertise a subjectLinkKey that is a
|
|
16
|
+
* member of the CLOSED enum {opaque-idp, configured-immutable}. The
|
|
17
|
+
* enum names classes, not attributes. Mutable/PII keys are
|
|
18
|
+
* inexpressible by construction, which is the witness that converts
|
|
19
|
+
* RFC 0159 §A.2/§A.4's negative-existence prohibition into a positive
|
|
20
|
+
* advertisement. The schema conditional (subjectLinking:true ⇒
|
|
21
|
+
* subjectLinkKey required) is pinned server-free in
|
|
22
|
+
* src/lib/capabilities-auth-subject-link.test.ts.
|
|
20
23
|
* (§B) SAME-IDP TRUST ROOT — behavioral. A SAML(IdP-A) + SCIM(IdP-B)
|
|
21
|
-
* identifier collision MUST NOT form a cross-lane link. This leg
|
|
22
|
-
* TWO-TRUST-ROOT fixture
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* identifier collision MUST NOT form a cross-lane link. This leg is
|
|
25
|
+
* backed by the TWO-TRUST-ROOT fixture engineered in
|
|
26
|
+
* `conformance/src/lib/saml-idp.ts` (two synthetic IdPs with distinct
|
|
27
|
+
* `entityID`s / signing keys minting a colliding subject id — RFC 0163
|
|
28
|
+
* gap G-fixture, addressed in #1163). The fixture's own proof (distinct
|
|
29
|
+
* roots, colliding subject, signed Issuers, cross-root rejection) lives
|
|
30
|
+
* in `src/lib/saml-idp.test.ts` — deliberately NOT in this file, so a
|
|
31
|
+
* host that never advertises linking does not record an executed-pass
|
|
32
|
+
* about a fixture (certification gap G8). This file carries only the
|
|
33
|
+
* live BLACK-BOX leg over the host's SCIM+SAML seams, opt-in via the
|
|
34
|
+
* two-IdP env vars below, `inapplicable` until a host wires the seam.
|
|
27
35
|
*
|
|
28
|
-
* @see RFCS/0163-subject-linking-hardening.md
|
|
36
|
+
* @see RFCS/0163-subject-linking-hardening.md §B (same-IdP trust root)
|
|
37
|
+
* @see conformance/src/lib/saml-idp.ts — the two-trust-root fixture
|
|
29
38
|
* @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
|
|
30
39
|
*/
|
|
31
40
|
|
|
@@ -36,7 +45,7 @@ import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
|
36
45
|
|
|
37
46
|
const SAML_PROFILE = 'openwop-auth-saml';
|
|
38
47
|
const SCIM_PROFILE = 'openwop-auth-scim';
|
|
39
|
-
const SUBJECT_LINK_KEY_CLASSES = ['opaque-idp', '
|
|
48
|
+
const SUBJECT_LINK_KEY_CLASSES = ['opaque-idp', 'configured-immutable'] as const;
|
|
40
49
|
|
|
41
50
|
interface DiscoveryAuth {
|
|
42
51
|
profiles?: string[];
|
|
@@ -72,14 +81,14 @@ describe('auth-subject-link-key-class: advertisement shape (RFC 0163 §A)', () =
|
|
|
72
81
|
).toBe(true);
|
|
73
82
|
|
|
74
83
|
// §A.3 — the value MUST be a member of the CLOSED enum of allowed classes
|
|
75
|
-
// (opaque-idp |
|
|
84
|
+
// (opaque-idp | configured-immutable). Mutable/PII keys (email, userName,
|
|
76
85
|
// displayName) are absent from the enum by construction, so a conforming
|
|
77
86
|
// host cannot name one.
|
|
78
87
|
expect(
|
|
79
88
|
(SUBJECT_LINK_KEY_CLASSES as readonly string[]).includes(auth.subjectLinkKey ?? ''),
|
|
80
89
|
driver.describe(
|
|
81
90
|
'auth-profiles.md §Subject linking',
|
|
82
|
-
`RFC 0163 §A.3: subjectLinkKey MUST be one of ${SUBJECT_LINK_KEY_CLASSES.join(', ')} — a closed enum of allowed
|
|
91
|
+
`RFC 0163 §A.3: subjectLinkKey MUST be one of ${SUBJECT_LINK_KEY_CLASSES.join(', ')} — a closed enum of allowed CLASSES (not attributes); a mutable/PII key is inexpressible`,
|
|
83
92
|
),
|
|
84
93
|
).toBe(true);
|
|
85
94
|
});
|
|
@@ -100,18 +109,68 @@ describe('auth-subject-link-key-class: advertisement shape (RFC 0163 §A)', () =
|
|
|
100
109
|
});
|
|
101
110
|
});
|
|
102
111
|
|
|
103
|
-
describe('auth-subject-link-key-class: same-IdP trust root (RFC 0163 §B —
|
|
104
|
-
|
|
112
|
+
describe('auth-subject-link-key-class: same-IdP trust root (RFC 0163 §B — behavioral, opt-in two-IdP seam)', () => {
|
|
113
|
+
// Two distinct trust roots served over two operator-supplied synthetic-IdP
|
|
114
|
+
// endpoints, plus the RFC 0159 SCIM seam. IdP-A feeds the SCIM lane; IdP-B is
|
|
115
|
+
// the colliding cross-root SAML issuer. Opt-in and soft-skipping until a host
|
|
116
|
+
// wires the two-trust-root seam — it MUST NOT fail a host that has not.
|
|
117
|
+
const idpAUrl = process.env.OPENWOP_TEST_SAML_IDP_URL; // trust root A (also the SCIM lane's IdP)
|
|
118
|
+
const idpBUrl = process.env.OPENWOP_TEST_SAML_IDP_URL_B; // trust root B (the cross-IdP collider)
|
|
119
|
+
const scimUrl = process.env.OPENWOP_TEST_SCIM_URL;
|
|
120
|
+
|
|
121
|
+
it('a same-IdP link forms (control) but a cross-IdP collision MUST NOT link', async () => {
|
|
105
122
|
const auth = await readAuth();
|
|
106
|
-
if (auth === null || auth.subjectLinking !== true)
|
|
107
|
-
|
|
123
|
+
if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated on auth.subjectLinking');
|
|
124
|
+
if (!idpAUrl || !idpBUrl || !scimUrl) {
|
|
125
|
+
return softSkip('inapplicable', 'opt-in: the two-trust-root seam needs OPENWOP_TEST_SAML_IDP_URL (IdP-A) + OPENWOP_TEST_SAML_IDP_URL_B (IdP-B) + OPENWOP_TEST_SCIM_URL');
|
|
108
126
|
}
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
127
|
+
|
|
128
|
+
// POSITIVE CONTROL — same trust root both lanes. Provision a SCIM user
|
|
129
|
+
// whose SCIM connection is fed by IdP-A, then present a valid SAML assertion
|
|
130
|
+
// from IdP-A for the same opaque id: the link forms and authenticates.
|
|
131
|
+
// Proving presence here is what keeps the negative leg non-vacuous — "no
|
|
132
|
+
// cross-IdP link" passes identically on a host that never links at all.
|
|
133
|
+
const sameId = 'idp-op-same-8f3a';
|
|
134
|
+
const provSame = await driver.post('/v1/host/sample/auth/scim/provision', {
|
|
135
|
+
scimUrl,
|
|
136
|
+
idpUrl: idpAUrl, // the SCIM connection's IdP trust root
|
|
137
|
+
op: 'create-user',
|
|
138
|
+
externalId: sameId,
|
|
139
|
+
userName: 'r.smith',
|
|
140
|
+
});
|
|
141
|
+
if (provSame.status === 404) return softSkip('blocked', 'SCIM provisioning seam unwired');
|
|
142
|
+
expect(provSame.status, driver.describe('auth-profiles.md §Subject linking', 'SCIM provisioning MUST succeed')).toBeLessThan(400);
|
|
143
|
+
|
|
144
|
+
const sameIdp = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl: idpAUrl, variant: 'valid', nameId: sameId });
|
|
145
|
+
if (sameIdp.status === 404) return softSkip('blocked', 'SAML validate seam unwired');
|
|
146
|
+
expect(
|
|
147
|
+
(sameIdp.json as { authenticated?: boolean } | undefined)?.authenticated,
|
|
148
|
+
driver.describe('auth-profiles.md §Subject linking', 'RFC 0163 §B positive control: a SAML assertion from the SAME IdP trust root as the SCIM lane links and authenticates'),
|
|
149
|
+
).toBe(true);
|
|
150
|
+
|
|
151
|
+
// THE §B.1 MUST — cross-IdP identifier collision. Provision a SCIM user fed
|
|
152
|
+
// by IdP-A, then present a valid SAML assertion for the SAME opaque id but
|
|
153
|
+
// ISSUED BY IdP-B (a different trust root). The string collides; the trust
|
|
154
|
+
// roots do not. The host MUST NOT form the link, so the assertion MUST NOT
|
|
155
|
+
// authenticate as the SCIM-provisioned principal.
|
|
156
|
+
const collideId = 'idp-op-collide-8f3a';
|
|
157
|
+
const provCross = await driver.post('/v1/host/sample/auth/scim/provision', {
|
|
158
|
+
scimUrl,
|
|
159
|
+
idpUrl: idpAUrl, // SCIM lane trust root = IdP-A
|
|
160
|
+
op: 'create-user',
|
|
161
|
+
externalId: collideId,
|
|
162
|
+
userName: 'a.other',
|
|
163
|
+
});
|
|
164
|
+
expect(provCross.status, driver.describe('auth-profiles.md §Subject linking', 'SCIM provisioning MUST succeed')).toBeLessThan(400);
|
|
165
|
+
|
|
166
|
+
const crossIdp = await driver.post('/v1/host/sample/auth/saml/validate', { idpUrl: idpBUrl, variant: 'valid', nameId: collideId });
|
|
167
|
+
if (crossIdp.status === 404) return softSkip('blocked', 'SAML validate seam unwired');
|
|
168
|
+
expect(
|
|
169
|
+
(crossIdp.json as { authenticated?: boolean } | undefined)?.authenticated === true,
|
|
170
|
+
driver.describe(
|
|
171
|
+
'auth-profiles.md §Subject linking',
|
|
172
|
+
'RFC 0163 §B.1: a SAML assertion from IdP-B colliding on an identifier the SCIM lane provisioned from IdP-A MUST NOT form a cross-lane link (no cross-IdP identifier collision joins two principals)',
|
|
173
|
+
),
|
|
174
|
+
).toBe(false);
|
|
116
175
|
});
|
|
117
176
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* auth-subject-link — RFC 0159: SCIM ⟷ SAML subject linking (the combined
|
|
3
3
|
* leaver contract).
|
|
4
4
|
*
|
|
5
|
-
* Status:
|
|
5
|
+
* Status: ACCEPTED. RFC 0159 is `Accepted` (amends RFC 0050; hardened by RFC 0163). The obligation is
|
|
6
6
|
* documented in `auth-profiles.md` §"Subject linking (SAML ⟷ SCIM)" and is
|
|
7
7
|
* discoverable via `capabilities.auth.subjectLinking`.
|
|
8
8
|
*
|