From 27a1343b65388008617c1d71567585185d30d087 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 14:36:13 -0400 Subject: [PATCH] fix(auth): stop emitting user.updated on sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production's sign-in stamps last_sign_in_at via a dedicated, silent updateWithSignIn path — a raw, debounced DB write that bypasses the event-emitting update() — so a login fires session.created alone. Emulate routed the stamp through ws.users.update, whose onUpdate hook fires user.updated on every login, diverging from real WorkOS and sending customers events that prod never emits. Closes #55 --- src/core/store.ts | 18 ++++++++++++++++++ src/workos/routes/auth.spec.ts | 24 +++++++++++++++++++++--- src/workos/routes/auth.ts | 26 +++++++++++++++++--------- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/core/store.ts b/src/core/store.ts index 338904e..4e54ecb 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -105,6 +105,24 @@ export class Collection { return updated; } + // Like update(), but silent: re-indexes the record without firing onUpdate or bumping + // updated_at. Mirrors production's updateWithSignIn — a raw, debounced DB write that + // stamps last_sign_in_at on sign-in without emitting a user.updated webhook or treating + // the login as a user edit. See https://github.com/workos/emulate/issues/55. + updateSilent(id: string, data: Partial): T | undefined { + const existing = this.items.get(id); + if (!existing) return undefined; + this.removeFromIndex(existing); + const updated = { + ...existing, + ...data, + id, + } as T; + this.items.set(id, updated); + this.addToIndex(updated); + return updated; + } + delete(id: string): boolean { const existing = this.items.get(id); if (!existing) return false; diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 30a465b..f665706 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -975,12 +975,14 @@ describe('Auth routes', () => { await signInWithMagicAuth('quiet@test.com'); const afterFirst = countUpdates(); - // The sign-up login both verifies the email and stamps last_sign_in_at — one write. + // The sign-up login verifies the email (a real attribute change), which emits + // user.updated. last_sign_in_at rides along in the same write. expect(afterFirst).toBe(1); await signInWithMagicAuth('quiet@test.com'); - // The second login only stamps last_sign_in_at; email_verified is already true. - expect(countUpdates()).toBe(2); + // The second login only stamps last_sign_in_at, which production writes silently via + // updateWithSignIn (no user.updated); email_verified is already true. See #55. + expect(countUpdates()).toBe(1); }); it('magic auth sign-up verifies the email and yields an org-less session', async () => { @@ -2294,6 +2296,22 @@ describe('authentication events (spec-named, spec-shaped)', () => { expect(event.data.expires_at).toBeTruthy(); }); + it('sign-in stamps last_sign_in_at without emitting a spurious user.updated', async () => { + // Production's sign-in stamps last_sign_in_at via a dedicated, silent updateWithSignIn + // path — a raw DB write that bypasses the event-emitting update() — so a login fires + // session.created alone, not user.updated. See https://github.com/workos/emulate/issues/55. + await registerUser('evt-no-user-updated@test.com', 'secret'); + + await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'evt-no-user-updated@test.com', password: 'secret' }), + }); + + expect(eventsNamed('session.created')).toHaveLength(1); + expect(eventsNamed('user.updated')).toHaveLength(0); + }); + it('MFA session falls back to auth_method: unknown when the pending token records no mapped primary', async () => { const user = await registerUser('evt-mfa@test.com', 'secret'); const ws = getWorkOSStore(store); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 5455673..4a90d14 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -759,16 +759,24 @@ export function authRoutes(ctx: RouteContext): void { // reuses the existing session, so it emits neither session.created nor an auth event. let session; if (isFreshLogin) { - // A redeemed magic-auth code proves mailbox ownership, so production marks the email - // verified. Folded into the sign-in write rather than done up in the grant: one - // user.updated per login instead of two, and nothing is persisted before the template - // gate above — which is what keeps a failed render from implying a login that never - // completed. Only set when it actually changes, so a repeat sign-in stays quiet. const verifyEmail = authMethod === 'MagicAuth' && !user.email_verified; - ws.users.update(user.id, { - last_sign_in_at: new Date().toISOString(), - ...(verifyEmail ? { email_verified: true } : {}), - }); + if (verifyEmail) { + // A redeemed magic-auth code proves mailbox ownership; production marks the email + // verified via the standard update path, which emits user.updated. Folded into the + // sign-in write so it is one write, one event, and nothing persists before the + // template gate above — which keeps a failed render from implying a login that + // never completed. + ws.users.update(user.id, { + last_sign_in_at: new Date().toISOString(), + email_verified: true, + }); + } else { + // No real attribute change: production stamps last_sign_in_at via a dedicated, + // silent updateWithSignIn path (a raw, debounced DB write) that bypasses the + // event-emitting update(), so a login fires session.created without a spurious + // user.updated. See https://github.com/workos/emulate/issues/55. + ws.users.updateSilent(user.id, { last_sign_in_at: new Date().toISOString() }); + } session = ws.sessions.insert({ object: 'session', user_id: user.id,