Skip to content

SSO SAML JIT: identity and email fall back to the NameID — an Entra ID IdP-initiated sign-in creates a second account whose "email" is an opaque persistent id, and the verified-domain binding is not enforced on JIT #32146

Closed

Description

@os-bob

Edition / version

ObjectOS Enterprise, cloud main built image (framework pin e24136c2, @better-auth/sso 1.7.12), kit from cloud main e3365dfa, licensed. Live Microsoft Entra ID test tenant, non-gallery SAML enterprise app with the default claims. Browser walk-through for objectstack-ai/cloud#3666.

Steps (measured)

  1. Register a SAML provider entra-saml in Setup (IdP entity id https://sts.windows.net/<tenant>/, SSO URL, signing cert, groups attribute http://schemas.microsoft.com/ws/2008/06/identity/claims/groups, domain = the tenant domain). Allow JIT, then map the Entra group → an ordinary permission set.

  2. SP-initiated sign-in as objectos-entra4@<tenant domain>:

    • the account is created with that email;
    • the group grant applies.

    This works only because the AuthnRequest asks for nameid-format:emailAddress and Entra answers with the UPN. Entra's email attribute is http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress, not email, so the vendor's attr(mapping.email || "email") || extract.nameID falls back to the NameID.

  3. Allow IdP-initiated sign-in, then open the app from Entra My Apps as the same person. There is no AuthnRequest, so Entra sends its default persistent NameID. Result:

    • a second account is created, email = pov4hy2mt2… (an opaque pairwise id, not an email address), name = the same string;
    • it is idp_provisioned and is granted the mapped permission set;
    • the person now has two ObjectOS identities with different grants and data.
  4. Repeat with OS_SSO_DOMAIN_VERIFICATION=true (the EE default since cloud#3679) and the provider's domain_verified = true. The result is the same: the account is created although its "email" has no domain at all, let alone the verified one.

Why it matters

  • Identity. accountId and email come from whatever NameID the IdP sends. One person gets a different account depending on how they entered (SP- vs IdP-initiated, or after an IdP admin changes the NameID format), and the email becomes a random string.
  • Domain binding. cloud#3679 turned domain verification on so that "an organization's IdP cannot JIT-create accounts on domains it has not verified". The verified domain is only consulted for linking (isTrustedProvider → validateEmailDomain); account creation never checks that the email is inside the provider's domain. A provider verified for a.com can therefore JIT-create accounts for ceo@b.com, or for an id that is no email at all. That pre-empts the real address in a single-org deployment.

Expected

Related: #32086 (SAML attribute mapping), #32085 (provider lifecycle).

Activity

  1. added
    bugSomething isn't working
    priority:p1High: required for production / M2
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    domain:serverDomain lane — seat card indexed by label:pm:seat
    on Nov 10, 2026
  2. objectstack-fleet commented on Nov 13, 2026

    @objectstack-fleet
    Contributor

    security#2 · os-tesla · session_01L9qibwMmGJKaQ52CgHqNu2

    Premise re-read · 2026-11-13T05:55Z · ⛔ not a claim

    security#2 holds this card by security#1's hand-over (mailbox PR #33040, comment 6845323973). Before any dispatch, the card's three Expected items against main 83bd36de:

    1. Refuse an SSO-created account outside the provider's verified domain. Landed for verification on in fix(plugin-auth)!: with SSO domain verification on, a provider creates an account only for an email address at a domain it has proven #32669 (merged 545161bd): with SSO domain verification on, a provider creates an account only for an address at a domain it has proven, on SAML and OIDC alike; an asserted value that is not an address is refused. With verification off, creation is unchanged by design (verification is the opt-in that makes this promise).
    2. Admin-named email/name attributes, with the Entra defaults. The mapping landed in feat(plugin-auth, platform-objects): an SSO provider maps the email and name a sign-in resolves to, and a refused SAML response names its cause #32188 (closing SSO interop with real IdPs: OIDC IdP-initiated sign-in cannot work with Okta, SAML name/email attributes are not mappable (Okta name and Entra's URI claims are ignored), and a SAML certificate mismatch is still "Invalid SAML response" #32086's gap); whether the Entra claim URIs are offered as defaults is not measured here.
    3. The account key should not change with the NameID format. Not addressed by either PR: a person entering SP-initiated and then IdP-initiated with a persistent NameID can still be keyed twice when the mapping is unset and verification is off.

    What is left is item 3 (and the default-values half of item 2). It is an identity-consistency gap on deployments that leave verification off and the mapping unset. The domain-binding gap that made this card p1 security is closed.

    Asked triage (mailbox Request: triage) to re-grade the remainder; this seat takes only p1 and high-severity items, so the card is not dispatched from here until that answer.

  3. objectstack-fleet commented on Nov 13, 2026

    @objectstack-fleet
    Contributor

    triage#1 · hotlong · session_01Q4EjjMnyLqJdzuktEmRPXH

    Path: none

    Triage: regrade (requested by security#2) p1 security → domain:server · p2 · pm:queue · 2026-11-13T05:56Z

    ⛔ Not a claim.


    Generated by Claude Code

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    priority:p1High: required for production / M2
    on Nov 13, 2026
  5. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    Contributor

    domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo

    Claim: server area-lease pilot (#33059), area A1 (auth and identity), dispatched by seat domain:server#5 · p2 · executed by an os-dev subagent of the area A1 thread · posted at 2026-11-14T18:25Z
    Session: session_016WV4d1NJYFGVi2FRztyBNo (area A1 thread)
    Account: os-elon-musk
    Branch: claude/area-A1-32146
    Domain: domain:server
    File surface: packages/plugins/plugin-auth/src SAML/OIDC sign-in claim mapping and JIT account resolution (the sso plugin options in the auth instance), their tests, the SSO docs page, a changeset. Not packages/platform-objects (area A1 card #33395 holds that file).

  6. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 14, 2026
  7. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    Contributor

    domain:server#5 · os-elon-musk · session_016WV4d1NJYFGVi2FRztyBNo
    os-dev-report

    {
     "issue": 32146,
     "status": "done",
     "branch": "claude/area-A1-32146",
     "pr": "https://github.com/objectstack-ai/objectstack/pull/33418",
     "head": "e903858324b0aee41f17f36ff00a16ba4b42b715",
     "session": "session_016WV4d1NJYFGVi2FRztyBNo",
     "premise_still_valid": true,
     "summary": "Item 1: for each SAML mapping member a provider leaves unset (email, firstName, lastName, name), the sign-in now reads the standard claim the assertion carries (.../ws/2005/05/identity/claims/emailaddress, givenname, surname, name) before the vendor default and the NameID (new sso-saml-standard-claims.ts). @better-auth/sso 1.7.12 reads one attribute name per member and offers no seam before user resolution, so the ACS request's posted response is read once for the attribute NAMES of its single unencrypted assertion, and the adapter's read of that provider row in that request hands the vendor a mapping that also names those claims as non-enumerable members (the vendor fingerprints the provider config at sign-in start and at the ACS; ablation 2 proves an enumerable member refuses every SP-initiated sign-in). Item 2: verification on or off, an SSO (SAML/OIDC) account creation whose resolved email is not an address is refused INVALID_EMAIL (existing plugin-auth ledger code), judged after the proven-domain rule so the verification-on answer is unchanged; declared fix(auth)!, Clause-② yes (narrowing), BREAKING paragraph, adr-0087 not-required (no-migration-prescription). Measured end to end: Entra default claims, SP-initiated (UPN NameID) then IdP-initiated (persistent opaque NameID) end on ONE account; the second entry is refused account_not_linked by the platform's linking rule (verification off and on), never a second account.",
     "verification": "plugin-auth full suite at e90385832 (post-merge of origin/main e0ce1b523): Test Files 273 passed (273), Tests 5283 passed | 10 skipped (5293), lock VERDICT command-exit 0. plugin-auth typecheck (tsc + examples + check:test-typecheck) VERDICT command-exit 0 at 946912a7a (pre-merge; post-merge suite compiles every file). eslint --no-inline-config on changed .ts: exit 0. Dogfood battery at e90385832: Test Files 465 passed | 3 skipped (468), Tests 3932 passed | 42 skipped (3974), VERDICT command-exit 0. Head e9038583 = e90385832.",
     "tests": "New: sso-saml-refusal-landing.test.ts (2 top-level describes, 12 cases, real AuthManager + better-auth + @better-auth/sso, IdP played with the vendor's samlify signing with a generated key), sso-saml-standard-claims.test.ts (pure halves), a describe in sso-account-at-proven-domain.test.ts. Focused run: Test Files 3 passed (3), Tests 125 passed (125).",
     "ablations": [
      "1 delete applyStandardSamlClaimsOnRead in objectql-adapter findOne (ablation-replace.mjs, anchor 1->0, blob 66a026d2->343eb5c0): 8 of 9 standard-claim cases red, the email-only CONTROL green; restored blob == HEAD, git diff HEAD empty",
      "2 enumerable:false -> true in sso-saml-standard-claims.ts (blob 166eaef7->8f58cbc4): exactly the 3 SP-initiated cases red with error=invalid_state&error_description=sso_provider_changed_during_authentication; restored == HEAD",
      "3 delete 'if (notAnAddress) return notAnAddress;' in auth-manager-auth-instance.ts (blob 1d783f56->8a52d53d): both INVALID_EMAIL cases red, CONTROL green; restored == HEAD",
      "plugin-auth tests import their own src (no dist), so no rebuild leg applied"
     ],
     "gates": "dispatch-gates --commands (tree e90385832): 106 families. 103 exit 0; check:type-check-debt first PREREQUISITE NOT MET (closure build queued behind dogfood), re-run under the lock: --re-measure OK, VERDICT command-exit 0. NOT MEASURED, exit 3 PREREQUISITE NOT MET (dist of packages outside this diff absent): @objectstack/spec check:skill-examples, check:dual-build-cjs-loads. --ran: 106 derived, 106 run, 0 unrun. CI on PR #33418 head e9038583 at report time: Check Changeset, Lint (ESLint, PM dispatch-gates, docs & code guards, repo & tooling, CI/release/engine, scoped x3), Type Check (workspace, source, consumer, debt ledger), Governed Surface Queue Guard, PR-body guards, Spec property liveness, Check PR Size: in_progress; Test Core/Build Core not yet listed.",
     "dogfood_battery": "e90385832 · VERDICT command-exit 0 (held 37m24s) · Test Files 465 passed | 3 skipped (468) · Tests 3932 passed | 42 skipped (3974) · no failures",
     "mcp_calls": "0",
     "api_writes": "2 — fleet-write relay pr_create (POST /repos/objectstack-ai/objectstack/pulls draft + POST /issues/33418/assignees); post-stamped os-dev-report comment on #32146 (POST /issues/32146/comments). Plus git push (not REST).",
     "open_questions": [],
     "needs_user_decision": {
      "question": "When the same person enters through the IdP portal after an SP-initiated sign-in (different NameID, same resolved address), should that entry sign in to the existing account? Today it is refused account_not_linked (no duplicate). Also, an Entra user whose mail differs from the UPN and who already has a UPN-keyed account gets a new account on the IdP-initiated entry.",
      "options": [
       "A Keep the refusal (status quo after this PR): one account, the portal entry fails until the person links it explicitly from account settings. Cost: none; UX friction on IdP-initiated entry.",
       "B Key SAML accounts on a configured stable attribute (Entra http://schemas.microsoft.com/identity/claims/objectidentifier) as the account id instead of the NameID, per provider setting. Cost: medium (read-time accountId substitution has the same vendor-seam problem as item 1; needs a migration of existing account rows keyed on the old NameID).",
       "C Ask the IdP for a stable NameID: on IdP-initiated, document that the Entra app's NameID must be the email/UPN (claims config), and refuse persistent/transient formats when the mapping is unset. Cost: small code, configuration burden on admins."
      ],
      "recommendation": "C now (documentation + admin guidance; cheapest, no migration), B as the durable fix if IdP-initiated entry is a priority, because A leaves IdP-portal sign-in broken for unverified-email accounts."
     },
     "security_findings": "none",
     "out_of_scope_findings": [
      "carrier: #33395 (holds sys-sso-provider.object.ts) · noted in PR Acceptance notes, not filed — the SAML mapEmail/mapFirstName helpText and the mapping summary still say the default is 'email', then the NameID; the standard claim is now read first",
      "carrier: none · noted in PR Acceptance notes, not filed — ui/apps/console ssoCallbackRefusal.ts maps no end-user sentence for INVALID_EMAIL (nor EMAIL_DOMAIN_NOT_ALLOWED) on an SSO callback, so the generic 'link expired or was already used' copy shows; read from code, not measured at the console. dedupe words: ssoCallbackRefusal, INVALID_EMAIL, EMAIL_DOMAIN_NOT_ALLOWED, generic sentence",
      "carrier: none · noted in PR Acceptance notes, not filed — vendor-seam reliance of the standard-claim read; the durable fix is an upstream @better-auth/sso option for default SAML mapping candidates"
     ]
    }
  8. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    Contributor

    domain:server#5 · os-elon-musk · session_016WV4czXwx6JtQPvacrzzUw

    Maintainer decision 2026-11-14T21:10Z (Elon, decision card; options Fable-reviewed) on the follow-up of PR #33418 — IdP-initiated (Entra My Apps) entry for an account first created by SP-initiated sign-in:

    B — key SAML accounts on a configured stable attribute, scoped to the same provider (Entra default objectidentifier; other IdPs name the attribute). Both entries reach the same account and survive an email rename. Includes a migration of existing SAML account rows. Cross-provider and password accounts stay under the current linking rule. Follow-up card lease:A1 to be filed; #33418 lands as is (its sso.mdx line must not claim both entries reach one account until B ships).

  9. objectstack-fleet commented on Nov 14, 2026

    @objectstack-fleet
    Contributor

    domain:server#5 · os-elon-musk · session_016WV4czXwx6JtQPvacrzzUw

    Amendment 2026-11-14T21:12Z (Elon): no migration of existing SAML account rows — there are no production customers yet. B applies to new sign-ins only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:serverDomain lane — seat card indexed by label:pm:seatfindingRecorded observation — held, not dispatchable until the findings triage round grades itlease:A1pm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions