@openwop/openwop-conformance 1.149.0 → 1.151.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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [1.151.0] — 2026-09-02 — RFC 0164: the subject-link scenarios gate on the profile pair; the opted-out shape fails
4
+
5
+ No new scenario file. `auth-subject-link.test.ts` and `auth-subject-link-key-class.test.ts` re-gate every leg on the host advertising **both** `openwop-auth-saml` and `openwop-auth-scim`, not on `capabilities.auth.subjectLinking`, which RFC 0164 makes a derived advertisement. The RFC 0159 advertisement block gains a leg: both profiles advertised ⇒ `subjectLinking === true` and `subjectLinkKey ∈ {opaque-idp, configured-immutable}`, else `executed-fail` citing RFC 0164 §A.3. That is the shape a combined host that opted out used to present, and it used to read `inapplicable` — a host shipping the leaver bypass was invisible to the suite. `src/lib/capabilities-auth-subject-link.test.ts` pins the new schema conditional (both profiles with the flag absent, `false`, or without a key are rejected; with `true` + a class accepted; profile order irrelevant; single-profile hosts unaffected). `capabilities.schema.json` composes the RFC 0163 and RFC 0164 conditionals under `allOf`; `coverage.md` rows re-worded. No production host advertises both profiles, so no host's recorded evidence changes.
6
+
3
7
  ## [1.149.0] — 2026-09-02 — RFC 0163 `Active → Accepted` (tier-1 witness)
4
8
 
