From 22da6a385ee9b7aadf180e2f46110dc3a4408464 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 11 Sep 2026 17:28:24 -0400 Subject: [PATCH] auth0: only merge an identity that has nothing to lose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linking makes the secondary user cease to exist as a subject, and Aesthetic Computer keys by subject — `@handles` is findOne({_id: sub}), and it is not the only place. An identity with history behind it cannot be merged at login time; that needs an AC-side re-key first. Found by looking rather than reasoning: me@jas.life has two unlinked Auth0 users, auth0|63effeeb… on the database connection with 783 logins and email|63ed8722… on the passwordless one with 35, and BOTH already own a @handles row pointing at @jeffrey. The Action as written would have merged them and orphaned the second one's history — on the maintainer's own account, on the first real test. So it declines anything with logins_count > 1. An account already split this way keeps working exactly as it does today: two subjects, one handle. Unifying them is a migration, not a login-time decision. Co-Authored-By: Claude Opus 5 (1M context) --- system/backend/auth0-actions/README.md | 23 +++++++++++++++++++ .../auth0-actions/link-email-identity.js | 10 ++++++++ 2 files changed, 33 insertions(+) diff --git a/system/backend/auth0-actions/README.md b/system/backend/auth0-actions/README.md index 04eb165191..2784e170c1 100644 --- a/system/backend/auth0-actions/README.md +++ b/system/backend/auth0-actions/README.md @@ -66,6 +66,29 @@ It worked when the `sub` in the returned `id_token` is the **existing** `auth0|…` subject rather than a fresh `email|…` one, and `GET /handle?for=` answers with the handle instead of 404. +### It only ever touches a brand new identity + +Linking makes the secondary user **cease to exist as a subject**, and Aesthetic +Computer keys by subject — `@handles` is `findOne({_id: sub})`, and it is not +the only place. So a passwordless identity that already has history cannot be +merged here; that needs an AC-side re-key first, which is separate work. + +This is not hypothetical. `me@jas.life` has two unlinked Auth0 users: + +``` +auth0|63effeeb… Username-Password-Authentication, 783 logins → @jeffrey +email|63ed8722… email passwordless, 35 logins → @jeffrey +``` + +Both own a `@handles` row. Merging them would have silently orphaned the +second one's history. The Action therefore declines anything with +`logins_count > 1` — a brand new identity has nothing to lose, and that is the +only case it is allowed to touch. + +An account already split this way keeps working exactly as it does today: two +subjects, both resolving to the same handle. Unifying them is a migration, not +a login-time decision. + ### The security rule, and why it is not optional Linking on an unverified address is account takeover: anyone who controls a diff --git a/system/backend/auth0-actions/link-email-identity.js b/system/backend/auth0-actions/link-email-identity.js index 5c0bbd8c2d..369678b1e2 100644 --- a/system/backend/auth0-actions/link-email-identity.js +++ b/system/backend/auth0-actions/link-email-identity.js @@ -51,6 +51,16 @@ exports.onExecutePostLogin = async (event, api) => { // takeover. if (event.user.email_verified !== true || !event.user.email) return; + // Only an identity created by THIS login. Linking makes the secondary user + // cease to exist as a subject, and Aesthetic Computer keys by subject — + // `@handles` is `findOne({_id: sub})`, and it is not alone. A passwordless + // identity with history behind it therefore cannot be merged here without + // re-keying that history first: @jeffrey's own `email|…` had thirty-five + // logins and a handle row of its own when this was written, and linking it + // would have orphaned both. A brand new identity has nothing to lose, which + // is the only case this Action is allowed to touch. + if ((event.stats?.logins_count ?? 0) > 1) return; + const management = new ManagementClient({ domain: event.secrets.AUTH0_DOMAIN, clientId: event.secrets.AUTH0_M2M_CLIENT_ID, -- 2.51.2