Repository navigation
security(auth): implicit account linking on social / OIDC sign-in is broader than the platform's account-ownership rules allow — detail withheld pending maintainer #21846
Description
Activity
objectstack-fleet commented
on Oct 5, 2026 ContributorAuthorMore actionsPath: sign-in and identity — external identity: OAuth consent and social account linking | identity-auth.linked-accounts-social | P2
Triage: first grade —
bug·security·priority:p1·domain:services·area:identity·pm:queue. Already ruled ("tighten"): implicit linking meets the library's local-ownership requirement, except the documented cloud providerTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-05T08:55Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only. The withheld detail stays withheld (RUNNER rule 2); this seat does not hold it.Triage: lands in the social / OIDC sign-in account-linking configuration (
plugin-auth, by class) ⇒domain:services; rationale: account ownership across an external provider is the identity lane's boundary, and the maintainer has already ruled its direction.- Ruled (recorded on this card, 「算漏洞,收紧」):
- every provider except the platform's own cloud provider meets the library's standard local-ownership requirement before an implicit link;
- a user's unlink stops implicit re-linking.
- No decision is owed.
- Why p1. It is an account-ownership defect, graded high where self-registration is open. It predates 17.6.0.
- Pins: both directions. A link that fails the ownership requirement is refused, an unlinked provider does not re-link implicitly, and the cloud provider's documented exception still links.
- Serial: identity: the "Link Social Account" action is dead on every boot — it navigates to a GET of the POST-only social sign-in route (404) and offers a fixed provider list regardless of configuration #21849 (the explicit link action) is in the same identity area. The claim reads its file list.
- The withheld detail goes to the claiming seat from
session_018zT8d8NpiQ1ExhuNd5TxY6on the maintainer's word.
Generated by Claude Code
- Ruled (recorded on this card, 「算漏洞,收紧」):
- addedarea:identityLogin and identity — sign-up, sessions, organization membership, SSOLogin and identity — sign-up, sessions, organization membership, SSObugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Oct 5, 2026 objectstack-fleet commented
on Oct 5, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 (17.7 pre-release follow-up, dispatched on the maintainer's direct order)
Session:session_018zT8d8NpiQ1ExhuNd5TxY6
Account:hotlong(the seat's linked user asGET /useranswers it; always the card's assignee)
Branch:claude/issue-21846-implicit-account-linking
Worktree:objectstack-issue-21846
Domain:domain:services(card label, as triage set it)
File surface:packages/plugins/plugin-auth/src,packages/qa/dogfood/test,.changeset/
Container & model:M,mode:subagent,model: opus(default tier; no path-derived mandate)
Clause-②: no
Thread-read: 5991270306
Serial constraints cleared: none namedProvenance: the maintainer, in Claude Code session
session_018zT8d8NpiQ1ExhuNd5TxY6, 2026-10-05, verbatim: 「同意,开单派发处理」, with the ruling 「算漏洞,收紧」 recorded on the card; landing per the standing order 「开发完整就进队列合并」. Dispatched one at a time within the session's load cap. Where the card withholds detail, this session holds it and the dispatch carries it privately.
Generated by Claude Code
objectstack-fleet commented
on Oct 5, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 21846, "status": "done", "branch": "claude/issue-21846-implicit-account-linking", "pr": "https://github.com/objectstack-ai/objectstack/pull/21872", "session": "session_018zT8d8NpiQ1ExhuNd5TxY6 (subagent; parent's id)", "premise_still_valid": true, "summary": "Implicit account linking on external sign-in now requires the library's standard local-ownership condition (verified local row) for every provider except the platform identity provider objectstack-cloud, which keeps its documented exception; a user's unlink is recorded and stops implicit re-linking until an explicit session-authenticated link-social, which clears the record. better-auth 1.7.3's requireLocalEmailVerified is one global boolean with no per-provider form, so the vendor flag stays false by default and the rule is enforced at the user.validateUserInfo seam (action link-account, called before linkAccount and the emailVerified flip); explicit links are told apart by the server-written link in getOAuthState(). Operator override: account.accountLinking.requireLocalEmailVerified unset = new rules, true = vendor-strict for every provider, false = local check off (unlink rule kept). New module packages/plugins/plugin-auth/src/implicit-account-linking.ts; wiring in auth-manager.ts (validateUserInfo, accountLinking, composeDatabaseHooks account.create.after / account.delete.after); patch changeset; regenerated engine-double pinned ledger for the new test double.", "tests": "Final head 6b0f00e9b2 (comment-only delta over 3532f099ae). New file src/implicit-account-linking.test.ts: 13 passed at 6b0f00e9b2 (pure decision per condition; vendor config; 5 end-to-end OAuth round trips over the real better-auth pipeline with stubbed IdP token/userinfo endpoints: unverified local + untrusted provider + IdP verified -> error=account_not_linked, no sys_account, row stays unverified; verified local -> linked; platform IdP still links unverified owner-seeded row; operator opt-out links; unlink -> implicit refused -> explicit link-social allowed + record cleared -> implicit sign-in works). Full plugin-auth vitest: 121 files, 2528 passed, 10 skipped. plugin-auth typecheck (src + examples + check:test-typecheck): exit 0. Ablations via scripts/ablation-replace.mjs (anchor hit, blob restored to HEAD, git diff HEAD empty; subject is src imported directly, no dist leg): gate wiring disabled -> 2 failed; explicit-link discrimination disabled -> 1 failed; cloud exemption removed -> 2 failed; direction red as expected. dispatch-gates --ran: 74 derived, 73 run exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads exit 3 PREREQUISITE NOT MET: needs whole-workspace build; declared to CI); extra exit 0: check:error-code-casing, check:durability-log-level, check:startup-registry-verdict. Union ran at 3532f099ae; at 6b0f00e9b2 re-ran exit 0: check:nul-bytes, engine-double-contract, type-check-debt, type-check-coverage, doc-authoring, issue-citations, comment-mask-corpus, durability-log-level, error-code-casing. eslint narrowed: eslint --no-inline-config --format json over 3 changed TS files -> 3 files, 0 errors, 0 warnings; config has no type-aware linting (no parserOptions.project) so untouched files' verdicts cannot move; repo-wide pnpm lint left to CI. packages/cli integration tier: not applicable. CI: not awaited (in_progress at report time).", "mcp_calls": "0", "api_writes": "3 - POST /repos/objectstack-ai/objectstack/pulls (pr_create via fleet-write relay, draft forced); POST /repos/objectstack-ai/objectstack/issues/21872/assignees (label-write via relay, PR assignee hotlong, zero labels: none named by the dispatch, skip-changeset not applicable); POST /repos/objectstack-ai/objectstack/issues/21846/comments (this report, post-stamped). Plus git pushes (not REST).", "open_questions": [], "out_of_scope_findings": [ "carrier: the PR that bumps better-auth to the minor where requireLocalEmailVerified becomes unconditional (vendor deprecation note); the platform-IdP exception then needs a new carrier (e.g. the owner seed) and the pinned end-to-end test turns red on that bump - noted in Acceptance notes, not filed", "carrier: none - on the id-token sign-in and one-tap paths the new refusal answers 403 code account_not_linked where the vendor's own refusal answers 401 OAUTH_LINK_ERROR; browser callback is byte-identical (error=account_not_linked); no in-repo or objectui consumer reads either - noted in Acceptance notes, not filed", "carrier: none - the unlink rule also applies to objectstack-cloud (ruling wording); a user who unlinks it must re-link from account settings before platform SSO signs them in again - noted in Acceptance notes for maintainer visibility" ], "deviations": "No dogfood real-boot run (box loaded; end-to-end tests drive the real better-auth pipeline in-process). File surface grew by scripts/engine-double-contract.pinned.json (generated by --write for the new pinned test double; coverage-only). PR body footer uses the AGENTS.md session-URL form instead of the harness's robot-line form." }
Generated by Claude Code
objectstack-fleet commented
on Oct 5, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 21846, "status": "done", "round": 2, "branch": "claude/issue-21846-implicit-account-linking", "pr": "https://github.com/objectstack-ai/objectstack/pull/21872", "head": "83010a685e", "session": "session_018zT8d8NpiQ1ExhuNd5TxY6 (subagent; parent's id)", "premise_still_valid": true, "summary": "Round 2 review items. F1: the changeset is now minor for plugin-auth, with a bang summary line, Clause-② no (narrowing), a BREAKING banner, the adr-0087 not-required (no-migration-prescription) marker and an upgrade note, following the 21197 precedent; check-adr-0087-registration accepts it. PR title and body untouched. F2: an explicit link now also requires the OAuth state's link.userId to equal data.user.id. F3: the unlink record is written in account.delete.before; if the write fails, the hook logs at error and rethrows, which aborts the delete, so the unlink answers an error and the provider stays linked (fail closed). The secondaryStorage caveat is documented in the module: ObjectStack wires no secondaryStorage, so the record is a sys_verification row. F5: the record is now one row per user (identifier account-unlinked:USERID, value lists the unlinked providers), so user.delete.after can clear it with one delete. Server-side calls with no endpoint context resolve the store from auth.$context. F6: clearUnlink runs before stampIdentitySource. Docs: content/docs/permissions/sso.mdx gains a 'Linking to an existing account' section, and authentication.mdx gains a pointer at the OAuth callback step. auth-service.mdx and services-checklist.mdx make no linking or unlink statement and were left unchanged.", "tests": "At head 83010a685e. plugin-auth typecheck (tsc src, tsconfig.examples, check:test-typecheck): exit 0. Full plugin-auth vitest: 121 files, 2534 passed, 10 skipped. src/implicit-account-linking.test.ts: 19 passed. The 6 new cases cover id-token sign-in on /sign-in/social with a social provider (403 account_not_linked, no link, row stays unverified; a verified row links), a client-supplied link in additionalData (refused), an explicit link-social for an unverified user (linked, email stays unverified), a generic OIDC provider configured by discovery (refused, then linked once verified), a store fault during unlink (unlink answers an error, account kept, no record), and a user deletion through internalAdapter.deleteUser with no endpoint context (record removed). Ablation via scripts/ablation-replace.mjs: removing the rethrow in accountDeleteBefore made 1 test fail (the fail-closed case); the file was restored to its HEAD blob and git diff HEAD was empty. A first attempt was a no-op, which the tool refused because the replacement was a substring of the anchor; it was re-run with a distinct replacement. The round-1 ablations still stand. eslint --no-inline-config --format json on the 3 changed TS files: 3 files, 0 errors, 0 warnings; the config has no type-aware linting, so other files' results cannot change. dispatch-gates --ran at 83010a685e: 105 derived, 104 run with exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads exit 3, needs a whole-workspace build, declared to CI). spec check:skill-examples first exited 3 (client-react not built) and exits 0 after building client-react. Also exit 0 at HEAD: check:error-code-casing, check:durability-log-level, check:startup-registry-verdict. The CI type-check red on 598e7a7bf9 was real: the test file still called the old two-argument unlinkTombstoneIdentifier. It is fixed in 07ba1e9378, where the lanes were cancelled by the next push rather than failed. CI was not awaited.", "mcp_calls": "0", "api_writes": "1 this round - POST /repos/objectstack-ai/objectstack/issues/21846/comments (this report, via post-stamped). Plus git pushes. Reads: check-run annotations through gh api.", "open_questions": [], "out_of_scope_findings": [ "carrier: none - content/docs/permissions/sso.mdx 'OAuth flow', OIDC step 2, says POST /api/v1/auth/sign-in/oauth2 with providerId. better-auth 1.7.3's generic-oauth registers no plugin endpoint ('Providers are used through the standard signIn.social and callback/:id core endpoints'), auth-route-ledger.ts has no sign-in/oauth2 row, and this PR's tests drive generic providers through /sign-in/social. This is earlier drift unrelated to linking; noted, not fixed here.", "carrier: none - F2 cannot be reached through the callback today: the explicit-link branch always passes link.userId as data.user.id, and the implicit path never runs while link is set. It is defence in depth, so no test can make it go red without a synthetic state.", "round-1 carriers unchanged: the better-auth minor that makes requireLocalEmailVerified unconditional; the id-token refusal answers 403 account_not_linked where the vendor answers 401 OAUTH_LINK_ERROR; the unlink rule also applies to objectstack-cloud." ] }
Generated by Claude Code
objectstack-fleet commented
on Oct 5, 2026 ContributorAuthorMore actionsCorrection to the claim above:
Clause-②: no (narrowing), notno.PR #21872 narrows a previously accepted behaviour of
@objectstack/plugin-auth. An external sign-in that used to link implicitly to an unverified local user, or that re-linked a provider the user had unlinked, is now refused. That narrowing is BREAKING. The changeset therefore grades plugin-authminor, carries the BREAKING banner and the ADR-0087not-required (no-migration-prescription)marker, and the PR is retitledfix(plugin-auth)!:(head83010a685e). No public export is added or removed. No other part of the claim changes.
Generated by Claude Code
objectstack-fleet commented
on Oct 5, 2026 ContributorAuthorMore actionsCross-lane note from
domain:engineseat 1 (seat post #6367) ·session_017ErfyP2Rx7XWHJA27QjyUi· 2026-10-05T14:38Z. ⛔ Not a claim on this card. Information for the holder (session_018zT8d8NpiQ1ExhuNd5TxY6); a reply is owed only on an objection.#21849 (claim
5996648361, ruling D5995717941) retires thesys_accountlink_socialaction. That is the "Link Social Account" button in the console's Account → Identity Links. It never completed a link on any boot: it navigates to a GET of the POST-only sign-in route.PR #21872's diff, read at head: its changeset and
content/docs/permissions/sso.mdxgive the remedy for a refused implicit link as "links the provider from account settings", and describe the explicit link as one "from account settings (/link-social)".- The door
POST /api/v1/auth/link-socialand the SDK'sauth.accounts.linkSocialstay. identity: the "Link Social Account" action is dead on every boot — it navigates to a GET of the POST-only social sign-in route (404) and offers a fixed provider list regardless of configuration #21849 touches neither. - The account-settings button that wording points at does not work today. Once identity: the "Link Social Account" action is dead on every boot — it navigates to a GET of the POST-only social sign-in route (404) and offers a fixed provider list regardless of configuration #21849 lands, it is gone.
If the remedy should name a path that works, it is the signed-in
POST /api/v1/auth/link-social(orauth.accounts.linkSocial). That wording is your PR's to decide. #21849 does not editplugin-auth, the changeset orsso.mdx.
Generated by Claude Code
- The door
objectstack-fleet commented
on Oct 5, 2026 ContributorAuthorMore actionsos-dev-report
{ "issue": 21846, "status": "done", "round": 3, "branch": "claude/issue-21846-implicit-account-linking", "pr": "https://github.com/objectstack-ai/objectstack/pull/21872", "head": "93ed0240e1", "session": "session_018zT8d8NpiQ1ExhuNd5TxY6 (subagent; parent's id)", "premise_still_valid": true, "summary": "Round 3 security re-review items. (1) Unlink records are now one row per user and provider (identifier account-unlinked:USERID:PROVIDERID). A row is only ever created or deleted, never rewritten. An unlink creates its row in account.delete.before, or does nothing if the row already exists, and a failed create aborts the unlink. A landed link deletes only that provider's row. A user's deletion deletes the rows by the account-unlinked:USERID: prefix (starts_with). No write passes through a state with less protection, and concurrent unlinks each create their own row. (2) Records are read and written through the database adapter (AuthContext.adapter), not internalAdapter verification helpers. When a host passes secondaryStorage, the auth manager now sets verification.storeInDatabase: true, which keeps every verification value a database row with the cache in front, as with no cache. Without it better-auth drops the verification model from the schema. Hosts without secondaryStorage are unchanged. The changeset gains one bullet for this; nothing else in it changed. (3) The platform exception now also requires source.method === 'oauth' (isPlatformIdpSource), so an sso-oidc or sso-saml provider registered as objectstack-cloud gets no exception. I did not add the optional registration refusal: the ObjectStack bridge is not the only door, because the vendor's /sso/register is served by the catch-all, so refusing in the bridge alone would be partial. The method binding closes the gap on every door.", "module_header_wording_for_pr_body": "The unlink record is always a sys_verification row, written and read through the database adapter, never through internalAdapter.*VerificationValue. Those helpers would put it in a host's secondaryStorage cache, where an eviction silently re-opens implicit re-linking. A host secondaryStorage on its own would also drop the verification model from better-auth's schema, so the auth manager sets verification.storeInDatabase: true whenever one is configured: every verification value then stays a database row (as it is with no cache, the default), and the cache only fronts it.", "tests": "At head 93ed0240e1. plugin-auth typecheck (tsc src, examples, check:test-typecheck): exit 0. Full plugin-auth vitest: 121 files, 2538 passed, 10 skipped. src/implicit-account-linking.test.ts: 23 passed. New cases: the platform exception is refused for sso-oidc, sso-saml and missing methods; a second unlink that hits a store fault leaves the first provider's record and refusal in place and the second provider linked; concurrent unlinks of two providers (Promise.all) leave both records and both refusals; with a host secondaryStorage the record is a database row and survives evicting every verification cache entry. Ablations via scripts/ablation-replace.mjs, each restored to its HEAD blob with git diff HEAD empty. Item 1, the delete-then-create rewrite reintroduced in the unlink hook: 2 failed (the store-fault second unlink and the concurrent case). Item 2, storeInDatabase removed: 1 failed (the secondaryStorage case). eslint --no-inline-config --format json on the 3 changed TS files: 3 files, 0 errors, 0 warnings. dispatch-gates --ran at 93ed0240e1: 105 derived, 104 run with exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, exit 3, needs a whole-workspace build; declared to CI). spec check:skill-examples exits 0 after building client-react. Also exit 0: check:error-code-casing, check:durability-log-level, check:startup-registry-verdict, check-adr-0087-registration. The concurrent test runs on an in-process memory engine, so it proves there is no read-modify-write left to lose, not database-level isolation. CI not awaited.", "mcp_calls": "0", "api_writes": "1 this round - POST /repos/objectstack-ai/objectstack/issues/21846/comments (this report via post-stamped). Plus git push of 93ed0240e1.", "open_questions": [], "out_of_scope_findings": [ "carrier: none - an SSO provider can still be registered under providerId objectstack-cloud, through the bridge or the vendor's /sso/register. After this round it gets no linking exception, but the id collision itself is still unguarded. Noted, not filed.", "Round-1 and round-2 notes unchanged, including the sso.mdx sign-in/oauth2 drift." ] }
Generated by Claude Code
- added 2 commits that reference this issue
on Oct 7, 2026
QA-source: #21845 · identity-auth.linked-accounts-social · outside the item's clauses
Found while building the OIDC fixture for
identity-auth.linked-accounts-socialin the follow-up run #21845 (subject316be321e); confirmed by an independent verifier (RUNNER rule 7) for every condition.session_018zT8d8NpiQ1ExhuNd5TxY6).objectstack. No open card covers it.Generated by Claude Code