OpenWOP openwop.dev
FieldValue
RFC0163
TitleSubject-linking hardening — a declarable, witnessable link-key class (capabilities.auth.subjectLinkKey, a closed enum of allowed classes only) plus a same-IdP trust-root MUST before a SAML⟷SCIM link may form. The additive follow-on to RFC 0159 that converts its §A.2/§A.4 negative-existence claims-check into a positive advertisement and closes its cross-IdP collision gap.
StatusAccepted
Author(s)David Tufts (@davidscotttufts)
Created2026-09-01
Updated2026-09-02 (Active → Acceptedtier-1 evidence per GOVERNANCE.md §"Acceptance evidence tiers": the reference host openwop-app implements the host leg in openwop-app #3614 (2f6ca969d, ADR 0620) — emits capabilities.auth.subjectLinkKey: "opaque-idp" from the same gate as subjectLinking, compares the SAML assertion's signed <saml:Issuer> to the entityID bound to the SCIM connection before consulting the link on both the validate seam and the production ACS, accepts idpUrl on the SCIM provision seam, and reconstructs the signed Issuer in its node:crypto validator. Witness: In-process witnesses on the reference host: test/auth-subject-link.test.ts (two synthetic IdPs with distinct signed issuers — advertisement subjectLinkKey === 'opaque-idp'; same-root provision + assertion authenticates as the positive control; cross-root collision refused 401 subject_link_trust_root_mismatch; the RFC 0159 legs — SCIM-deactivate ⇒ SAML denied, mutable-key hygiene — stay green), test/auth-saml-sso-trust-root.test.ts (production ACS: same-root mints a session, cross-root refuses with no cookie) and test/auth-saml.test.ts (signed <saml:Issuer> in the canonical + principal.issuer surfaced). npm run ci: backend 470 files / 3013 tests, 0 failed. The SSRF widening was sabotage-verified: with the trust-root refusal reverted the cross-root leg returns 200 authenticated:true and the assertion fails, so the refusal — not the 403 — is load-bearing.. Single-witness under the bootstrap waiver, as RFC 0159 was; no tier-2/3 host advertises subjectLinking.) · 2026-09-01 (Draft → Active, same day as filing — comment window waived — the 7-day window under the GOVERNANCE.md single-maintainer lazy-consensus bootstrap rule, exactly as RFC 0159 and RFC 0050 were; recorded in MAINTAINERS.md §Bootstrap-phase RFC waivers. Wire shape locked at Active after three pre-lock amendments ruled by the architect pass of 2026-09-01: (1) the enum names classes, not vendor attributes — {opaque-idp, configured-immutable} replaces {opaque-idp, oid, immutable-id}, since the two Entra attribute names carried no witness the suite could distinguish and the RFC's own G1 already anticipated a fourth; (2) the subjectLinking:true ⇒ subjectLinkKey co-requirement is a schema if/then as well as a prose MUST (closes R5); (3) §B.1 pins the trust-root representation — SAML <saml:Issuer> entityID ↔ the IdP entityID recorded for the SCIM connection (closes UQ3/G2). The two-trust-root fixture landed in #1163 (closes UQ4/G-fixture). Fixture self-tests moved out of the scenario file into conformance/src/lib/saml-idp.test.ts so a host that never advertises linking does not record an executed-pass about a fixture (certification gap G8). Three invariants registered.)
Affectsspec/v1/auth-profiles.md (§"Subject linking (SAML ⟷ SCIM)" gains the key-class declaration + the same-IdP MUST with a pinned trust-root representation) · schemas/capabilities.schema.json (new auth.subjectLinkKey closed enum + an if/then making it required when subjectLinking is true) · spec/v1/host-sample-test-seams.md (SCIM provision seam gains idpUrl + the RFC 0159 link op; SAML validate seam response documented) · SECURITY/invariants.yaml (three subject-link-* rows) · RFC 0159 (gains an Amended by forward pointer) · SECURITY/threat-model-auth-profiles.md §4.5 (the cross-IdP mitigation gains a normative MUST; the §A.2 negative-existence residual is strengthened by the declarable key class) · new conformance scenario auth-subject-link-key-class.test.ts (advertisement leg executable; the UQ4 two-trust-root behavioral leg declared + phased)
Compatibilityadditive per COMPATIBILITY.md (a new optional discovery field + two MUSTs that bind only hosts already advertising subjectLinking:true — no host conforming to RFC 0159 or RFC 0050 today de-conforms)
Supersedes
Superseded by
Amended byRFC 0164 — the obligations of this RFC and RFC 0159 follow the profile pair; the subjectLinking gate becomes a derived advertisement (closes UQ1).

Summary

RFC 0159 made a SCIM-deactivated leaver fail-close on the linked SAML lane, keyed on an opaque IdP-stable id — but two of its safety obligations survive only as negative-existence claims-checks (§A.2 "MUST NOT key on a mutable/PII attribute", §A.4 "MUST NOT silently fall back") that a suite cannot fully witness (it can prove the correct deny on the pairs it probes, never that a host never joins on email for some pair it did not), and it left the SAML/SCIM lanes' trust-root relationship as an open question (UQ4: nothing forbids linking two principals that merely collide on an identifier across two different IdPs). This RFC hardens both, additively, gated on the same opt-in subjectLinking:true flag: (1) a new capabilities.auth.subjectLinkKey — a closed enum of allowed key classes only ("opaque-idp", "configured-immutable"), required by schema conditional whenever subjectLinking is true — that a subjectLinking:true host MUST advertise and MUST honour, with "email"/"userName"/any mutable key made inexpressible in the enum (the enum's closedness is the witness: a conforming host can only name a safe class); and (2) a normative same-IdP trust-root MUST — the SAML assertion issuer MUST match the SCIM provisioning IdP before a link may form. Both bind only hosts that already opt into subjectLinking; nothing de-conforms.

