Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ export class Collection<T extends Entity> {
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>): 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;
Expand Down
24 changes: 21 additions & 3 deletions src/workos/routes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 17 additions & 9 deletions src/workos/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading