Repository navigation
Description
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)
-
Register a SAML provider
entra-samlin Setup (IdP entity idhttps://sts.windows.net/<tenant>/, SSO URL, signing cert, groups attributehttp://schemas.microsoft.com/ws/2008/06/identity/claims/groups, domain = the tenant domain). Allow JIT, then map the Entra group → an ordinary permission set. -
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:emailAddressand Entra answers with the UPN. Entra's email attribute ishttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress, notemail, so the vendor'sattr(mapping.email || "email") || extract.nameIDfalls back to the NameID. -
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_provisionedand is granted the mapped permission set; - the person now has two ObjectOS identities with different grants and data.
- a second account is created,
-
Repeat with
OS_SSO_DOMAIN_VERIFICATION=true(the EE default since cloud#3679) and the provider'sdomain_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.
accountIdandemailcome 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 fora.comcan therefore JIT-create accounts forceo@b.com, or for an id that is no email at all. That pre-empts the real address in a single-org deployment.
Expected
- JIT refuses (and linking refuses) any SAML/OIDC user whose email is not a syntactically valid address inside the provider's verified domain, with a clear refusal. Measure the OIDC path too; only SAML was measured here.
- The account key should not silently change with the NameID format. Request/require a stable format, or key on a configured attribute (
objectidentifierfor Entra). - The SAML form lets the admin name the email/name attributes. This is the gap SSO interop with real IdPs: OIDC IdP-initiated sign-in cannot work with Okta, SAML name/email attributes are not mappable (Okta
nameand Entra's URI claims are ignored), and a SAML certificate mismatch is still "Invalid SAML response" #32086 already lists for Okta. For Entra the defaults should be…/claims/emailaddress,…/claims/givennameand…/claims/surname(today the name also becomes the email).
Related: #32086 (SAML attribute mapping), #32085 (provider lifecycle).
Activity
objectstack-fleet commentedon Nov 13, 2026
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
main83bd36de: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).nameand 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.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.objectstack-fleet commentedon Nov 13, 2026
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.
main83bd36de.domain:server(plugin-authSSO: a key that is stable across NameID formats, plus Entra claim defaults in the mapping). Thesecuritylabel comes off at the security seat's request, since the security part is closed;findingstays.Generated by Claude Code
objectstack-fleet commentedon Nov 14, 2026
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 anos-devsubagent of the area A1 thread · posted at 2026-11-14T18:25ZSession:
session_016WV4d1NJYFGVi2FRztyBNo(area A1 thread)Account:
os-elon-muskBranch:
claude/area-A1-32146Domain:
domain:serverFile surface:
packages/plugins/plugin-auth/srcSAML/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. Notpackages/platform-objects(area A1 card #33395 holds that file).objectstack-fleet commentedon Nov 14, 2026
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" ] }objectstack-fleet commentedon Nov 14, 2026
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).objectstack-fleet commentedon Nov 14, 2026
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.