Motivation

RFC 0159 is the standard combined SAML-SSO + SCIM-provisioning leaver contract, and it is Accepted with a reference-host implementation. Its own Falsifiability table (RFC 0159 §Conformance) and gap/risk registers name two residuals it deliberately deferred to follow-ups (UQ2, UQ3, UQ4; R4, R5):

1. The mutable-key prohibition is only half-witnessable (UQ2 / R4). RFC 0159 §A.2 says the link key MUST NOT be email/userName/any mutable-or-PII attribute, and §A.4 says a host MUST NOT silently fall back to one. The conformance suite (auth-subject-link.test.ts, RFC 0159) can witness the positive consequence — that a mutable-key "link" the suite constructs yields no cross-lane pass — but it cannot prove a host never joins on a mutable key for some pair it did not probe. This is a negative-existence property: "no unsafe join happened anywhere." RFC 0159's own note (§Conformance, and SECURITY/threat-model-auth-profiles.md §6 residual bullet) records the gap and proposes the fix used here: have discovery declare the key class, so the suite asserts the claimed class against a closed set of safe classes and gates on it — converting a claims-check into a witnessable advertisement.

2. The acceptable key-class set is unpinned (UQ3). RFC 0159 §A.1 admits "the SCIM externalId matched to the SAML persistent-format NameID, or a host-configured stable linking attribute asserted by the same IdP" — an open set under an opaque+stable+non-PII constraint. Leaving it open eases adoption but means the conformance suite cannot name what a conforming host is allowed to say; enumerating the acceptable classes closes that.

3. Nothing forbids a cross-IdP identifier collision (UQ4 / R5). RFC 0159 §A.1 presumes both lanes are fed by the same IdP so the opaque id is comparable, but states no MUST to that effect. If SAML is fed by IdP-A and SCIM by IdP-B and the two happen to mint the same externalId/NameID string for two different humans, the host would join two different principals — a cross-IdP identifier collision. SECURITY/threat-model-auth-profiles.md §4.5 already lists this ("Spoofing (cross-tenant / -IdP)") but its mitigation cell defers the same-IdP scoping to "RFC 0159 UQ4". This RFC supplies the MUST.

The spec is the right place for the same reason RFC 0159 was: left to per-host code, each vendor re-litigates which key classes count and whether the two lanes must share a trust root, and some will reach for a wider set or skip the trust-root check — the exact footgun-per-host outcome RFC 0050/0159 exist to prevent. UQ2/UQ3/UQ4 are recorded in RFC 0159's registers as Security/Conformance/Spec-owned decisions "needed before a strengthening"; this is that strengthening.