5
9
  No scenario change; `coverage.md` (packed) moves the `auth-subject-link-key-class.test.ts` behaviour grade from `host-pending` to host-pass, so the published tarball content changes and the identity gate asks for a bump. The reference host (openwop-app #3614, `2f6ca969d`, ADR 0620) emits `subjectLinkKey: "opaque-idp"` from the same gate as `subjectLinking`, reconstructs the suite's signed `<saml:Issuer>` in its validator, binds the SCIM connection to an IdP entityID (`idpUrl` accepted on the provision seam, SSRF-guarded), and refuses a cross-IdP identifier collision with `401 subject_link_trust_root_mismatch` on the validate seam and with no session on the production ACS. The host widened its seam SSRF allowlist to the second synthetic IdP first, so the cross-root negative is refused on trust root rather than on origin — sabotage-verified. Single-witness under the bootstrap waiver; no tier-2/3 host advertises `subjectLinking`.
package/coverage.md CHANGED
@@ -117,8 +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-pass (tier-1 in-process witness, openwop-app #3614) | 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
+ | `auth-subject-link.test.ts` | `openwop-auth-saml` + `openwop-auth-scim` advertised together (RFC 0159 + RFC 0164, `auth-profiles.md` §Subject linking; `subjectLinking` is derived) | B (advertisement gate always — `subjectLinking:true` only alongside both profiles, AND both profiles ⇒ `subjectLinking:true` + `subjectLinkKey` (RFC 0164 vulnerable-shape leg, `executed-fail` never `inapplicable`); 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; gated on the profile pair since RFC 0164) | 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-pass (tier-1 in-process witness, openwop-app #3614) | 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). |
122
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`. |
123
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"]`. |
124
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.149.0",
3
+ "version": "1.151.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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.149.0",
4
- "corpusCommit": "6cdf17d36ce5856f9ba775fd393ee02a6da3eded"
3
+ "suiteVersion": "1.151.0",
4
+ "corpusCommit": "b1203c4399516184e2ad36aaedfec35331abb175"
5
5
  }
@@ -3693,7 +3693,7 @@
3693
3693
  },
3694
3694
  "subjectLinking": {
3695
3695
  "type": "boolean",
3696
- "description": "RFC 0159 (auth-profiles.md \u00a7Subject linking). When true, this host advertises BOTH `openwop-auth-saml` and `openwop-auth-scim` AND honours the cross-profile subject-linking obligation: it links the SAML and SCIM principals of one human on an opaque, IdP-stable subject id (SCIM `externalId` \u2194 persistent SAML `NameID`), same-tenant only, and on SCIM deactivation fail-closes the linked SAML identity too (composing RFC 0049 \u00a7C) \u2014 so a provisioned leaver cannot still SSO in. The link key MUST NOT be a mutable/PII attribute (email, userName). Optional; absent/false means the SAML and SCIM lanes are independent and the combined leaver guarantee is NOT claimed. A host MUST NOT set this true unless both profiles are advertised and the obligation holds."
3696
+ "description": "RFC 0164: DERIVED and DEPRECATED toward v2. A host that advertises both `openwop-auth-saml` and `openwop-auth-scim` MUST set this true (schema conditional on `profiles[]`); a document advertising both with this absent/false is invalid. Kept through v1.x for clients pinned to it; clients SHOULD derive the guarantee from the profile pair. Original (RFC 0159) semantics follow. RFC 0159 (auth-profiles.md \u00a7Subject linking). When true, this host advertises BOTH `openwop-auth-saml` and `openwop-auth-scim` AND honours the cross-profile subject-linking obligation: it links the SAML and SCIM principals of one human on an opaque, IdP-stable subject id (SCIM `externalId` \u2194 persistent SAML `NameID`), same-tenant only, and on SCIM deactivation fail-closes the linked SAML identity too (composing RFC 0049 \u00a7C) \u2014 so a provisioned leaver cannot still SSO in. The link key MUST NOT be a mutable/PII attribute (email, userName). Optional; absent/false means the SAML and SCIM lanes are independent and the combined leaver guarantee is NOT claimed. A host MUST NOT set this true unless both profiles are advertised and the obligation holds."
3697
3697
  },
3698
3698
  "subjectLinkKey": {
3699
3699
  "type": "string",
@@ -3862,14 +3862,36 @@
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
+ "allOf": [
3866
+ {
3867
+ "if": {
3868
+ "properties": { "subjectLinking": { "const": true } },
3869
+ "required": ["subjectLinking"]
3870
+ },
3871
+ "then": {
3872
+ "required": ["subjectLinkKey"],
3873
+ "$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)."
3874
+ }
3875
+ },
3876
+ {
3877
+ "if": {
3878
+ "properties": {
3879
+ "profiles": {
3880
+ "allOf": [
3881
+ { "contains": { "const": "openwop-auth-saml" } },
3882
+ { "contains": { "const": "openwop-auth-scim" } }
3883
+ ]
3884
+ }
3885
+ },
3886
+ "required": ["profiles"]
3887
+ },
3888
+ "then": {
3889
+ "properties": { "subjectLinking": { "const": true } },
3890
+ "required": ["subjectLinking", "subjectLinkKey"],
3891
+ "$comment": "RFC 0164 \u00a7A.3 / \u00a7B: advertising BOTH openwop-auth-saml and openwop-auth-scim implies the combined leaver contract, so `subjectLinking` MUST be true (a derived statement of fact, deprecated toward v2) and `subjectLinkKey` MUST name the class. Binds only documents that emit both profile strings; none did when it landed (COMPATIBILITY.md \u00a72.2 / \u00a74 default-on row with an empty harm set)."
3892
+ }
3893
+ }
3894
+ ],
3873
3895
  "additionalProperties": true
3874
3896
  },
3875
3897
  "discovery": {
@@ -2,8 +2,9 @@
2
2
  * Server-free pin of the RFC 0163 §C schema conditional in
3
3
  * `schemas/capabilities.schema.json`:
4
4
  *
5
- * auth.subjectLinking: true ⇒ auth.subjectLinkKey required
6
- * auth.subjectLinkKey ∈ { opaque-idp, configured-immutable }
5
+ * auth.subjectLinking: true ⇒ auth.subjectLinkKey required (RFC 0163)
6
+ * auth.subjectLinkKey ∈ { opaque-idp, configured-immutable } (RFC 0163)
7
+ * profiles ⊇ {saml, scim} ⇒ subjectLinking const true + key required (RFC 0164)
7
8
  *
8
9
  * This checks the SCHEMA ARTIFACT, not any host, which is why it lives under
9
10
  * `src/lib/` (no scenario ledger row — see `saml-idp.test.ts` for the G8
@@ -68,8 +69,35 @@ describe('capabilities.schema.json: auth.subjectLinkKey conditional (RFC 0163 §
68
69
  }
69
70
  });
70
71
 
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);
72
+ it('one profile alone does not require the flag or a key (SAML-only / SCIM-only hosts are unaffected)', () => {
73
+ expect(validate(doc({ profiles: ['openwop-auth-saml'] }))).toBe(true);
74
+ expect(validate(doc({ profiles: ['openwop-auth-scim'], subjectLinking: false }))).toBe(true);
75
+ });
76
+ });
77
+
78
+ describe('capabilities.schema.json: the profile pair implies the contract (RFC 0164 §B)', () => {
79
+ const validate = compileCapabilities();
80
+
81
+ it('BOTH profiles with subjectLinking absent fails validation (the vulnerable pre-0164 shape)', () => {
82
+ const ok = validate(doc({ profiles: PROFILES }));
83
+ expect(ok, JSON.stringify(validate.errors)).toBe(false);
84
+ expect(JSON.stringify(validate.errors)).toContain('subjectLinking');
85
+ });
86
+
87
+ it('BOTH profiles with subjectLinking:false fails validation (the flag is derived, not a choice)', () => {
88
+ expect(validate(doc({ profiles: PROFILES, subjectLinking: false, subjectLinkKey: 'opaque-idp' }))).toBe(false);
89
+ });
90
+
91
+ it('BOTH profiles with subjectLinking:true but no subjectLinkKey fails validation', () => {
92
+ expect(validate(doc({ profiles: PROFILES, subjectLinking: true }))).toBe(false);
93
+ });
94
+
95
+ it('BOTH profiles with subjectLinking:true + a class member validates (the derived advertisement)', () => {
96
+ expect(validate(doc({ profiles: PROFILES, subjectLinking: true, subjectLinkKey: 'opaque-idp' }))).toBe(true);
97
+ });
98
+
99
+ it('profile order does not matter', () => {
100
+ expect(validate(doc({ profiles: ['openwop-auth-scim', 'openwop-auth-api-key-rotation', 'openwop-auth-saml'], subjectLinking: true, subjectLinkKey: 'configured-immutable' }))).toBe(true);
101
+ expect(validate(doc({ profiles: ['openwop-auth-scim', 'openwop-auth-api-key-rotation', 'openwop-auth-saml'] }))).toBe(false);
74
102
  });
75
103
  });
