You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 41a1135
Browse filesBrowse the repository at this point in the historyBrowse files
fix(plugin-auth)!: implicit account linking requires the standard local-ownership condition; unlink is honoured (#21872)
Fixes#21846
Clause-②: no (narrowing)
> **BREAKING** (`@objectstack/plugin-auth` `minor`, under the
launch-window convention for accept-set narrowings): an external sign-in
that used to link implicitly to an unverified local user, or re-link a
provider the user had unlinked, is now refused with
`error=account_not_linked`. The changeset carries the upgrade note and
the ADR-0087 `not-required (no-migration-prescription)` disposition.
## What changes
Implicit account linking on external sign-in (OAuth, OIDC, SSO) now
requires the library's standard local-ownership condition. The platform
identity provider keeps its documented exception, and a user's unlink is
honoured. This follows the ruling recorded on the card (「算漏洞,收紧」).
- **Every provider except the platform identity provider**
(`objectstack-cloud`) links implicitly only to a local user whose email
is verified. Otherwise the callback answers `error=account_not_linked`,
the same code better-auth's own refusal produces. No link is written and
the local row stays unverified, so the link no longer sets
`emailVerified` on an unverified row.
- **The platform identity provider** still links to an owner-seeded row
(those rows are created with `emailVerified=false`). The exception
applies only to its OAuth sign-in path (`source.method === 'oauth'`), so
an SSO provider registered under the same id gets no exception.
- **After a user unlinks a provider**, an implicit sign-in through it no
longer re-creates the link. This applies to every provider. An explicit,
session-authenticated `/link-social` is still allowed and ends the
refusal.
- **Operator override** via
`account.accountLinking.requireLocalEmailVerified`:
- unset (the default): the rules above;
- `true`: also handed to better-auth, so the strict form applies to
every provider, the platform one included;
- `false`: turns off only the local-verification check; the unlink rule
stays.
## Mechanism (better-auth 1.7.3, measured in the installed `dist/`)
- **Why not the vendor flag.** `requireLocalEmailVerified` is one global
boolean. It has no per-provider form, and `trustedProviders` does not
relax it, so it cannot carry the platform exception. The vendor flag
therefore stays `false` by default, and the requirement is enforced at
the `user.validateUserInfo` seam.
- **Where the gate runs.** `handleOAuthUserInfo` calls
`user.validateUserInfo` with `action: 'link-account'` and the provider
id, right before `linkAccount` and the `emailVerified` flip. Every
implicit-link entry goes through it: the OAuth callback, id-token
sign-in, one-tap, oauth-proxy and SSO.
- **Explicit link.** An explicit link uses the same action. It is told
apart by the server-written `link` in the parsed OAuth state
(`getOAuthState()`), and only when that state's `link.userId` equals the
user being linked. `generateState` writes `link` after the client's
`additionalData`, so a client cannot forge it.
- **Unlink record.** One `sys_verification` row per user and provider
(`account-unlinked:<user id>:<provider id>`), created in
`account.delete.before`, scoped to the `/unlink-account` path. A row is
only ever created or deleted, never rewritten, so no write passes
through a state with less protection and concurrent unlinks each keep
their own row. A failed create is logged at `error` and rethrown, so the
unlink fails and the provider stays linked (fail-closed). A landed link
deletes only that provider's row, before the identity source is stamped;
deleting the user deletes all of that user's rows by prefix. Records go
through the database adapter. When a host configures better-auth
`secondaryStorage`, the auth manager now also sets
`verification.storeInDatabase: true`, so the record stays a database row
behind the cache and survives eviction; hosts without `secondaryStorage`
are unchanged.
- **Deprecation.** better-auth marks the flag deprecated ("the gate will
become unconditional"). On that upgrade the platform exception needs
another carrier. The platform-provider end-to-end test fails on that
bump, on purpose.
New module:
`packages/plugins/plugin-auth/src/implicit-account-linking.ts`. Wiring:
`auth-manager.ts` (`validateUserInfo`, `account.accountLinking`,
`composeDatabaseHooks`).
Docs: `content/docs/permissions/sso.mdx` gains a "Linking to an existing
account" section (the verified-email rule, the platform-provider
exception, unlink and explicit re-link, the operator override, and what
`trustedProviders` does and does not relax);
`content/docs/permissions/authentication.mdx` points to it from the
OAuth callback step. `auth-service.mdx` and `services-checklist.mdx`,
also named by the docs drift check, say nothing about linking and are
unchanged.
## Tests
All at head `93ed0240e1` unless noted.
- `pnpm --filter @objectstack/plugin-auth exec vitest run
--maxWorkers=2`: 121 files, 2538 passed, 10 skipped.
`src/implicit-account-linking.test.ts`: 23 passed. Besides the decision
table, the vendor config and the end-to-end OAuth round trips over the
real better-auth pipeline (stubbed IdP, in-memory engine), it covers:
- an unverified local row with an IdP-verified email is refused; a
verified one links; the platform provider still links an unverified row;
the operator opt-out works;
- unlink, then implicit sign-in, is refused; an explicit link-social is
then allowed and clears the record;
- id-token sign-in through `/sign-in/social`: refused for an unverified
user, linked for a verified one;
- a client-supplied `link` in `additionalData` is refused;
- an explicit link-social for an unverified user links without marking
the email verified;
- a generic OIDC provider configured by discovery: refused, then linked
once verified;
- a store fault during unlink: the unlink answers an error, the account
is kept, no record is written;
- deleting the user with no endpoint context removes the record;
- a second unlink that hits a store fault keeps the first provider's
record and refusal;
- concurrent unlinks of two providers keep both records and both
refusals;
- with a host `secondaryStorage`, the record is a database row and
survives evicting every verification cache entry;
- the platform exception is refused for SSO (OIDC, SAML) sources and a
missing method.
- `pnpm --filter @objectstack/plugin-auth typecheck` (src, examples,
`check:test-typecheck`): exit 0.
- **Ablations** via `scripts/ablation-replace.mjs`, each restored to the
HEAD blob with `git diff HEAD` empty:
- gate wiring disabled: the unverified-row and unlink refusals fail;
- explicit-link discrimination disabled: the explicit link after unlink
fails;
- platform exception removed: the pure and end-to-end platform cases
fail;
- the rethrow in `account.delete.before` removed: the store-fault test
fails;
- the delete-then-create rewrite reintroduced in the unlink hook: the
second-unlink fault and concurrent-unlink tests fail.
- **Gates.** `dispatch-gates --ran` at `83010a685e` (round 2): 105
derived, 104 run with exit 0, `check:dual-build-cjs-loads` NOT MEASURED
(exit 3, it needs a whole-workspace build; declared to CI). Also exit 0:
`check:adr-0087-registration`, `check:error-code-casing`,
`check:durability-log-level`, `check:startup-registry-verdict`.
- `scripts/engine-double-contract.pinned.json` is regenerated
(`--write`) for the new test file's pinned double, a coverage-only
addition.
- **eslint, narrowed.** `eslint --no-inline-config --format json` over
the 3 changed TS files: 0 errors, 0 warnings. The config has no
type-aware linting, so this diff cannot change a verdict on an untouched
file.
## Acceptance notes
- **Refusal on the id-token and one-tap paths.** There, the new refusal
answers 403 with code `account_not_linked`. The vendor's own refusal on
those paths answers 401 `OAUTH_LINK_ERROR`. On the browser callback the
two are identical (`error=account_not_linked`). No in-repo or objectui
consumer reads either code.
- **Unlink applies to the platform provider too.** A user who unlinks
`objectstack-cloud` must re-link from account settings before platform
SSO signs them in to that environment again. That follows the ruling's
wording, and the platform exception is about the verification
precondition only.
- **Future better-auth bump.** When `requireLocalEmailVerified` becomes
unconditional, the platform-provider exception needs a new carrier, for
example the owner seed. Carrier: the PR that bumps better-auth to that
minor, where the pinned end-to-end test turns red.
- **Not run locally.** No dogfood real-boot run, because the box is
loaded. The end-to-end tests drive the real better-auth pipeline
in-process.
- **Defence in depth.** The `link.userId` binding cannot be reached
through a real flow today (the explicit-link callback always passes the
linking user), so no test turns it red without a synthetic state.
---
_Generated by [Claude
Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(plugin-auth)!: implicit account linking on external sign-in requires the library's standard local-ownership condition; the platform identity provider keeps its documented exception; an unlink is honoured
6
+
7
+
Clause-②: no (narrowing)
8
+
9
+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key, export or config field is removed or renamed: the change narrows when an external sign-in links implicitly to an existing local user, and nothing an author wrote needs rewriting. The one config key it reads, account.accountLinking.requireLocalEmailVerified, keeps its name and gains an explicit opt-out meaning. -->
10
+
11
+
**BREAKING for deployments that relied on external sign-in (OAuth, OIDC, SSO) linking implicitly to a local user whose email is not verified.**
12
+
13
+
**What changed.**
14
+
15
+
- An external sign-in links implicitly to an existing local user only when that local user's email is verified. Otherwise the sign-in is refused with `error=account_not_linked`, the same code better-auth's own refusal produces. No link is written and the local user stays unverified. A verified local user links as before.
16
+
- The platform's own identity provider (`objectstack-cloud`) keeps its documented exception and still links to an unverified local user, because it seeds the environment owner's row without a mailbox round-trip.
17
+
- After a user unlinks a provider, an implicit sign-in through it no longer links the identity again, for any provider. An explicit, signed-in link from account settings (`/link-social`) is still allowed and ends the refusal. If the unlink cannot be recorded, the unlink itself is refused and the provider stays linked. Deleting a user removes the user's unlink records.
18
+
- A deployment that passes `secondaryStorage` to the auth plugin now also keeps verification values in the database (`verification.storeInDatabase: true`). The cache still fronts them. This keeps the unlink records durable when the cache evicts entries. Deployments without `secondaryStorage` are unchanged.
19
+
-`account.accountLinking.requireLocalEmailVerified` now reads as follows. Unset (the default) means the rules above. `true` applies the strict check to every provider, including `objectstack-cloud`. `false` turns off only the local-verification check and keeps the unlink rule.
20
+
21
+
**What to do after upgrading.**
22
+
23
+
- A user refused this way signs in with their existing method, then links the provider from account settings, or verifies their email first.
24
+
- To let unverified local users link implicitly again, set `account.accountLinking.requireLocalEmailVerified: false`. Before you do, read the library's warning about account takeover.
25
+
- If you pass `secondaryStorage`: verification values written to the cache alone before the upgrade (password-reset links, one-time codes, magic links and email-verification links that were in flight at deploy time) can no longer be consumed afterwards. Users who hit this request a fresh link or code once.
0 commit comments