Explicitly out of scope: UQ1 (making cross-lane deactivation mandatory for any host advertising both profiles, retiring the opt-in flag). That is a breaking v2 change — it de-conforms every current combined host in one step — and is a separate later safety-fix. This RFC is deliberately additive-only. See §Unresolved questions 1.

Proposal

All obligations below apply only to a host that advertises openwop-auth-saml and openwop-auth-scim and sets capabilities.auth.subjectLinking: true (the RFC 0159 opt-in). A host that does not opt into subject linking is entirely unaffected.

  • §A.1 — A subjectLinking:true host MUST declare its link-key class. A host that advertises capabilities.auth.subjectLinking: true MUST also advertise capabilities.auth.subjectLinkKey, whose value MUST be one of the closed enum:

- "opaque-idp" — the SCIM resource externalId matched to the SAML persistent-format NameID (the RFC 0159 §A.1 default pairing). - "configured-immutable" — a host-configured attribute, asserted by the same IdP on both lanes, that satisfies the conjunctive predicate opaque ∧ stable for the lifetime of the account ∧ never reassigned ∧ non-PII ∧ not influenceable by the user or a tenant operator (the RFC 0159 §A.1 "host-configured stable linking attribute" case — a directory object id or an immutable-id claim both qualify).

The enum names classes, not attributes: the specific attribute a configured-immutable host joins on stays host configuration; the promise about its class goes on the wire (UQ3, resolved). Both classes name an opaque, IdP-asserted, stable, non-PII identifier, satisfying the RFC 0159 §A.1/§A.2 key constraint. The co-requirement is expressed twice on purpose: as this MUST, and as a capabilities.schema.json if/then (subjectLinking: truerequired: ["subjectLinkKey"]), so a host setting subjectLinking:true and omitting the key, or setting it to any value outside the enum, fails schema validation and the advertisement scenario. A host self-certifying against the bare schema therefore cannot mistake the omission for a pass.

  • §A.2 — A host MUST NOT advertise a subjectLinkKey it does not honour. The declared class MUST be the class the host actually joins on. This is the RFC 0011 / RFC 0048 §B authorization-oracle discipline (advertise only what you honour) applied to the key class: a host advertising "opaque-idp" MUST form links on externalId↔persistent-NameID and MUST NOT form them on any other attribute; a host advertising "configured-immutable" MUST form them only on the one configured attribute and MUST document that attribute at its discovery document or operator docs.
  • §A.3 — The enum is closed; unsafe key classes are inexpressible. The set is allowed classes only. "email", "userName", "displayName", and every mutable-or-PII attribute are absent from the enum by construction — a conforming discovery document cannot name one. This is the witness: RFC 0159 §A.2/§A.4 said a host MUST NOT use a mutable key (a property a suite cannot exhaustively falsify); §A.3 here says a host cannot say it uses one and stay schema-valid, and MUST honour what it says (§A.2), so the suite asserts the positive fact "the declared class is a member of the safe set" instead of the negative fact "no unsafe join ever happened." A host that keys links on email is now non-conforming in a witnessable way: it either declares a class it does not honour (violates §A.2, and its behavioral leg fails) or declares nothing / an out-of-enum value (violates §A.1, and its discovery shape fails). The negative-existence residual is not fully eliminated — a host could still lie (declare "opaque-idp" while secretly joining on email) — but the lie is now a §A.2 violation the behavioral leg can catch on any probed pair, not an unstated fallback, and the residual shrinks to "a host that both lies in discovery and is never probed on the lying pair." (See §Conformance Falsifiability note.)

§B — Same-IdP trust-root MUST (closes UQ4 / R5)

  • §B.1 — The SAML and SCIM lanes MUST share an IdP trust root before a link may form. The host MUST NOT form a subject link between a SAML-asserted principal and a SCIM-provisioned principal unless both lanes are fed by the same IdP trust root: the issuer of the SAML assertion (the assertion's Issuer / the trusted IdP entityID for the SAML connection) MUST correspond to the same IdP that provisioned the SCIM resource (the SCIM connection's configured IdP). An opaque identifier that collides across two different IdPs MUST NOT join two principals. Equivalently: the link key's comparability is scoped to one trust root; a match on the key across two trust roots is not a link. Representation (UQ3, resolved): correspondence is established by comparing the SAML assertion's <saml:Issuer> (the IdP entityID, which MUST lie inside the signed element so it cannot be swapped post-signing) against the IdP entityID the host recorded for the SCIM connection when that connection was configured. The SCIM lane carries no issuer on the wire — it authenticates by bearer token — so the host MUST bind each SCIM client credential to exactly one IdP entityID at configuration time and MUST NOT infer it from the request.
  • §B.2 — Absent a shared trust root, fail closed (compose with RFC 0159 §A.4). If the host cannot establish that the two lanes share an IdP trust root for a candidate pair, it MUST NOT form the link, exactly as RFC 0159 §A.4 requires when no opaque key is available: treat the two identities as independent subjects, and MUST NOT claim the combined leaver guarantee for that pair on the strength of a cross-IdP identifier match.