@@ -169,6 +169,27 @@ export function signPayload(
169
169
  * for a single-tenant host (which omits `owner`) or when no probe
170
170
  * fixture is available — callers then omit `tenantId`, which single-tenant hosts
171
171
  * accept. (RFC 0093 / webhooks.md §Register; suite defect fixed 2026-08-09.)
172
+ *
173
+ * **Prefers `owner.workspace`, and that is not a fallback ordering — it is which
174
+ * field the route actually takes.** `webhooks.md` §Register documents the body's
175
+ * `tenantId` as *"Workspace under which the subscription lives. Caller MUST be a
176
+ * member."*, and every example in that document passes a workspace
177
+ * (`workspace-123`, `workspace-prod`). RFC 0048 §A defines `owner.tenant` as
178
+ * something else entirely — the **top-level isolation boundary** — with
179
+ * `workspace` an optional sub-tenant beneath it. The two coincide only on hosts
180
+ * where tenant ≡ workspace, which is why reading `owner.tenant` worked
181
+ * everywhere it had been run.
182
+ *
183
+ * Reported 2026-09-02 by a tier-2 host with a real workspace layer: its
184
+ * schema-correct `owner` is `{tenant: "<instance>", workspace: "<workspaceId>",
185
+ * principal: …}`, so this helper derived the instance label — a value **nobody
186
+ * is a member of** — and registration 403'd by design. Registering with
187
+ * `owner.workspace` returns 201, verified by curl on that host. The host is
188
+ * conformant and the suite was wrong; the docblock above already stated the
189
+ * right intent ("a tenant the calling bearer provably OWNS") while reading the
190
+ * field that does not carry it.
191
+ *
192
+ * The `tenant` read is retained beneath it for hosts that emit no `workspace`.
172
193
  */
173
194
  export async function discoverOwnedTenant(
174
195
  driver: { post: (p: string, b: unknown) => Promise<{ status: number; json: unknown }>; get: (p: string) => Promise<{ status: number; json: unknown }> },
@@ -179,7 +200,12 @@ export async function discoverOwnedTenant(
179
200
  const runId = (create.json as { runId?: string } | null)?.runId;
180
201
  if (typeof runId !== 'string') return undefined;
181
202
  const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
182
- const owner = (snap.json as { owner?: { tenant?: unknown; tenantId?: unknown } } | null)?.owner;
203
+ const owner = (snap.json as { owner?: { tenant?: unknown; workspace?: unknown; tenantId?: unknown } } | null)
204
+ ?.owner;
205
+ // `tenantId` on POST /v1/webhooks is the WORKSPACE (webhooks.md §Register), not
206
+ // the RFC 0048 §A `tenant` isolation boundary. Where a host distinguishes them,
207
+ // only the workspace is a scope the bearer is a member of.
208
+ if (typeof owner?.workspace === 'string' && owner.workspace.length > 0) return owner.workspace;
183
209
  if (typeof owner?.tenant === 'string' && owner.tenant.length > 0) return owner.tenant;
184
210
  // Pre-S29 hosts that copied the suite's misnamed field: tolerated, never asserted.
185
211
  return typeof owner?.tenantId === 'string' && owner.tenantId.length > 0 ? owner.tenantId : undefined;
@@ -11,7 +11,7 @@
11
11
  * `capabilities.auth.subjectLinking` opt-in as RFC 0159.
12
12
  *
13
13
  * TWO LEGS:
14
- * (§A) ADVERTISEMENT — a discovery read, runs whenever subjectLinking:true.
14
+ * (§A) ADVERTISEMENT — a discovery read, runs whenever both profiles are advertised.
15
15
  * A subjectLinking:true host MUST advertise a subjectLinkKey that is a
16
16
  * member of the CLOSED enum {opaque-idp, configured-immutable}. The
17
17
  * enum names classes, not attributes. Mutable/PII keys are
@@ -58,6 +58,12 @@ interface DiscoveryDoc {
58
58
  extensions?: { auth?: DiscoveryAuth };
59
59
  }
60
60
 
61
+ /** RFC 0164: the gate is the profile PAIR, not the (now derived) flag. */
62
+ function advertisesBoth(auth: DiscoveryAuth | null): auth is DiscoveryAuth {
63
+ const p = auth?.profiles ?? [];
64
+ return p.includes(SAML_PROFILE) && p.includes(SCIM_PROFILE);
65
+ }
66
+
61
67
  async function readAuth(): Promise<DiscoveryAuth | null> {
62
68
  const res = await driver.get('/.well-known/openwop');
63
69
  const body = res.json as DiscoveryDoc | undefined;
@@ -67,11 +73,12 @@ async function readAuth(): Promise<DiscoveryAuth | null> {
67
73
  describe('auth-subject-link-key-class: advertisement shape (RFC 0163 §A)', () => {
68
74
  it('subjectLinking:true requires a subjectLinkKey drawn from the closed safe-class enum', async () => {
69
75
  const auth = await readAuth();
70
- if (auth === null || auth.subjectLinking !== true) {
71
- return softSkip('inapplicable', 'auth.subjectLinking not claimed');
76
+ if (!advertisesBoth(auth)) {
77
+ return softSkip('inapplicable', 'RFC 0164 gate: host does not advertise both openwop-auth-saml and openwop-auth-scim');
72
78
  }
73
79
 
74
- // §A.1 — a subjectLinking:true host MUST advertise a subjectLinkKey.
80
+ // §A.1 — a subjectLinking:true host (RFC 0164: any host advertising both
81
+ // profiles) MUST advertise a subjectLinkKey.
75
82
  expect(
76
83
  typeof auth.subjectLinkKey === 'string' && auth.subjectLinkKey.length > 0,
77
84
  driver.describe(
@@ -120,7 +127,7 @@ describe('auth-subject-link-key-class: same-IdP trust root (RFC 0163 §B — beh
120
127
 
121
128
  it('a same-IdP link forms (control) but a cross-IdP collision MUST NOT link', async () => {
122
129
  const auth = await readAuth();
123
- if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated on auth.subjectLinking');
130
+ if (!advertisesBoth(auth)) return softSkip('inapplicable', 'RFC 0164 gate: host does not advertise both openwop-auth-saml and openwop-auth-scim');
124
131
  if (!idpAUrl || !idpBUrl || !scimUrl) {
125
132
  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');
126
133
  }
@@ -6,14 +6,20 @@
6
6
  * documented in `auth-profiles.md` §"Subject linking (SAML ⟷ SCIM)" and is
7
7
  * discoverable via `capabilities.auth.subjectLinking`.
8
8
  *
9
- * Advertisement shape runs unconditionally when the flag is set. The
10
- * cross-lane behavioral legs (a SCIM deactivation fail-closing the linked SAML
11
- * identity; a mutable-key link never producing a cross-lane pass) are opt-in
12
- * via `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SCIM_URL` (operator-supplied
9
+ * GATE (RFC 0164): every leg runs whenever the host advertises BOTH
10
+ * `openwop-auth-saml` and `openwop-auth-scim` the profile PAIR is the gate,
11
+ * not `capabilities.auth.subjectLinking`, which since RFC 0164 is a DERIVED
12
+ * advertisement that MUST be true when both profiles are present. A host that
13
+ * advertises both and omits the flag is the vulnerable pre-0164 shape and
14
+ * FAILS the advertisement leg (never `inapplicable`). The cross-lane
15
+ * behavioral legs (a SCIM deactivation fail-closing the linked SAML identity;
16
+ * a mutable-key link never producing a cross-lane pass) are opt-in via
17
+ * `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SCIM_URL` (operator-supplied
13
18
  * endpoints), following the `auth-scim-profile.test.ts` opt-in precedent.
14
19
  * Soft-skips otherwise; soft-skips `blocked` until the host wires the seam.
15
20
  *
16
21
  * @see RFCS/0159-scim-saml-subject-linking.md
22
+ * @see RFCS/0164-mandatory-subject-linking.md (the profile-pair gate + the vulnerable-shape leg)
17
23
  * @see spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)"
18
24
  */
19
25
 
@@ -28,6 +34,15 @@ const SCIM_PROFILE = 'openwop-auth-scim';
28
34
  interface DiscoveryAuth {
29
35
  profiles?: string[];
30
36
  subjectLinking?: boolean;
37
+ subjectLinkKey?: string;
38
+ }
39
+
40
+ const SUBJECT_LINK_KEY_CLASSES: readonly string[] = ['opaque-idp', 'configured-immutable'];
41
+
42
+ /** RFC 0164: the gate is the profile PAIR. */
43
+ function advertisesBoth(auth: DiscoveryAuth | null): auth is DiscoveryAuth {
44
+ const p = auth?.profiles ?? [];
45
+ return p.includes(SAML_PROFILE) && p.includes(SCIM_PROFILE);
31
46
  }
32
47
 
33
48
  interface DiscoveryDoc {
@@ -41,7 +56,7 @@ async function readAuth(): Promise<DiscoveryAuth | null> {
41
56
  return capabilityFamily<DiscoveryAuth>(body, 'auth') ?? body?.extensions?.auth ?? null;
42
57
  }
43
58
 
44
- describe('auth-subject-link: advertisement shape (RFC 0159 §B)', () => {
59
+ describe('auth-subject-link: advertisement shape (RFC 0159 §B + RFC 0164 §A.3)', () => {
45
60
  it('subjectLinking:true is only claimed alongside both SAML and SCIM profiles', async () => {
46
61
  const auth = await readAuth();
47
62
  if (auth === null || auth.subjectLinking !== true) {
@@ -56,6 +71,31 @@ describe('auth-subject-link: advertisement shape (RFC 0159 §B)', () => {
56
71
  ),
57
72
  ).toBe(true);
58
73
  });
74
+
75
+ it('advertising BOTH profiles REQUIRES subjectLinking:true + a subjectLinkKey (RFC 0164 — the vulnerable shape fails, never inapplicable)', async () => {
76
+ const auth = await readAuth();
77
+ if (!advertisesBoth(auth)) {
78
+ return softSkip('inapplicable', 'host does not advertise both openwop-auth-saml and openwop-auth-scim');
79
+ }
80
+ // The pre-RFC-0164 "opted out" shape: both lanes advertised, no flag. That
81
+ // host ships the combined-deployment leaver bypass under a conforming
82
+ // banner. RFC 0164 §A.3 makes the flag a DERIVED statement of fact, so its
83
+ // absence alongside both profiles is a conformance FAILURE.
84
+ expect(
85
+ auth.subjectLinking,
86
+ driver.describe(
87
+ 'auth-profiles.md §Subject linking (RFC 0164)',
88
+ 'RFC 0164 §A.3: a host advertising BOTH openwop-auth-saml and openwop-auth-scim MUST advertise capabilities.auth.subjectLinking:true — the leaver contract follows the profile pair; omitting the flag is the vulnerable pre-0164 shape',
89
+ ),
90
+ ).toBe(true);
91
+ expect(
92
+ typeof auth.subjectLinkKey === 'string' && SUBJECT_LINK_KEY_CLASSES.includes(auth.subjectLinkKey),
93
+ driver.describe(
94
+ 'auth-profiles.md §Subject linking (RFC 0164)',
95
+ `RFC 0164 §A.3 / RFC 0163 §A.1: a host advertising both profiles MUST advertise subjectLinkKey ∈ {${SUBJECT_LINK_KEY_CLASSES.join(', ')}}`,
96
+ ),
97
+ ).toBe(true);
98
+ });
59
99
  });
60
100
 
61
101
  describe('auth-subject-link: cross-lane deactivation (RFC 0159 §A.3 — opt-in)', () => {
@@ -64,7 +104,7 @@ describe('auth-subject-link: cross-lane deactivation (RFC 0159 §A.3 — opt-in)
64
104
 
65
105
  it('a SCIM deactivation fail-closes the linked SAML identity', async () => {
66
106
  const auth = await readAuth();
67
- if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated');
107
+ if (!advertisesBoth(auth)) return softSkip('inapplicable', 'RFC 0164 gate: host does not advertise both openwop-auth-saml and openwop-auth-scim');
68
108
  if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
69
109
 
70
110
  // 1. Provision a SCIM user carrying an opaque, IdP-stable externalId.
@@ -110,7 +150,7 @@ describe('auth-subject-link: link-key hygiene (RFC 0159 §A.2 — opt-in)', () =
110
150
 
111
151
  it('a mutable/PII link key (email) never produces a cross-lane pass', async () => {
112
152
  const auth = await readAuth();
113
- if (auth === null || auth.subjectLinking !== true) return softSkip('inapplicable', 'capability-gated');
153
+ if (!advertisesBoth(auth)) return softSkip('inapplicable', 'RFC 0164 gate: host does not advertise both openwop-auth-saml and openwop-auth-scim');
114
154
  if (!idpUrl || !scimUrl) return softSkip('inapplicable', 'opt-in: SAML IdP and/or SCIM endpoint not provided');
115
155
 
116
156
  // Attempt to form the cross-lane link on email (a mutable/PII attribute).