Non-normative: §B tightens RFC 0159 §A.1's presumption ("presumes both lanes are fed by the same IdP") into a MUST. It composes with the existing same-tenant MUST (RFC 0159 §A.1): a link now requires both same-tenant and same-IdP-trust-root. It does not change the link mechanism (still link-not-merge; no userIdFor rewrite; the RFC 0048 §C owner echo on run.started and RFC 0006 :fork replay stay deterministic).

§C — Discovery shape (schemas/capabilities.schema.json)

Add subjectLinkKey under auth.properties, a closed enum, plus an if/then on the auth object that requires it whenever subjectLinking is true (auth remains additionalProperties: true; the conditional binds only a flag no host emitted when it landed, so it is a tightening of an unemitted shape under COMPATIBILITY.md §2.2, not closure of a server-emitted shape under §2.1):

       "subjectLinking": {
         "type": "boolean",
         "description": "RFC 0159 … (unchanged)"
       },
+      "subjectLinkKey": {
+        "type": "string",
+        "enum": ["opaque-idp", "configured-immutable"],
+        "description": "RFC 0163 (auth-profiles.md §Subject 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 ↔ persistent-format SAML NameID) or `configured-immutable` (a host-configured attribute asserted by the same IdP on both lanes that is opaque, stable, never reassigned, non-PII and not user- or operator-influenceable). Mutable/PII keys are INEXPRESSIBLE by construction. REQUIRED when subjectLinking is true — as the auth-profiles.md MUST and as this block's if/then."
+      },
     },
+    "if": { "properties": { "subjectLinking": { "const": true } }, "required": ["subjectLinking"] },
+    "then": { "required": ["subjectLinkKey"] },
  • Absent by default. Existing clients ignore it; existing hosts do not emit it (no host advertised subjectLinking:true in production discovery when this landed — the INTEROP-MATRIX lists no combined host, MyndHyve's live discovery carries no auth block, openwop-app advertises the profiles only behind test seams). The obligation rides both §A.1 and the schema conditional; the conditional was deliberately added pre-Active because the prose-only form left a subjectLinking:true document with no key schema-valid (the R5 residual), which is precisely the self-certification hole RFC 0148 exists to close.
  • auth's additionalProperties: true is preserved (same rail subjectLinking rode).

Examples

Positive (conforming). Host discovery:

{ "auth": { "profiles": ["openwop-auth-saml", "openwop-auth-scim"], "subjectLinking": true, "subjectLinkKey": "opaque-idp" } }

A SCIM POST /Users provisions { "externalId": "idp-op-8f3a", "userName": "r.smith" } from IdP-A; a SAML assertion issued by IdP-A with persistent NameID = idp-op-8f3a links on the opaque-idp class (§A.1) because the two lanes share IdP-A's trust root (§B.1). The declared opaque-idp is the class the host joins on (§A.2).

Negative 1 — mutable key inexpressible (§A.3). { "auth": { "profiles": ["openwop-auth-saml", "openwop-auth-scim"], "subjectLinking": true, "subjectLinkKey": "email" } } fails schema validation"email" is not in the enum. A host that joins on email cannot produce a conforming discovery document that says so.

Negative 2 — missing declaration (§A.1). { "auth": { "profiles": ["openwop-auth-saml", "openwop-auth-scim"], "subjectLinking": true } } fails schema validation (the if/then requires subjectLinkKey) and is non-conforming under the §A.1 "MUST also advertise" obligation; the advertisement scenario de-conforms it independently of the schema.

Negative 3 — cross-IdP collision (§B.1). SAML asserted by IdP-A with NameID = idp-op-8f3a; SCIM provisioned by IdP-B which independently minted externalId = idp-op-8f3a for a different human. The string matches, but the two lanes do not share an IdP trust root, so the host MUST NOT form the link (§B.1) and MUST NOT propagate a cross-lane deny/pass between the two unrelated principals (§B.2).

Compatibility

Classification: additive. Per COMPATIBILITY.md §2.2: no required→optional or type change; subjectLinkKey is a new property, required only under a conditional on a flag no conforming host emits (the auth object stays additionalProperties: true); no event-shape change; the SCIM/SAML endpoint contracts are unchanged; no existing MUST is relaxed (both new MUSTs add obligations); error-code meanings are unchanged (§B fail-closed reuses RFC 0159 §A.4 / RFC 0049 §C semantics and the existing forbidden/unauthenticated envelope). A host that conforms to RFC 0159 today does not emit subjectLinkKey and — because both §A and §B bind only subjectLinking:true hosts — a host that never opted in incurs no new obligation.

Backward-compatibility clauses:

  • auth.subjectLinkKey is absent by default; existing clients ignore it; existing hosts don't emit it. The if/then fires only on subjectLinking: true, which no production host emits.
  • The §A and §B MUSTs are gated on subjectLinking:true. A host that never sets subjectLinking (SAML-only, SCIM-only, or neither) incurs no new obligation.
  • A pre-existing RFC 0159 subjectLinking:true host that has not yet added subjectLinkKey fails schema validation of its discovery document against the schema version shipping this RFC and the §A.1 advertisement scenario in the same suite version. No such host exists (see §C); if one appears, the fix is a one-line discovery addition. This is the intended tightening.

Why not safety-fix? As with RFC 0159, the motivation is a security gap (an unwitnessable prohibition + a cross-IdP collision), but the mechanism is opt-in and additive — nothing de-conforms without opting in, no migration/embargo is required. The stronger mandatory posture (UQ1) remains the deferred breaking follow-up.

Conformance

Existing coverage. conformance/src/scenarios/auth-subject-link.test.ts (RFC 0159) covers the advertisement gate (subjectLinking:true ⟹ both profiles) plus the opt-in behavioral legs (cross-lane leaver deny; mutable-key link yields no pass). It does not assert the key-class declaration or the same-IdP trust root.

New scenario — auth-subject-link-key-class.test.ts (gated on capabilities.auth.subjectLinking === true; soft-skips otherwise, per coverage.md §"Capability-gated scenarios"):

  • Advertisement leg (§A — executable, discovery read). When subjectLinking:true, assert subjectLinkKey is present and is a member of the closed enum {opaque-idp, configured-immutable}; inapplicable otherwise. This is the leg that converts RFC 0159's §A.2/§A.4 negative-existence residual into a positive advertisement assertion. The schema conditional's three cases (missing key rejected, out-of-enum rejected, member accepted) are pinned server-free in conformance/src/lib/capabilities-auth-subject-link.test.ts — a check of the schema artifact, not of any host, so it produces no scenario ledger row.
  • UQ4 behavioral leg (§B — executable over the two-trust-root seam, #1163). SAML from IdP-A + a SCIM externalId provisioned from IdP-B colliding on one id ⟹ the host MUST refuse to link (no cross-lane deny/pass propagates between the two unrelated principals). A same-IdP positive control in the same test keeps the negative non-vacuous. 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; inapplicable until a host wires the seam. The fixture itself (two createSyntheticSamlIdp({ entityID }) instances with distinct signing keys and signed <saml:Issuer>) is proven server-free in conformance/src/lib/saml-idp.test.tsnot inside the scenario file, so a host that never advertises linking does not record an executed-pass about a fixture (RFC 0148 / certification gap G8).

Fixtures. The advertisement leg needs none (discovery read). The §B behavioral leg uses the two-trust-root synthetic IdP (conformance/src/lib/saml-idp.ts, extended in #1163). It is a src/lib/ code fixture like oidc-issuer.ts, documented in-code and self-tested in saml-idp.test.ts, not a seeded workflow-JSON fixture, so no conformance/fixtures.md row is added (G-fixture, closed).

Capability gate: host.auth.subjectLinking (the RFC 0159 discovery flag; this RFC adds the subjectLinkKey sub-shape under the same gate). INTEROP-MATRIX: host-per-row with advertised auth metadata inline per host cell (no structural column added); subjectLinkKey is noted in a host's cell when it first advertises it. No current reference host advertises subjectLinking, so none advertises subjectLinkKey.

Falsifiability — one row per normative requirement

RequirementObservable — what an outside party seesWho can cause the conditionVerdict
§A.1 (subjectLinking:truesubjectLinkKey ∈ enum)discovery with subjectLinking:true and an absent/out-of-enum subjectLinkKey fails schema validation and de-conforms; a member value conformsthe suite, unaided (discovery read + schema)witnessable, unaided
§A.2 (advertise only the class you honour)a host advertising "opaque-idp" forms links on externalId↔persistent-NameID and on no other attribute — probed via the RFC 0159 cross-lane seamthe suite, via the SCIM+SAML seams (capability-gated)witnessable on probed pairs (residual: an unprobed lying pair — see note)
§A.3 (enum closed; unsafe classes inexpressible)no conforming discovery document names "email"/"userName"/any mutable keythe suite, unaided (schema validation of discovery)witnessable, unaided
§B.1 (same-IdP trust root before a link)a SAML(IdP-A)+SCIM(IdP-B) identifier collision yields no cross-lane link (no deny/pass propagates between the two principals), while the same-IdP control linksthe suite, via the two-trust-root seam (#1163)witnessable — seam-gated
§B.2 (fail closed absent a shared trust root)for a cross-IdP candidate pair, the host treats the two as independent subjects — never a silent cross-IdP jointhe suite, by presenting a cross-IdP pair over the seamwitnessable — seam-gated

Note (per the template's two failure modes). §A converts the RFC 0159 §A.2/§A.4 negative-existence residual into a positive advertisement the suite witnesses unaided (§A.1/§A.3), plus a behavioral honesty check on probed pairs (§A.2). The residual shrinks but is not zero: a host could declare a safe class in discovery yet secretly join on a mutable key for a pair the suite never probes. That surviving sliver — "lies in discovery and is never probed on the lying pair" — is strictly smaller than RFC 0159's residual ("uses a mutable key for any unprobed pair"), because the lie is now a §A.2 violation catchable on any probed pair rather than an unstated, unadvertised fallback. §B.1/§B.2 are witnessable and seam-gated: the observable (no cross-IdP join, beside a same-IdP control that does join) is clear and causable over the two-trust-root seam shipped in #1163.

Alternatives considered

1. Do nothing (leave RFC 0159 UQ2/UQ3/UQ4 open). The mutable-key prohibition stays a negative-existence claims-check the suite can't fully witness, the acceptable key-class set stays unpinned, and a cross-IdP identifier collision can still join two principals. Rejected — RFC 0159's own registers name these as the strengthening path, and a spec that can't witness its own safety MUST invites "conforming" hosts that aren't. 2. Make subjectLinkKey an OPEN string (advertise whatever you key on, including "email"). Rejected — an open string lets a host advertise "email" honestly, which does not make an unsafe key safe; the point is that a mutable key must be inexpressible, so a conforming discovery document cannot name one. The closed enum is the witness; an open string discards it. 3. Add "email"/mutable classes to the enum as explicitly-rejected values (a deny-list). Rejected — enumerating forbidden values in a discovery field invites a host to advertise one, and a schema enum is an allow-list by nature. The safe design is allowed-classes-only; the forbidden ones live in the threat model, not the wire vocabulary. 4. Make the same-IdP scoping a SHOULD, not a MUST (§B). Rejected — a cross-IdP identifier collision joins two different humans' principals, which is an authorization-boundary violation (R5), not a quality-of-implementation nicety. RFC 0159 §A.1 already scopes the link to a single tenant as a MUST for the analogous cross-tenant collision; same-IdP is the same class of boundary and takes the same keyword. 5. Fold this into RFC 0159 by amending it in place. Rejected — RFC 0159 is Accepted with a merged reference-host implementation; the reasoning trail (and the register rows UQ2–UQ4/R4–R5 that motivate this) is better served by an additive follow-on that RFC 0159 forward-points to, exactly as RFC 0159 amended RFC 0050 rather than rewriting it. 6. Name vendor attributes as enum members (oid, immutable-id) — the shape this RFC carried as Draft. Rejected pre-Active — both are Entra ID attribute names, not classes; the suite's only check is membership, so two members with identical observable behaviour are one class with two names; and an attribute allow-list is perpetually incomplete (the Draft G1 already anticipated a fourth member in its first week). RFC 0154's schemes enum is the model because each of its members names a distinct verification path; here there is one path with one configured attribute, so one class (configured-immutable) with the conjunctive predicate stated in §A.1. 7. Leave the co-requirement in prose only (the Draft §C). Rejected pre-Active — it left a subjectLinking:true discovery document with no key schema-valid (R5), and a schema conditional on a flag zero hosts emit is a tightening of an unemitted shape, not a COMPATIBILITY.md §2.1 closure of a server-emitted one.

Unresolved questions

1. Mandatory follow-up (UQ1, inherited) — RESOLVED by RFC 0164 (2026-09-02). Original text: Should a later (breaking, v2) RFC make cross-lane deactivation mandatory for any host advertising both openwop-auth-saml and openwop-auth-scim (retiring the opt-in subjectLinking flag)? This RFC, like RFC 0159, deliberately stays additive/opt-in. Out of scope here; recorded so the sequencing is explicit. 2. Enum extensibility — RESOLVED at Active (2026-09-01). The enum names classes, not attributes: {opaque-idp, configured-immutable}. A directory-specific GUID, oid, ImmutableID and ms-DS-ConsistencyGuid are all configured-immutable if they meet the §A.1 conjunctive predicate. A new class (one that names a different verification path, as RFC 0154's schemes members do) would still be an additive enum growth; a new attribute is not a new class and does not change the wire. 3. §B trust-root identity representation — RESOLVED at Active (2026-09-01). Pinned in §B.1: SAML <saml:Issuer> entityID (inside the signed element) ↔ the IdP entityID the host recorded for the SCIM connection at configuration time; the SCIM credential is bound to exactly one entityID and the binding is never inferred from a request. "Correspond" without a representation was unwitnessable; the fixture's issuerOf() is the comparison the suite makes. 4. Two-trust-root fixture ownership — RESOLVED (#1163, suite 1.147.0). Shipped as a src/lib/ code fixture with its own self-test. 5. Unprobed-pair residual (inherited from RFC 0159 UQ2, narrowed). §A.2 remains a probed-pair honesty check: the RFC 0159 seam reports whether a link formed, not which attribute it formed on (G3), so a host that declares opaque-idp and joins on email for a pair the suite never probes is not caught by this RFC. Closing it structurally means carrying the issuer and key class on the emitted subject itself rather than asserting them once in discovery — a v2 identity-record change, recorded in the v2 charter, not a v1.x follow-on.

Implementation notes (non-normative)

  • Reference host (openwop-app). RFC 0159's reference implementation (openwop-app #3581, ADR 0613) already keys the link on the opaque externalId and consults a per-tenant link-scoped deny. This RFC adds two host tasks, both small: (a) emit capabilities.auth.subjectLinkKey: "opaque-idp" in discovery (the reference host's class is exactly opaque-idp); (b) add a same-IdP trust-root check to the link-formation path — compare the SAML assertion <saml:Issuer> to the entityID bound to the SCIM connection before writing the link, and accept idpUrl on the SCIM provision seam so the suite can state which trust root fed the SCIM lane. Neither touches userIdFor (no durable key rewrite); the RFC 0048 §C owner echo and RFC 0006 :fork replay stay deterministic. Host work lands after this RFC reaches Active (wire shape pinned first).
  • Sequencing. Spec prose (auth-profiles.md §Subject linking additions) + the capabilities.schema.json enum and conditional + the RFC 0159 Amended by pointer + the SECURITY/threat-model-auth-profiles.md §4.5 MUST + the three invariants landed at Active; the advertisement leg, the §B two-trust-root behavioral leg and its fixture landed with it (#1157, #1163, and the Active PR); the reference-host subjectLinkKey emission + same-IdP check + in-process witness gate Active → Accepted.
  • Cross-cut. Auth-profile-family surface (RFC 0010/0050/0159 lineage); additive, so it can merge independently of the workflow-protocol plan — no CC-N coordination entry required.

Acceptance criteria

  • [x] Spec text merged — spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)" gains the §A key-class declaration + the §B same-IdP MUST. (#1157; representation pinned and enum collapsed to classes in the Active PR)
  • [x] schemas/capabilities.schema.json gains the auth.subjectLinkKey closed enum (additionalProperties: true preserved) plus the subjectLinking:true ⇒ subjectLinkKey conditional; redocly lint api/openapi.yaml stays clean. (#1157; conditional in the Active PR)
  • [x] RFC 0159 gains an Amended by row pointing to RFC 0163 with a one-line summary. (#1157)
  • [x] SECURITY/threat-model-auth-profiles.md §4.5 — the cross-IdP mitigation cell gains the §B.1 normative MUST; the §6 residual bullet notes the §A declarable key class strengthens the §A.2 negative-existence residual. (#1157; invariant ids subject-link-leaver-deny / subject-link-key-class-declared / subject-link-same-trust-root registered in the Active PR)
  • [x] At least one conformance scenario (auth-subject-link-key-class.test.ts) covering the §A advertisement leg (executable) and the §B two-trust-root behavioral leg (executable over the seam), capability-gated on auth.subjectLinking; fixture self-test in src/lib/saml-idp.test.ts. (#1157, #1163, Active PR)
  • [x] CHANGELOG entry under the suite version. (#1157 → 1.145.0, #1163 → 1.147.0, Active PR → 1.148.0)
  • [x] Reference host (openwop-app) advertises subjectLinkKey and enforces the §B same-IdP check. (openwop-app #3614, 2f6ca969d, ADR 0620 — see § Implementation record)

Implementation record

ItemWhereStatus
Enum collapse + schema conditional + §B.1 representation + invariants + fixture self-test relocation + Draft → Active../openwop #1165 (da881bec), suite 1.148.0merged
Two-trust-root fixture + live §B leg../openwop #1163 (f36ca6d0), suite 1.147.0merged
Reference-host leg — subjectLinkKey: "opaque-idp" emitted from the subjectLinking gate; signed <saml:Issuer> reconstructed in the validator and surfaced on the principal; SCIM connection bound to an IdP entityID (idpUrl accepted on the provision seam); same-trust-root check before the link consult on the validate seam AND the production ACS; SSRF allowlist widened to the second synthetic IdP so the negative is non-vacuousopenwop-app #3614 (2f6ca969d), ADR 0620merged

Witness. In-process witnesses on the reference host: test/auth-subject-link.test.ts (two synthetic IdPs with distinct signed issuers — advertisement subjectLinkKey === 'opaque-idp'; same-root provision + assertion authenticates as the positive control; cross-root collision refused 401 subject_link_trust_root_mismatch; the RFC 0159 legs — SCIM-deactivate ⇒ SAML denied, mutable-key hygiene — stay green), test/auth-saml-sso-trust-root.test.ts (production ACS: same-root mints a session, cross-root refuses with no cookie) and test/auth-saml.test.ts (signed <saml:Issuer> in the canonical + principal.issuer surfaced). npm run ci: backend 470 files / 3013 tests, 0 failed. The SSRF widening was sabotage-verified: with the trust-root refusal reverted the cross-root leg returns 200 authenticated:true and the assertion fails, so the refusal — not the 403 — is load-bearing.

References

  • RFC 0159 — SCIM ⟷ SAML subject linking (the amended base; UQ2/UQ3/UQ4 + registers R4/R5 are the motivation).
  • RFC 0050 — SAML / SCIM (and optional LDAP) enterprise identity profiles.
  • RFC 0048 — Identity triple / principals; §D owner-echo determinism (why link-not-merge is preserved).
  • RFC 0049 — Roles and authorization decisions; §C fail-closed (the deny §B composes with).
  • RFC 0011 / RFC 0010 — auth-scoped discovery + auth-profile-conformance (advertise-only-what-you-honour).
  • spec/v1/auth-profiles.md §"Subject linking (SAML ⟷ SCIM)".
  • SECURITY/threat-model-auth-profiles.md §4.5 (join-and-inherit / leaver / cross-tenant-or-IdP) + §6 (unprobed-pair residual).
  • Prior art: SCIM 2.0 (RFC 7644) externalId; SAML 2.0 persistent NameID format + assertion Issuer (OASIS); OIDC oid/immutable-id directory claims.