From 78908ceb07973c499d78e7e72da2344c46300401 Mon Sep 17 00:00:00 2001 From: Grayash Date: Thu, 6 Aug 2026 17:25:53 +0900 Subject: [PATCH 01/14] fix(auth): support Magic Auth sign-up for emails without a user --- src/workos/routes/auth.spec.ts | 24 ++++++++++++++++++++++++ src/workos/routes/auth.ts | 2 ++ src/workos/routes/magic-auth.ts | 21 +++++++++++++++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 43d39d4..872a1d7 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -462,6 +462,30 @@ describe('Auth routes', () => { expect(body.authentication_method).toBe('MagicAuth'); }); + it('creates the user at magic auth code creation for an unknown email', async () => { + const magicRes = await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email: 'signup@test.com' }), + }); + expect(magicRes.status).toBe(201); + const magicBody = await json(magicRes); + expect(magicBody.user_id).toBeTruthy(); + + const usersRes = await req('/user_management/users?email=signup%40test.com'); + const users = await json(usersRes); + expect(users.data).toHaveLength(1); + expect(users.data[0].id).toBe(magicBody.user_id); + expect(users.data[0].email_verified).toBe(false); + }); + + it('magic auth sign-up verifies the email and yields an org-less session', async () => { + const res = await signInWithMagicAuth('signup2@test.com'); + expect(res.status).toBe(200); + const body = await json(res); + expect(body.user.email_verified).toBe(true); + expect(decodeJwt(body.access_token).org_id).toBeUndefined(); + }); + // --- Device code tests --- it('device authorization + device_code grant flow', async () => { diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 985f6e4..12904b3 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -387,6 +387,8 @@ export function authRoutes(ctx: RouteContext): void { ); } + // A redeemed code proves mailbox ownership — production marks the email verified. + ws.users.update(magicAuth.user_id, { email_verified: true }); user = ws.users.get(magicAuth.user_id); ws.magicAuths.delete(magicAuth.id); authMethod = 'MagicAuth'; diff --git a/src/workos/routes/magic-auth.ts b/src/workos/routes/magic-auth.ts index e668651..a840b38 100644 --- a/src/workos/routes/magic-auth.ts +++ b/src/workos/routes/magic-auth.ts @@ -19,8 +19,25 @@ export function magicAuthRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'email is required', 'invalid_request'); } - const user = ws.users.findOneBy('email', email); - if (!user) throw notFound('User'); + // Magic Auth doubles as sign-up: production creates the user at code-creation + // time (the response already carries its user_id), not at authenticate. + const user = + ws.users.findOneBy('email', email) ?? + ws.users.insert({ + object: 'user', + email, + name: null, + first_name: null, + last_name: null, + email_verified: false, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: null, + impersonator: null, + }); const ma = ws.magicAuths.insert({ object: 'magic_auth', From 91120c08628006a073705642f7d0fefecf83df96 Mon Sep 17 00:00:00 2001 From: Grayash Date: Thu, 6 Aug 2026 19:38:31 +0900 Subject: [PATCH 02/14] fix(auth): reject non-string emails on magic auth creation --- src/workos/routes/auth.spec.ts | 10 ++++++++++ src/workos/routes/magic-auth.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 872a1d7..a9ece0c 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -462,6 +462,16 @@ describe('Auth routes', () => { expect(body.authentication_method).toBe('MagicAuth'); }); + it('rejects a non-string email on magic auth creation', async () => { + const res = await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email: 123 }), + }); + expect(res.status).toBe(400); + const body = await json(res); + expect(body.code).toBe('invalid_request'); + }); + it('creates the user at magic auth code creation for an unknown email', async () => { const magicRes = await req('/user_management/magic_auth', { method: 'POST', diff --git a/src/workos/routes/magic-auth.ts b/src/workos/routes/magic-auth.ts index a840b38..8219fd1 100644 --- a/src/workos/routes/magic-auth.ts +++ b/src/workos/routes/magic-auth.ts @@ -14,8 +14,8 @@ export function magicAuthRoutes(ctx: RouteContext): void { app.post('/user_management/magic_auth', async (c) => { const body = await parseJsonBody(c); - const email = body.email as string | undefined; - if (!email) { + const email = body.email; + if (typeof email !== 'string' || !email) { throw new WorkOSApiError(400, 'email is required', 'invalid_request'); } From 188208ea298b54e5bba9803a17af99b5786628a8 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 10:15:04 -0400 Subject: [PATCH 03/14] fix(auth): harden the magic auth sign-up path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing account creation through a lookup that was only ever a read exposes what the read could afford to get wrong. An exact-match miss on 'User@x.test' vs 'user@x.test' used to be a harmless 404; once a miss creates a user, the same miss forks the account in two. A bare presence check on the email was likewise fine for a read and turns a typo into a permanent ghost account once it can write. Verifying the email up in the grant also meant persisting a change, and firing user.updated, before the JWT template gate — the one thing that comment is there to prevent, since a failed render is supposed to leave no trace of a login that never completed. Folding it into the sign-in write fixes the ordering and drops the second webhook per login. --- src/workos/helpers.ts | 20 ++++++++++++++ src/workos/routes/auth.spec.ts | 47 +++++++++++++++++++++++++++++++++ src/workos/routes/auth.ts | 13 ++++++--- src/workos/routes/magic-auth.ts | 14 +++++++--- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 9d78fe2..e3b6c1c 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -335,6 +335,26 @@ export function generateCode(): string { return String(Math.floor(100000 + Math.random() * 900000)); } +/** + * Whether a string is shaped enough like an email to be worth storing. Deliberately loose — + * the emulator is not an address validator, it just refuses input that could only be a typo. + */ +export function isEmailShaped(value: string): boolean { + const at = value.indexOf('@'); + return at > 0 && at === value.lastIndexOf('@') && at < value.length - 1 && !/\s/.test(value); +} + +/** + * Look a user up by email, ignoring case. `findOneBy` is an exact-match index lookup, which is + * fine for a read but forks the account in two anywhere a miss creates a user instead. + */ +export function findUserByEmail(ws: WorkOSStore, email: string): WorkOSUser | undefined { + const exact = ws.users.findOneBy('email', email); + if (exact) return exact; + const normalized = email.toLowerCase(); + return ws.users.all().find((u) => u.email.toLowerCase() === normalized); +} + /** * Hash password using SHA256. * NOTE: This is intentionally weak for emulator/testing only. diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index a9ece0c..7c4e028 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -488,6 +488,53 @@ describe('Auth routes', () => { expect(users.data[0].email_verified).toBe(false); }); + it('resolves an existing user case-insensitively instead of forking the account', async () => { + const first = await json( + await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email: 'Casing@Test.com' }), + }), + ); + const second = await json( + await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email: 'casing@test.com' }), + }), + ); + + expect(second.user_id).toBe(first.user_id); + // Stored as first given — production preserves the case it was handed. + const users = getWorkOSStore(store).users.all(); + expect(users).toHaveLength(1); + expect(users[0].email).toBe('Casing@Test.com'); + }); + + it('rejects an email that could only be a typo, rather than creating a ghost user', async () => { + for (const email of ['', ' ', 'not-an-email', 'a b@test.com', '@test.com', 'nope@']) { + const res = await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email }), + }); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invalid_request'); + } + expect(getWorkOSStore(store).users.all()).toHaveLength(0); + }); + + it('emits one user.updated per magic auth sign-in, and none for a no-op re-verify', async () => { + const ws = getWorkOSStore(store); + const countUpdates = () => ws.events.all().filter((e: { event: string }) => e.event === 'user.updated').length; + + await signInWithMagicAuth('quiet@test.com'); + const afterFirst = countUpdates(); + // The sign-up login both verifies the email and stamps last_sign_in_at — one 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); + }); + it('magic auth sign-up verifies the email and yields an org-less session', async () => { const res = await signInWithMagicAuth('signup2@test.com'); expect(res.status).toBe(200); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 12904b3..bb45c90 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -387,8 +387,6 @@ export function authRoutes(ctx: RouteContext): void { ); } - // A redeemed code proves mailbox ownership — production marks the email verified. - ws.users.update(magicAuth.user_id, { email_verified: true }); user = ws.users.get(magicAuth.user_id); ws.magicAuths.delete(magicAuth.id); authMethod = 'MagicAuth'; @@ -682,7 +680,16 @@ export function authRoutes(ctx: RouteContext): void { // reuses the existing session, so it emits neither session.created nor an auth event. let session; if (isFreshLogin) { - ws.users.update(user.id, { last_sign_in_at: new Date().toISOString() }); + // 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 } : {}), + }); session = ws.sessions.insert({ object: 'session', user_id: user.id, diff --git a/src/workos/routes/magic-auth.ts b/src/workos/routes/magic-auth.ts index 8219fd1..82fff87 100644 --- a/src/workos/routes/magic-auth.ts +++ b/src/workos/routes/magic-auth.ts @@ -1,6 +1,6 @@ import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatMagicAuth, generateCode, expiresIn } from '../helpers.js'; +import { formatMagicAuth, generateCode, expiresIn, findUserByEmail, isEmailShaped } from '../helpers.js'; export function magicAuthRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -14,15 +14,21 @@ export function magicAuthRoutes(ctx: RouteContext): void { app.post('/user_management/magic_auth', async (c) => { const body = await parseJsonBody(c); - const email = body.email; - if (typeof email !== 'string' || !email) { + // This handler now creates users, so its input guard is the only thing standing between a + // typo and a permanent ghost account. A bare presence check was enough when the endpoint + // could only ever read. + const email = typeof body.email === 'string' ? body.email.trim() : ''; + if (!email || !isEmailShaped(email)) { throw new WorkOSApiError(400, 'email is required', 'invalid_request'); } // Magic Auth doubles as sign-up: production creates the user at code-creation // time (the response already carries its user_id), not at authenticate. + // The lookup is case-insensitive because the creating branch below is: an exact-match + // miss on 'User@x.test' vs 'user@x.test' used to be a harmless 404 and would now fork + // the account in two. The address is stored as given — production preserves case. const user = - ws.users.findOneBy('email', email) ?? + findUserByEmail(ws, email) ?? ws.users.insert({ object: 'user', email, From 4d9ca7f83d31e1381804448a4ec5b233bd2dc36b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 11:46:30 -0400 Subject: [PATCH 04/14] fix(auth): redeem magic auth codes case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creation resolves the user case-insensitively but redemption matched the email exactly, so the two halves disagreed about what the same address means. A code requested for 'user@x.test' against a stored 'User@X.test' is recorded under the stored casing, and authenticating with the address the caller actually used then failed invalid_one_time_code — a 201 handing back a code the request it answered could never spend. Recoverable only because the 201 body carries the canonical email, which no client should have to read to finish a flow it already had the address for. The existing case-insensitivity test never redeemed, which is why it passed. It does now, and fails without this change. Also applies the typo guard to POST /user_management/users. That route creates users too, so the same miss becomes the same unreachable account, and holding one standard is what stops `{email: 'nope'}` being a 422 on one path and a 201 on the other. It keeps the route's existing 422 convention rather than magic auth's 400. And documents both behavior changes: the endpoint no longer 404s an unknown email, and redeeming a code verifies the email of any previously-unverified user, not only ones it just created — which a suite pointed at this can be surprised by in either direction. --- README.md | 11 +++++++++++ src/workos/routes/auth.spec.ts | 15 +++++++++++++++ src/workos/routes/auth.ts | 9 ++++++++- src/workos/routes/users.spec.ts | 15 +++++++++++++++ src/workos/routes/users.ts | 14 ++++++++++++-- 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2d84a00..e7554bc 100644 --- a/README.md +++ b/README.md @@ -515,6 +515,17 @@ Only `active` memberships count — an unaccepted invitation or a deactivated me The emulator issues a new refresh token on every refresh and invalidates the one you presented, so replaying it returns `invalid_grant`. WorkOS documents that refresh tokens _may_ be rotated after use, so production is free to hand back the same token and leave it valid. The emulator always takes the stricter path: a client that forgets to store the newly returned `refresh_token` fails locally instead of in production. +### Magic Auth doubles as sign-up + +`POST /user_management/magic_auth` creates the user when the email has none, so a sign-up flow needs no separate `POST /user_management/users` first. Production does the same at code-creation time rather than at authenticate: the 201 already carries a `user_id`, the user is immediately listable with `email_verified: false`, and the email it sends uses the "Sign up" template. + +Two consequences worth knowing before you point an existing test suite at it: + +- The endpoint no longer 404s an unknown email. A test that asserted that 404 now gets a 201 — and a user. +- Redeeming a Magic Auth code sets `email_verified` to `true`, matching the live authenticate response for the same flow. This applies to **any** user who was not already verified, not only ones the endpoint just created, so a fixture seeded `email_verified: false` comes back verified after its first Magic Auth login. + +An email is resolved case-insensitively (`User@x.test` and `user@x.test` are the same account, stored under whichever case created it), and one that could only be a typo is rejected rather than turned into an account nothing can reach — the same guard `POST /user_management/users` applies. + ### Emitted events Authentication events carry the spec payload `{ type, status, user_id, email, ip_address, user_agent }` (plus `error` on failures and `sso` details on SSO events). diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 7c4e028..6796108 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -507,6 +507,21 @@ describe('Auth routes', () => { const users = getWorkOSStore(store).users.all(); expect(users).toHaveLength(1); expect(users[0].email).toBe('Casing@Test.com'); + + // And the code is redeemable with the address it was requested for, not only the stored + // casing. Resolving the user case-insensitively while matching the code exactly would + // return a 201 carrying a code that this authenticate call could never spend. + const auth = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', + code: second.code, + email: 'casing@test.com', + }), + }); + expect(auth.status).toBe(200); + expect((await json(auth)).user.id).toBe(first.user_id); }); it('rejects an email that could only be a typo, rather than creating a ghost user', async () => { diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index bb45c90..e1600b5 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -375,7 +375,14 @@ export function authRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'code and email are required', 'invalid_request'); } - const magicAuth = ws.magicAuths.all().find((ma) => ma.code === code && ma.email === email); + // Case-insensitively, because code creation resolves the user that way: a code requested + // for 'user@x.test' against a stored 'User@X.test' is recorded under the stored casing, + // so an exact match here would hand back a code that the address it was requested for + // could never redeem. + const normalizedEmail = email.toLowerCase(); + const magicAuth = ws.magicAuths + .all() + .find((ma) => ma.code === code && ma.email.toLowerCase() === normalizedEmail); if (!magicAuth) { failAuth('MagicAuth', { email }, new WorkOSApiError(400, 'Invalid code', 'invalid_code')); } diff --git a/src/workos/routes/users.spec.ts b/src/workos/routes/users.spec.ts index 643e354..82f9f9e 100644 --- a/src/workos/routes/users.spec.ts +++ b/src/workos/routes/users.spec.ts @@ -33,6 +33,21 @@ describe('User routes', () => { expect(user.password_hash).toBeUndefined(); }); + // Held to the same standard as the magic auth handler, which validates for the same reason: + // both create users, and an address that could only be a typo becomes an unreachable account. + it('rejects an email that could only be a typo', async () => { + for (const email of ['', ' ', 'not-an-email', 'a b@test.com', '@test.com', 'nope@', 'two@at@test.com', 123]) { + const res = await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email }), + }); + expect(res.status).toBe(422); + expect((await json(res)).code).toBe('unprocessable_entity'); + } + const list = await json(await req('/user_management/users')); + expect(list.data).toHaveLength(0); + }); + it('rejects duplicate email', async () => { await req('/user_management/users', { method: 'POST', diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index b55e090..4e31d24 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -7,7 +7,7 @@ import { parseListParams, } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatUser, formatIdentity, hashPassword, formatListResponse } from '../helpers.js'; +import { formatUser, formatIdentity, hashPassword, formatListResponse, isEmailShaped } from '../helpers.js'; export function userRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -15,10 +15,20 @@ export function userRoutes(ctx: RouteContext): void { app.post('/user_management/users', async (c) => { const body = await parseJsonBody(c); - const email = body.email as string | undefined; + if (body.email !== undefined && typeof body.email !== 'string') { + throw validationError('email must be a string', [{ field: 'email', code: 'invalid_type' }]); + } + const email = body.email?.trim(); if (!email) { throw validationError('email is required', [{ field: 'email', code: 'required' }]); } + // The same guard the magic auth handler applies, for the same reason: this route creates + // users, and an address that could only be a typo becomes an account nothing can reach. + // Holding the two paths to one standard is what stops `{email: 'nope'}` being a 422 on one + // and a 201 on the other. + if (!isEmailShaped(email)) { + throw validationError('email must be a valid email address', [{ field: 'email', code: 'invalid' }]); + } const existing = ws.users.findOneBy('email', email); if (existing) { From f4177ecb34effd1163ef28c1be289824c3cdd3db Mon Sep 17 00:00:00 2001 From: Garen Torikian Date: Thu, 6 Aug 2026 11:49:22 -0400 Subject: [PATCH 05/14] Update README with Magic Auth details Clarified behavior of Magic Auth and email handling. --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e7554bc..6373afb 100644 --- a/README.md +++ b/README.md @@ -519,12 +519,9 @@ The emulator issues a new refresh token on every refresh and invalidates the one `POST /user_management/magic_auth` creates the user when the email has none, so a sign-up flow needs no separate `POST /user_management/users` first. Production does the same at code-creation time rather than at authenticate: the 201 already carries a `user_id`, the user is immediately listable with `email_verified: false`, and the email it sends uses the "Sign up" template. -Two consequences worth knowing before you point an existing test suite at it: +Redeeming a Magic Auth code sets `email_verified` to `true`, matching the live authenticate response for the same flow. This applies to **any** user who was not already verified, not only ones the endpoint just created, so a fixture seeded `email_verified: false` comes back verified after its first Magic Auth login. -- The endpoint no longer 404s an unknown email. A test that asserted that 404 now gets a 201 — and a user. -- Redeeming a Magic Auth code sets `email_verified` to `true`, matching the live authenticate response for the same flow. This applies to **any** user who was not already verified, not only ones the endpoint just created, so a fixture seeded `email_verified: false` comes back verified after its first Magic Auth login. - -An email is resolved case-insensitively (`User@x.test` and `user@x.test` are the same account, stored under whichever case created it), and one that could only be a typo is rejected rather than turned into an account nothing can reach — the same guard `POST /user_management/users` applies. +An email is resolved case-insensitively (`User@x.test` and `user@x.test` are the same account, stored under whichever case created it), and one that could only be a typo is rejected rather than turned into an account nothing can reach. This is the same guard `POST /user_management/users` applies. ### Emitted events From 29b69dd516b5e10ad2255c3f5d8deeb96bd07771 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 12:23:27 -0400 Subject: [PATCH 06/14] fix(auth): resolve every email lookup case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Magic Auth stores the case it was handed, so once it creates users the lookups that stayed exact-match strand the accounts it makes. A sign-up as 'Signup@y.test' could not be reached by the password grant, by login_hint, or by password reset using the address the caller actually had — password reset 404d on an account that existed. All three resolve the way magic auth does now. POST /user_management/users deduped exactly, so 'User@x.test' and 'user@x.test' were both created and the two creation paths then disagreed about which account an address names, with magic auth's resolver settling it by insertion order. It answers 409 instead, which is what holding one standard across the two creation paths actually requires. A malformed email reported "email is required" for an address that was supplied — the opposite of what happened, on the guard that exists to keep a typo from becoming an account nothing can reach. Also pins the sign-up's user.created event. A webhook consumer testing a sign-up flow is much of why this endpoint creating a user is worth having. --- README.md | 4 ++- src/workos/routes/auth.spec.ts | 53 +++++++++++++++++++++++++++++ src/workos/routes/auth.ts | 7 ++-- src/workos/routes/magic-auth.ts | 8 ++++- src/workos/routes/password-reset.ts | 13 +++++-- src/workos/routes/users.spec.ts | 18 ++++++++++ src/workos/routes/users.ts | 15 ++++++-- 7 files changed, 110 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6373afb..e59c6f6 100644 --- a/README.md +++ b/README.md @@ -521,7 +521,9 @@ The emulator issues a new refresh token on every refresh and invalidates the one Redeeming a Magic Auth code sets `email_verified` to `true`, matching the live authenticate response for the same flow. This applies to **any** user who was not already verified, not only ones the endpoint just created, so a fixture seeded `email_verified: false` comes back verified after its first Magic Auth login. -An email is resolved case-insensitively (`User@x.test` and `user@x.test` are the same account, stored under whichever case created it), and one that could only be a typo is rejected rather than turned into an account nothing can reach. This is the same guard `POST /user_management/users` applies. +An email is resolved case-insensitively (`User@x.test` and `user@x.test` are the same account, stored under whichever case created it), and one that could only be a typo is rejected rather than turned into an account nothing can reach. `POST /user_management/users` applies both — so it answers 409 for an address that differs from an existing one only in case, rather than creating a second account no lookup can tell apart from the first. + +Every lookup by email is case-insensitive, not just Magic Auth's: the password grant, `login_hint` on the authorize endpoints, and `POST /user_management/password_reset` all resolve the same way, so an account created by a Magic Auth sign-up is reachable by whatever casing the caller has. ### Emitted events diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 6796108..7851dd8 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -3,6 +3,7 @@ import { createServer, type ApiKeyMap } from '../../core/index.js'; import { workosPlugin } from '../index.js'; import { getWorkOSStore } from '../store.js'; import { STORE_KEYS } from '../constants.js'; +import { hashPassword } from '../helpers.js'; import type { Store } from '../../core/index.js'; const apiKeys: ApiKeyMap = { sk_test_auth: { environment: 'test' } }; @@ -488,6 +489,24 @@ describe('Auth routes', () => { expect(users.data[0].email_verified).toBe(false); }); + // A sign-up that creates a user is a sign-up a webhook consumer expects to hear about, which is + // a large part of why this endpoint creating one is useful to test against at all. + it('emits user.created for a sign-up, and none when the user already existed', async () => { + const ws = getWorkOSStore(store); + const created = () => + ws.events.all().filter((e: { event: string; data: Record }) => e.event === 'user.created'); + + const signup = await json( + await req('/user_management/magic_auth', { method: 'POST', body: JSON.stringify({ email: 'evented@test.com' }) }), + ); + expect(created()).toHaveLength(1); + expect(created()[0].data).toMatchObject({ id: signup.user_id, email: 'evented@test.com', email_verified: false }); + + // A second code for the same address resolves the existing user, so nothing was created. + await req('/user_management/magic_auth', { method: 'POST', body: JSON.stringify({ email: 'evented@test.com' }) }); + expect(created()).toHaveLength(1); + }); + it('resolves an existing user case-insensitively instead of forking the account', async () => { const first = await json( await req('/user_management/magic_auth', { @@ -536,6 +555,40 @@ describe('Auth routes', () => { expect(getWorkOSStore(store).users.all()).toHaveLength(0); }); + // Absent and malformed have the same fix only if the caller is told which one happened. + it('distinguishes a missing email from an unusable one', async () => { + const missing = await req('/user_management/magic_auth', { method: 'POST', body: JSON.stringify({}) }); + expect((await json(missing)).message).toBe('email is required'); + + const malformed = await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email: 'not-an-email' }), + }); + expect((await json(malformed)).message).toBe('email must be a valid email address'); + }); + + // Magic Auth stores the case it was handed, so every other way in has to resolve that way too + // — otherwise a sign-up creates an account the rest of the API cannot reach. + it('reaches a Magic Auth account by any casing of its address', async () => { + await req('/user_management/magic_auth', { method: 'POST', body: JSON.stringify({ email: 'Mixed@Case.test' }) }); + const user = getWorkOSStore(store).users.all()[0]; + getWorkOSStore(store).users.update(user.id, { password_hash: hashPassword('correct horse') }); + + const password = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 'mixed@case.test', password: 'correct horse' }), + }); + expect(password.status).toBe(200); + expect((await json(password)).user.id).toBe(user.id); + + const reset = await req('/user_management/password_reset', { + method: 'POST', + body: JSON.stringify({ email: 'mixed@case.test' }), + }); + expect(reset.status).toBe(201); + }); + it('emits one user.updated per magic auth sign-in, and none for a no-op re-verify', async () => { const ws = getWorkOSStore(store); const countUpdates = () => ws.events.all().filter((e: { event: string }) => e.event === 'user.updated').length; diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index e1600b5..cc713f6 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -24,6 +24,7 @@ import { generateCode, formatAuthChallenge, acceptInvitation, + findUserByEmail, } from '../helpers.js'; import { renderConfiguredJwtTemplate } from '../jwt-template.js'; import type { EventBus } from '../event-bus.js'; @@ -71,7 +72,9 @@ export function authRoutes(ctx: RouteContext): void { let user; if (loginHint) { - user = ws.users.findOneBy('email', loginHint); + // Case-insensitively, like every other lookup by email: Magic Auth stores the case it was + // handed, so an account created as 'User@x.test' has to be reachable as 'user@x.test'. + user = findUserByEmail(ws, loginHint); if (!user) { const redirect = new URL(redirectUri); redirect.searchParams.set('error', 'user_not_found'); @@ -347,7 +350,7 @@ export function authRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'email and password are required', 'invalid_request'); } - user = ws.users.findOneBy('email', email); + user = findUserByEmail(ws, email); if (!user || !user.password_hash || !verifyPassword(password, user.password_hash)) { failAuth( 'Password', diff --git a/src/workos/routes/magic-auth.ts b/src/workos/routes/magic-auth.ts index 82fff87..e76136c 100644 --- a/src/workos/routes/magic-auth.ts +++ b/src/workos/routes/magic-auth.ts @@ -18,9 +18,15 @@ export function magicAuthRoutes(ctx: RouteContext): void { // typo and a permanent ghost account. A bare presence check was enough when the endpoint // could only ever read. const email = typeof body.email === 'string' ? body.email.trim() : ''; - if (!email || !isEmailShaped(email)) { + if (!email) { throw new WorkOSApiError(400, 'email is required', 'invalid_request'); } + // Reported apart from absence: the two have the same fix only if the caller is told which + // one happened, and "email is required" describes an address that was in fact supplied + // exactly backwards. + if (!isEmailShaped(email)) { + throw new WorkOSApiError(400, 'email must be a valid email address', 'invalid_request'); + } // Magic Auth doubles as sign-up: production creates the user at code-creation // time (the response already carries its user_id), not at authenticate. diff --git a/src/workos/routes/password-reset.ts b/src/workos/routes/password-reset.ts index 8bc7ec9..5d8d278 100644 --- a/src/workos/routes/password-reset.ts +++ b/src/workos/routes/password-reset.ts @@ -1,6 +1,13 @@ import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatPasswordReset, generateVerificationToken, hashPassword, expiresIn, isExpired } from '../helpers.js'; +import { + formatPasswordReset, + generateVerificationToken, + hashPassword, + expiresIn, + isExpired, + findUserByEmail, +} from '../helpers.js'; import { STORE_KEYS, EVENTS } from '../constants.js'; import type { EventBus } from '../event-bus.js'; @@ -21,7 +28,9 @@ export function passwordResetRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, 'email is required', 'invalid_request'); } - const user = ws.users.findOneBy('email', email); + // Case-insensitively, like every other lookup by email: an account Magic Auth created as + // 'User@x.test' must not be unresettable by the address the caller actually has. + const user = findUserByEmail(ws, email); if (!user) throw notFound('User'); const pr = ws.passwordResets.insert({ diff --git a/src/workos/routes/users.spec.ts b/src/workos/routes/users.spec.ts index 82f9f9e..0148fbe 100644 --- a/src/workos/routes/users.spec.ts +++ b/src/workos/routes/users.spec.ts @@ -48,6 +48,24 @@ describe('User routes', () => { expect(list.data).toHaveLength(0); }); + // Case-insensitively, like the magic auth handler: two accounts differing only in case left the + // two creation paths disagreeing about which one an address names, with magic auth's resolver + // settling it by insertion order. + it('rejects a duplicate email that differs only in case', async () => { + const first = await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email: 'User@x.test' }), + }); + expect(first.status).toBe(201); + + const second = await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email: 'user@x.test' }), + }); + expect(second.status).toBe(409); + expect((await json(second)).code).toBe('user_already_exists'); + }); + it('rejects duplicate email', async () => { await req('/user_management/users', { method: 'POST', diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index 4e31d24..6aad0ae 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -7,7 +7,14 @@ import { parseListParams, } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatUser, formatIdentity, hashPassword, formatListResponse, isEmailShaped } from '../helpers.js'; +import { + formatUser, + formatIdentity, + hashPassword, + formatListResponse, + isEmailShaped, + findUserByEmail, +} from '../helpers.js'; export function userRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -30,7 +37,11 @@ export function userRoutes(ctx: RouteContext): void { throw validationError('email must be a valid email address', [{ field: 'email', code: 'invalid' }]); } - const existing = ws.users.findOneBy('email', email); + // Case-insensitively, for the same reason the magic auth handler resolves that way: an + // exact-match miss on 'User@x.test' vs 'user@x.test' let both be created, and then the two + // creation paths disagreed about which account an address names — with magic auth resolving + // the ambiguity by insertion order. + const existing = findUserByEmail(ws, email); if (existing) { throw new WorkOSApiError(409, 'A user with this email already exists', 'user_already_exists'); } From c4b3b05a51d8ff32b5695ababbc6a017b1de84f2 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 13:45:59 -0400 Subject: [PATCH 07/14] fix(auth): reject a non-string email instead of 500ing on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving an account case-insensitively means lowercasing the address, and every path that does it type-asserted `email` first. That assertion was survivable while a lookup by email could only miss — findOneBy returns undefined for a number as readily as for an unknown address — but toLowerCase throws, so the same request that used to come back a named 4xx now comes back Internal Server Error: 401 invalid_credentials on the password grant, 404 on password reset, and 400 invalid_code on the magic-auth grant all became 500 server_error. A server_error in a consumer's suite reads as a defect in the emulator rather than a malformed request, which is the one thing an emulator must never get wrong about its own inputs. One guard for all of them, since the shape of the value is the same question everywhere it is asked. Absence is handed back for the caller to report its own way, so `{}` still says "email is required" while `{email: 123}` says what it actually is — the distinction the magic auth handler already argued for and then lost by routing a non-string into its presence check, where it reported an address that was supplied as one that was missing. The guard trims, which also closes a smaller gap in the same seam: creation stored the trimmed address while the grants and password reset resolved the raw one, so a padded copy of an address could not reach the account written under it. --- src/workos/helpers.ts | 26 ++++++++++++ src/workos/routes/auth.spec.ts | 54 ++++++++++++++++++++++++ src/workos/routes/auth.ts | 13 +++--- src/workos/routes/magic-auth.ts | 11 ++++- src/workos/routes/password-reset.spec.ts | 28 ++++++++++++ src/workos/routes/password-reset.ts | 3 +- 6 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index e3b6c1c..50debbc 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -344,6 +344,32 @@ export function isEmailShaped(value: string): boolean { return at > 0 && at === value.lastIndexOf('@') && at < value.length - 1 && !/\s/.test(value); } +/** + * Narrow a request's `email` to a trimmed string. Absence is handed back as `''` for the caller to + * report its own way; a value that is present but not a string is rejected here, because the two + * have the same fix only if the caller is told which one happened. + * + * Every caller used to type-assert instead, which was survivable while a lookup by email could + * only miss — `findOneBy` returns undefined for a number as readily as for an unknown address. + * Resolving case-insensitively means calling `toLowerCase` on it, so the same assertion now throws + * and a malformed request comes back a 500 that tells the caller nothing. + * + * Trimmed here too: creation stores the trimmed address, so a read that skipped the trim could not + * find what creation had just written. + */ +export function requireEmailString(value: unknown): string { + if (value === undefined || value === null) return ''; + if (typeof value !== 'string') { + throw new WorkOSApiError(400, 'email must be a string', 'invalid_request'); + } + return value.trim(); +} + +/** Whether two addresses name the same account. Case-insensitive, like every lookup by email. */ +export function emailsMatch(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + /** * Look a user up by email, ignoring case. `findOneBy` is an exact-match index lookup, which is * fine for a read but forks the account in two anywhere a miss creates a user instead. diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index 7851dd8..edb63bf 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -471,6 +471,60 @@ describe('Auth routes', () => { expect(res.status).toBe(400); const body = await json(res); expect(body.code).toBe('invalid_request'); + // Named for what it is. Routing this into the presence check reported "email is required" for + // an address that was supplied — the same backwards message the shape guard exists to avoid. + expect(body.message).toBe('email must be a string'); + }); + + // Every one of these paths type-asserted `email` and then lowercased it to resolve the account + // case-insensitively, so a non-string arrived at `.toLowerCase()` and came back a 500 — + // `server_error` in a consumer's suite reads as an emulator defect, not a malformed request. + it('rejects a non-string email with 400 on every grant that resolves one', async () => { + const password = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: 123, password: 'whatever' }), + }); + expect(password.status).toBe(400); + expect((await json(password)).message).toBe('email must be a string'); + + const magic = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:workos:oauth:grant-type:magic-auth:code', + code: '123456', + email: { not: 'a string' }, + }), + }); + expect(magic.status).toBe(400); + expect((await json(magic)).message).toBe('email must be a string'); + }); + + // Creation trims before storing, so a read that skipped the trim could not find the account + // creation had just written under the same address. + it('trims a padded address on the paths that resolve one', async () => { + const signup = await json( + await req('/user_management/magic_auth', { + method: 'POST', + body: JSON.stringify({ email: ' padded@x.test ' }), + }), + ); + expect(signup.email).toBe('padded@x.test'); + + getWorkOSStore(store).users.update(signup.user_id, { password_hash: hashPassword('pw') }); + const password = await app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'password', email: ' padded@x.test ', password: 'pw' }), + }); + expect(password.status).toBe(200); + + const reset = await req('/user_management/password_reset', { + method: 'POST', + body: JSON.stringify({ email: ' padded@x.test ' }), + }); + expect(reset.status).toBe(201); }); it('creates the user at magic auth code creation for an unknown email', async () => { diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index cc713f6..37bcbda 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -25,6 +25,8 @@ import { formatAuthChallenge, acceptInvitation, findUserByEmail, + requireEmailString, + emailsMatch, } from '../helpers.js'; import { renderConfiguredJwtTemplate } from '../jwt-template.js'; import type { EventBus } from '../event-bus.js'; @@ -344,7 +346,7 @@ export function authRoutes(ctx: RouteContext): void { } case 'password': { - const email = body.email as string; + const email = requireEmailString(body.email); const password = body.password as string; if (!email || !password) { throw new WorkOSApiError(400, 'email and password are required', 'invalid_request'); @@ -373,7 +375,7 @@ export function authRoutes(ctx: RouteContext): void { case 'urn:workos:oauth:grant-type:magic-auth': case 'urn:workos:oauth:grant-type:magic-auth:code': { const code = body.code as string; - const email = body.email as string; + const email = requireEmailString(body.email); if (!code || !email) { throw new WorkOSApiError(400, 'code and email are required', 'invalid_request'); } @@ -382,10 +384,7 @@ export function authRoutes(ctx: RouteContext): void { // for 'user@x.test' against a stored 'User@X.test' is recorded under the stored casing, // so an exact match here would hand back a code that the address it was requested for // could never redeem. - const normalizedEmail = email.toLowerCase(); - const magicAuth = ws.magicAuths - .all() - .find((ma) => ma.code === code && ma.email.toLowerCase() === normalizedEmail); + const magicAuth = ws.magicAuths.all().find((ma) => ma.code === code && emailsMatch(ma.email, email)); if (!magicAuth) { failAuth('MagicAuth', { email }, new WorkOSApiError(400, 'Invalid code', 'invalid_code')); } @@ -610,7 +609,7 @@ export function authRoutes(ctx: RouteContext): void { // neither their credential nor the invitation. Compared case-insensitively: an invitation to // Foo@example.com is for the same person as foo@example.com, and rejecting on letter case alone // would be a false negative. - if (invitation && invitation.email.toLowerCase() !== user.email.toLowerCase()) { + if (invitation && !emailsMatch(invitation.email, user.email)) { throw new WorkOSApiError( 400, 'The invitation was issued for a different email address', diff --git a/src/workos/routes/magic-auth.ts b/src/workos/routes/magic-auth.ts index e76136c..a5258b2 100644 --- a/src/workos/routes/magic-auth.ts +++ b/src/workos/routes/magic-auth.ts @@ -1,6 +1,13 @@ import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatMagicAuth, generateCode, expiresIn, findUserByEmail, isEmailShaped } from '../helpers.js'; +import { + formatMagicAuth, + generateCode, + expiresIn, + findUserByEmail, + isEmailShaped, + requireEmailString, +} from '../helpers.js'; export function magicAuthRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -17,7 +24,7 @@ export function magicAuthRoutes(ctx: RouteContext): void { // This handler now creates users, so its input guard is the only thing standing between a // typo and a permanent ghost account. A bare presence check was enough when the endpoint // could only ever read. - const email = typeof body.email === 'string' ? body.email.trim() : ''; + const email = requireEmailString(body.email); if (!email) { throw new WorkOSApiError(400, 'email is required', 'invalid_request'); } diff --git a/src/workos/routes/password-reset.spec.ts b/src/workos/routes/password-reset.spec.ts index 568f588..148c417 100644 --- a/src/workos/routes/password-reset.spec.ts +++ b/src/workos/routes/password-reset.spec.ts @@ -45,6 +45,34 @@ describe('Password reset routes', () => { return { user, reset }; } + // Resolving the account case-insensitively means lowercasing the address, so a type-asserted + // non-string reached `.toLowerCase()` and this came back a 500 rather than a named 400. + it('rejects a non-string email with 400, not 500', async () => { + const res = await req('/user_management/password_reset', { + method: 'POST', + body: JSON.stringify({ email: 123 }), + }); + expect(res.status).toBe(400); + expect((await json(res)).message).toBe('email must be a string'); + }); + + it('still reports an absent email as absent', async () => { + const res = await req('/user_management/password_reset', { method: 'POST', body: JSON.stringify({}) }); + expect(res.status).toBe(400); + expect((await json(res)).message).toBe('email is required'); + }); + + it('resolves the account by any casing of its address', async () => { + await req('/user_management/users', { method: 'POST', body: JSON.stringify({ email: 'Mixed@Reset.test' }) }); + const res = await req('/user_management/password_reset', { + method: 'POST', + body: JSON.stringify({ email: 'mixed@reset.test' }), + }); + expect(res.status).toBe(201); + // The reset is recorded against the stored casing, not the one the caller sent. + expect((await json(res)).email).toBe('Mixed@Reset.test'); + }); + it('emits password_reset.created when a reset is requested', async () => { const { user } = await createUserAndRequestReset(); diff --git a/src/workos/routes/password-reset.ts b/src/workos/routes/password-reset.ts index 5d8d278..bd65eb8 100644 --- a/src/workos/routes/password-reset.ts +++ b/src/workos/routes/password-reset.ts @@ -7,6 +7,7 @@ import { expiresIn, isExpired, findUserByEmail, + requireEmailString, } from '../helpers.js'; import { STORE_KEYS, EVENTS } from '../constants.js'; import type { EventBus } from '../event-bus.js'; @@ -23,7 +24,7 @@ export function passwordResetRoutes(ctx: RouteContext): void { app.post('/user_management/password_reset', async (c) => { const body = await parseJsonBody(c); - const email = body.email as string | undefined; + const email = requireEmailString(body.email); if (!email) { throw new WorkOSApiError(400, 'email is required', 'invalid_request'); } From 249f11a518427f39f70b38a7edda1b6eb6c9b9db Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 6 Aug 2026 13:46:14 -0400 Subject: [PATCH 08/14] fix(auth): finish resolving every email lookup case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six lookups by email still matched exactly, so the invariant the README claimed held only where Magic Auth had been touched. Each one strands the accounts Magic Auth now creates, because sign-up stores whatever case it was handed: GET /user_management/users?email= is what an SDK's listUsers({email}) maps to — the way a caller finds the account a sign-up just made. Filtering exactly, `Signup@X.test` was returned by its own casing and by nothing else. The test added for sign-up creation leans on this filter and passed only because its address is lowercase throughout. Accepting an invitation resolved the recipient exactly, so for an account stored under another case it enrolled nobody: 200, invitation.accepted emitted, invitation spent, no membership, no error. The grant path already compares the two addresses case-insensitively, so the two halves of one flow disagreed about which person an address names. SSO authentication events reported user_id: null for an account that existed. The invitations email filter matched exactly. And a seeded membership joined its user exactly, rejecting at startup a reference the running emulator would have honoured. Seeded `users` are now unique case-insensitively too, matching the 409 this branch gave POST /user_management/users: a seed was otherwise the one door left open to the pair of accounts no lookup by email can tell apart, which is the state all of this exists to prevent. --- README.md | 4 ++- src/workos/config-validator.ts | 22 ++++++++++---- src/workos/index.ts | 5 ++-- src/workos/routes/invitations.spec.ts | 33 +++++++++++++++++++++ src/workos/routes/invitations.ts | 11 +++++-- src/workos/routes/sso.spec.ts | 26 +++++++++++++++++ src/workos/routes/sso.ts | 17 ++++++++--- src/workos/routes/users.spec.ts | 19 +++++++++++++ src/workos/routes/users.ts | 6 +++- src/workos/seed-memberships.spec.ts | 41 +++++++++++++++++++++++++++ 10 files changed, 168 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index e59c6f6..27a0958 100644 --- a/README.md +++ b/README.md @@ -523,7 +523,9 @@ Redeeming a Magic Auth code sets `email_verified` to `true`, matching the live a An email is resolved case-insensitively (`User@x.test` and `user@x.test` are the same account, stored under whichever case created it), and one that could only be a typo is rejected rather than turned into an account nothing can reach. `POST /user_management/users` applies both — so it answers 409 for an address that differs from an existing one only in case, rather than creating a second account no lookup can tell apart from the first. -Every lookup by email is case-insensitive, not just Magic Auth's: the password grant, `login_hint` on the authorize endpoints, and `POST /user_management/password_reset` all resolve the same way, so an account created by a Magic Auth sign-up is reachable by whatever casing the caller has. +Every lookup by email is case-insensitive, not just Magic Auth's, so an account created by a Magic Auth sign-up is reachable by whatever casing the caller has: the password grant, `login_hint` on the authorize endpoints, `POST /user_management/password_reset`, the `email` filter on `GET /user_management/users` and `GET /user_management/invitations`, accepting an invitation, the `user_id` on SSO authentication events, and the email a seeded organization membership joins its user by. Seeded `users` are held to the same uniqueness the API enforces — two entries differing only in case are a config error, since a seed was otherwise the one way left to produce the pair of accounts no lookup can tell apart. + +A field named `email` must be a string wherever it is accepted. A number or object is a `400` (`422` on `POST /user_management/users`, which keeps that route's validation shape) naming the type, distinct from the `email is required` reported for one that is genuinely absent. Addresses are trimmed before they are stored or resolved, so a padded copy of an address finds the account written under it. ### Emitted events diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 1a1e38a..0df9edd 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -28,10 +28,16 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe const errors: ConfigValidationError[] = []; // Seeded user ids are generated at insert time, so org memberships reference users - // by email — collect the emails defined in this config for cross-referencing. + // by email — collect the emails defined in this config for cross-referencing. Lowercased, + // because every lookup by email is case-insensitive: a membership for 'a@x.test' names the + // user seeded as 'A@x.test', and resolving it any other way would reject a reference the + // running emulator then honours. const userEmails = new Set( Array.isArray(config.users) - ? config.users.map((u) => u.email).filter((e): e is string => typeof e === 'string') + ? config.users + .map((u) => u.email) + .filter((e): e is string => typeof e === 'string') + .map((e) => e.toLowerCase()) : [], ); @@ -76,18 +82,22 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe }); // Email is the lookup key org memberships join on; duplicates would silently - // bind a membership to the first match. + // bind a membership to the first match. Compared case-insensitively, matching the + // uniqueness the API enforces: `POST /user_management/users` answers 409 for an address + // differing only in case, so a seed that got two through would be the one way left to + // manufacture the pair of accounts no lookup by email can tell apart. const seenEmails = new Set(); config.users.forEach((user, index) => { if (!user.email || typeof user.email !== 'string') return; - if (seenEmails.has(user.email)) { + const normalized = user.email.toLowerCase(); + if (seenEmails.has(normalized)) { errors.push({ path: `users[${index}].email`, message: 'email must be unique across users', value: user.email, }); } - seenEmails.add(user.email); + seenEmails.add(normalized); }); // A pinned user id is the primary key in the store; two users sharing one would @@ -194,7 +204,7 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: membership.email, }); } - } else if (!userEmails.has(membership.email)) { + } else if (!userEmails.has(membership.email.toLowerCase())) { // A dangling reference would seed a membership whose embedded user // cannot resolve, which membership serialization rejects. errors.push({ diff --git a/src/workos/index.ts b/src/workos/index.ts index 91ab0d3..622c915 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -63,6 +63,7 @@ import { formatApiKeyRecord, formatFeatureFlag, generateClientId, + findUserByEmail, } from './helpers.js'; import type { WorkOSConnectionType, @@ -325,8 +326,8 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee // insert time, so an id literal in a config could never resolve, and a // dangling membership would break membership serialization (which requires // a resolvable embedded user). validateSeedConfig guarantees the reference - // matches a seeded user. - const memberUser = ws.users.findOneBy('email', mm.email); + // matches a seeded user — case-insensitively, as it does here. + const memberUser = findUserByEmail(ws, mm.email); if (!memberUser) { throw new Error(`Seed membership references unknown user '${mm.email}' (organization '${orgConfig.name}')`); } diff --git a/src/workos/routes/invitations.spec.ts b/src/workos/routes/invitations.spec.ts index cb6236e..0e9e756 100644 --- a/src/workos/routes/invitations.spec.ts +++ b/src/workos/routes/invitations.spec.ts @@ -132,6 +132,39 @@ describe('Invitation routes', () => { expect(memberships.data[0].organization_id).toBe(org.id); }); + // Resolving the recipient exactly enrolled nobody for an account stored under a different case: + // the invitation was still spent and invitation.accepted still fired, with no membership to show + // for it and no error anywhere. Magic Auth sign-up makes accounts under whatever case it was + // handed, and the authenticate flow already compares the two addresses case-insensitively. + it('accepts an invitation for an account stored under a different case', async () => { + const user = await json( + await req('/user_management/users', { method: 'POST', body: JSON.stringify({ email: 'Member@X.test' }) }), + ); + const org = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Case Org' }) })); + + const inv = await json( + await req('/user_management/invitations', { + method: 'POST', + body: JSON.stringify({ email: 'member@x.test', organization_id: org.id }), + }), + ); + const accepted = await req(`/user_management/invitations/${inv.id}/accept`, { method: 'POST' }); + expect(accepted.status).toBe(200); + expect((await json(accepted)).state).toBe('accepted'); + + const memberships = await json(await req(`/user_management/organization_memberships?organization_id=${org.id}`)); + expect(memberships.data).toHaveLength(1); + expect(memberships.data[0].user_id).toBe(user.id); + }); + + it('filters invitations by email case-insensitively', async () => { + await req('/user_management/invitations', { method: 'POST', body: JSON.stringify({ email: 'Filter@X.test' }) }); + + const list = await json(await req('/user_management/invitations?email=filter%40x.test')); + expect(list.data).toHaveLength(1); + expect(list.data[0].email).toBe('Filter@X.test'); + }); + it('revokes an invitation', async () => { const created = await json( await req('/user_management/invitations', { diff --git a/src/workos/routes/invitations.ts b/src/workos/routes/invitations.ts index 6f2252c..7bb123d 100644 --- a/src/workos/routes/invitations.ts +++ b/src/workos/routes/invitations.ts @@ -13,6 +13,8 @@ import { expiresIn, formatListResponse, acceptInvitation, + findUserByEmail, + emailsMatch, } from '../helpers.js'; import type { EventBus } from '../event-bus.js'; import { STORE_KEYS, EVENTS } from '../constants.js'; @@ -53,7 +55,8 @@ export function invitationRoutes(ctx: RouteContext): void { const result = ws.invitations.list({ ...params, filter: (inv) => { - if (emailFilter && inv.email !== emailFilter) return false; + // Case-insensitively, like every other lookup by email. + if (emailFilter && !emailsMatch(inv.email, emailFilter)) return false; if (orgFilter && inv.organization_id !== orgFilter) return false; return true; }, @@ -82,7 +85,11 @@ export function invitationRoutes(ctx: RouteContext): void { throw new WorkOSApiError(400, `Invitation is ${inv.state}`, 'invalid_invitation_state'); } - acceptInvitation(inv, ws.users.findOneBy('email', inv.email), ws, store.getData(STORE_KEYS.eventBus)); + // Case-insensitively: an exact match here enrolled nobody for an account stored under a + // different case, spending the invitation and emitting invitation.accepted with no membership + // to show for it. The authenticate flow already compares the two addresses this way, and Magic + // Auth sign-up makes accounts under whatever case the caller sent. + acceptInvitation(inv, findUserByEmail(ws, inv.email), ws, store.getData(STORE_KEYS.eventBus)); const updated = ws.invitations.get(inv.id)!; return c.json(formatInvitation(updated)); diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 7746e03..690f9e4 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -291,6 +291,32 @@ describe('SSO authentication events', () => { expect(event.data).toHaveProperty('email'); }); + // SSO is profile-based, so the event's user_id is resolved from the profile's email. Resolving it + // exactly reported user_id: null for an account that existed under a different case — and Magic + // Auth sign-up creates accounts under whatever case it was handed. + it('resolves the event user_id for an account stored under a different case', async () => { + const { conn } = await createOrgWithConnection(); + const user = await json( + await req('/user_management/users', { + method: 'POST', + body: JSON.stringify({ email: 'Federated@SSO-Events.example.com' }), + }), + ); + + const authRes = await app.request( + `/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback&login_hint=federated%40sso-events.example.com`, + ); + const code = new URL(authRes.headers.get('location')!).searchParams.get('code')!; + await app.request('/sso/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code }), + }); + + const [event] = eventsNamed('authentication.sso_succeeded'); + expect(event.data).toMatchObject({ user_id: user.id, email: 'federated@sso-events.example.com' }); + }); + it('emits authentication.sso_failed with an error object for an invalid code', async () => { const res = await app.request('/sso/token', { method: 'POST', diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index f1b6e43..f891c46 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -1,7 +1,14 @@ import type { Context } from 'hono'; import { type RouteContext, parseJsonBody, WorkOSApiError, generateId } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatSSOProfile, expiresIn, isExpired, assertLocalRedirectUri, emitAuthenticationEvent } from '../helpers.js'; +import { + formatSSOProfile, + expiresIn, + isExpired, + assertLocalRedirectUri, + emitAuthenticationEvent, + findUserByEmail, +} from '../helpers.js'; import type { WorkOSConnection } from '../entities.js'; import type { EventBus } from '../event-bus.js'; import { STORE_KEY_PREFIXES, STORE_KEYS } from '../constants.js'; @@ -172,7 +179,7 @@ export function ssoRoutes(ctx: RouteContext): void { method: 'SSO', status: 'failed', email: expiredProfile?.email, - userId: ws.users.findOneBy('email', expiredProfile?.email ?? '')?.id, + userId: findUserByEmail(ws, expiredProfile?.email ?? '')?.id, error: { code: error.code, message: error.message }, ipAddress: c.req.header('x-forwarded-for') ?? null, userAgent: c.req.header('user-agent') ?? null, @@ -200,13 +207,15 @@ export function ssoRoutes(ctx: RouteContext): void { store.setData(`${STORE_KEY_PREFIXES.ssoToken}${accessToken}`, profile.id); - // SSO is profile-based; a user-management user may not exist for this email + // SSO is profile-based; a user-management user may not exist for this email. Resolved + // case-insensitively, like every other lookup by email, so the event carries the id of an + // account stored under a different case rather than reporting none. emitAuthenticationEvent({ eventBus: store.getData(STORE_KEYS.eventBus), method: 'SSO', status: 'succeeded', email: profile.email, - userId: ws.users.findOneBy('email', profile.email)?.id ?? null, + userId: findUserByEmail(ws, profile.email)?.id ?? null, ipAddress: c.req.header('x-forwarded-for') ?? null, userAgent: c.req.header('user-agent') ?? null, sso: { diff --git a/src/workos/routes/users.spec.ts b/src/workos/routes/users.spec.ts index 0148fbe..1f1a592 100644 --- a/src/workos/routes/users.spec.ts +++ b/src/workos/routes/users.spec.ts @@ -66,6 +66,25 @@ describe('User routes', () => { expect((await json(second)).code).toBe('user_already_exists'); }); + // This is the lookup an SDK's listUsers({ email }) maps to, so it is how a caller finds the + // account a Magic Auth sign-up just made — and sign-up stores whatever case it was handed. + // Filtering exactly meant the address the caller had returned nothing for a user that existed. + it('filters by email case-insensitively', async () => { + const created = await json( + await req('/user_management/users', { method: 'POST', body: JSON.stringify({ email: 'Listed@X.test' }) }), + ); + + for (const query of ['listed%40x.test', 'Listed%40X.test', 'LISTED%40X.TEST']) { + const list = await json(await req(`/user_management/users?email=${query}`)); + expect(list.data).toHaveLength(1); + expect(list.data[0].id).toBe(created.id); + } + + // Still a filter, not a fuzzy match. + const miss = await json(await req('/user_management/users?email=listed%40y.test')); + expect(miss.data).toHaveLength(0); + }); + it('rejects duplicate email', async () => { await req('/user_management/users', { method: 'POST', diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index 6aad0ae..fb89f8c 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -14,6 +14,7 @@ import { formatListResponse, isEmailShaped, findUserByEmail, + emailsMatch, } from '../helpers.js'; export function userRoutes(ctx: RouteContext): void { @@ -84,7 +85,10 @@ export function userRoutes(ctx: RouteContext): void { const result = ws.users.list({ ...params, filter: (user) => { - if (emailFilter && user.email !== emailFilter) return false; + // Case-insensitively, like every other lookup by email. This is the lookup an SDK's + // listUsers({ email }) reaches for, so it is how a caller finds the account a Magic Auth + // sign-up just made — and that account is stored under whatever case created it. + if (emailFilter && !emailsMatch(user.email, emailFilter)) return false; if (orgUserIds && !orgUserIds.has(user.id)) return false; return true; }, diff --git a/src/workos/seed-memberships.spec.ts b/src/workos/seed-memberships.spec.ts index d687d5b..7481242 100644 --- a/src/workos/seed-memberships.spec.ts +++ b/src/workos/seed-memberships.spec.ts @@ -49,6 +49,26 @@ describe('Seeding organization memberships', () => { expect(m.user).toMatchObject({ object: 'user', id: m.user_id, email: 'admin@acme.com' }); }); + // The join resolves case-insensitively, like every other lookup by email, so a reference the + // running emulator would honour is not rejected at startup on letter case alone. + it('joins a membership to its user by any casing of the address', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: 'Admin@Acme.com' }], + organizations: [{ name: 'Acme Corp', memberships: [{ email: 'admin@acme.com', role: 'admin' }] }], + }, + }); + + const res = await fetch(`${emulator.url}/user_management/organization_memberships`, { + headers: auth(emulator.apiKey), + }); + const list = (await res.json()) as any; + expect(list.data).toHaveLength(1); + // Stored under the case that seeded it, reached by the case the membership named. + expect(list.data[0].user).toMatchObject({ email: 'Admin@Acme.com' }); + }); + it('rejects startup when a membership references an email with no seeded user', async () => { await expect( createEmulator({ @@ -81,6 +101,27 @@ describe('Seeding organization memberships', () => { expect(error.message).toContain('must match a user defined in users'); }); + it('accepts a membership email differing from its user only in case', () => { + const { valid } = validateSeedConfig({ + users: [{ email: 'Admin@Acme.com' }], + organizations: [{ name: 'Acme', memberships: [{ email: 'admin@acme.com' }] }], + }); + expect(valid).toBe(true); + }); + + it('rejects two seeded users with the same email', () => { + const error = findError({ users: [{ email: 'dup@acme.com' }, { email: 'dup@acme.com' }] }, 'users[1].email'); + expect(error.message).toContain('unique across users'); + }); + + // The uniqueness the API enforces: POST /user_management/users answers 409 for an address + // differing only in case, so a seed that got two through would be the one remaining way to + // manufacture the pair of accounts no lookup by email can tell apart. + it('rejects two seeded users whose emails differ only in case', () => { + const error = findError({ users: [{ email: 'Dup@Acme.com' }, { email: 'dup@acme.com' }] }, 'users[1].email'); + expect(error.message).toContain('unique across users'); + }); + it('rejects a membership when no users are defined at all', () => { findError( { organizations: [{ name: 'Acme', memberships: [{ email: 'admin@acme.com' }] }] }, From 5733047a59edfe5d41c69d8920b8a9e6d833abec Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:03:05 -0400 Subject: [PATCH 09/14] refactor(auth): normalize an email in one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The string guard was restated in `users.ts` rather than shared, and the copy drifted: `{email: null}` was a type error there and an absent value on the magic auth path. That is the one distinction these guards exist to draw, disagreed on by the two routes that create users. `null` is how a JSON body spells absence, so both report it that way now. Shape was a separate call at each site, which is how a route ends up holding one half of the pair — the invitations route had neither. Folding it in as opt-in keeps the read paths answering 404 for an address that resolves to nothing, rather than turning a miss into a validation error. Handing the problem back instead of throwing it is what lets one normalizer serve both error shapes: a 422 with a per-field code on the CRUD routes, a 400 `invalid_request` on the grants — which also need absence returned rather than raised, so they can still say "code and email are required" instead of naming one field at a time. --- src/workos/helpers.ts | 94 ++++++++++++++++++++++++++------- src/workos/routes/auth.spec.ts | 10 ++-- src/workos/routes/magic-auth.ts | 13 ++--- src/workos/routes/users.spec.ts | 21 ++++++++ src/workos/routes/users.ts | 16 ++---- 5 files changed, 110 insertions(+), 44 deletions(-) diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index 50debbc..b625210 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -1,5 +1,5 @@ import { randomBytes, createHash, createCipheriv } from 'node:crypto'; -import { WorkOSApiError, generateId, type CursorPaginatedResult, type Entity } from '../core/index.js'; +import { WorkOSApiError, validationError, generateId, type CursorPaginatedResult, type Entity } from '../core/index.js'; import { EVENTS, type AuthenticationEventData, type WorkOSEventName } from './constants.js'; import type { WorkOSStore } from './store.js'; import type { EventBus } from './event-bus.js'; @@ -344,30 +344,85 @@ export function isEmailShaped(value: string): boolean { return at > 0 && at === value.lastIndexOf('@') && at < value.length - 1 && !/\s/.test(value); } +/** Why a supplied `email` cannot be used, kept apart so a caller can report which one happened. */ +export type EmailProblem = 'missing' | 'not_a_string' | 'malformed'; + +const EMAIL_PROBLEM_MESSAGES: Record = { + missing: 'email is required', + not_a_string: 'email must be a string', + malformed: 'email must be a valid email address', +}; + +/** The `errors[].code` each problem carries on the routes that report a 422. */ +const EMAIL_PROBLEM_FIELD_CODES: Record = { + missing: 'required', + not_a_string: 'invalid_type', + malformed: 'invalid', +}; + +export type NormalizedEmail = { ok: true; email: string } | { ok: false; problem: EmailProblem }; + /** - * Narrow a request's `email` to a trimmed string. Absence is handed back as `''` for the caller to - * report its own way; a value that is present but not a string is rejected here, because the two - * have the same fix only if the caller is told which one happened. + * Normalize a request's `email` to a trimmed string, or say why it can't be. Not every value + * handed to this is normalizable, so the problem comes back as a value rather than an exception: + * the routes disagree about how to report it — `validationError`'s 422 on the user-management + * CRUD routes, a 400 `invalid_request` on the grants — and only about that. + * + * Callers used to type-assert instead, which was survivable while a lookup by email could only + * miss — `findOneBy` returns undefined for a number as readily as for an unknown address. + * Resolving case-insensitively means calling `toLowerCase` on it, so the same assertion throws and + * a malformed request comes back a 500 that tells the caller nothing. * - * Every caller used to type-assert instead, which was survivable while a lookup by email could - * only miss — `findOneBy` returns undefined for a number as readily as for an unknown address. - * Resolving case-insensitively means calling `toLowerCase` on it, so the same assertion now throws - * and a malformed request comes back a 500 that tells the caller nothing. + * `null` is `missing`, not `not_a_string`: in a JSON body it is how a caller spells absence, and + * the guard exists to name what the caller must fix, not what `typeof` says. * - * Trimmed here too: creation stores the trimmed address, so a read that skipped the trim could not - * find what creation had just written. + * `requireShape` is for the routes that create something from the address rather than look one up. + * A read that misses is a 404 the caller can act on; a write that stores a typo is an account or + * an invitation nothing can ever reach. Read paths leave it off, so an address that does not + * resolve still 404s rather than changing error shape. */ -export function requireEmailString(value: unknown): string { - if (value === undefined || value === null) return ''; - if (typeof value !== 'string') { - throw new WorkOSApiError(400, 'email must be a string', 'invalid_request'); - } - return value.trim(); +export function normalizeEmail(value: unknown, opts?: { requireShape?: boolean }): NormalizedEmail { + if (value === undefined || value === null) return { ok: false, problem: 'missing' }; + if (typeof value !== 'string') return { ok: false, problem: 'not_a_string' }; + const email = value.trim(); + if (!email) return { ok: false, problem: 'missing' }; + if (opts?.requireShape && !isEmailShaped(email)) return { ok: false, problem: 'malformed' }; + return { ok: true, email }; } -/** Whether two addresses name the same account. Case-insensitive, like every lookup by email. */ +/** + * The trimmed `email` from a request body, as a route that reports 400 `invalid_request` wants it. + * + * Absence comes back as `''` rather than throwing, because the grants name it alongside whatever + * else they also require ("code and email are required") — a message that is more use than one + * field at a time. + */ +export function requireEmailString(value: unknown, opts?: { requireShape?: boolean }): string { + const result = normalizeEmail(value, opts); + if (result.ok) return result.email; + if (result.problem === 'missing') return ''; + throw new WorkOSApiError(400, EMAIL_PROBLEM_MESSAGES[result.problem], 'invalid_request'); +} + +/** + * The trimmed `email` from a request body, as the user-management CRUD routes want it: a 422 with + * the per-field code, which is the validation shape those routes already answer in. + */ +export function requireEmailField(value: unknown, opts?: { requireShape?: boolean }): string { + const result = normalizeEmail(value, opts); + if (result.ok) return result.email; + throw validationError(EMAIL_PROBLEM_MESSAGES[result.problem], [ + { field: 'email', code: EMAIL_PROBLEM_FIELD_CODES[result.problem] }, + ]); +} + +/** + * Whether two addresses name the same account. Case-insensitive, like every lookup by email, and + * trimmed for the same reason storage is: a padded copy of an address names the same person, and a + * filter that skipped the trim would not return what creation had just written. + */ export function emailsMatch(a: string, b: string): boolean { - return a.toLowerCase() === b.toLowerCase(); + return a.trim().toLowerCase() === b.trim().toLowerCase(); } /** @@ -377,8 +432,7 @@ export function emailsMatch(a: string, b: string): boolean { export function findUserByEmail(ws: WorkOSStore, email: string): WorkOSUser | undefined { const exact = ws.users.findOneBy('email', email); if (exact) return exact; - const normalized = email.toLowerCase(); - return ws.users.all().find((u) => u.email.toLowerCase() === normalized); + return ws.users.all().find((u) => emailsMatch(u.email, email)); } /** diff --git a/src/workos/routes/auth.spec.ts b/src/workos/routes/auth.spec.ts index edb63bf..fb4256f 100644 --- a/src/workos/routes/auth.spec.ts +++ b/src/workos/routes/auth.spec.ts @@ -609,10 +609,14 @@ describe('Auth routes', () => { expect(getWorkOSStore(store).users.all()).toHaveLength(0); }); - // Absent and malformed have the same fix only if the caller is told which one happened. + // Absent and malformed have the same fix only if the caller is told which one happened. `null` + // counts as absent — it is how a JSON body spells it, and both creation paths agree on that. it('distinguishes a missing email from an unusable one', async () => { - const missing = await req('/user_management/magic_auth', { method: 'POST', body: JSON.stringify({}) }); - expect((await json(missing)).message).toBe('email is required'); + for (const body of [{}, { email: null }]) { + const missing = await req('/user_management/magic_auth', { method: 'POST', body: JSON.stringify(body) }); + expect(missing.status).toBe(400); + expect((await json(missing)).message).toBe('email is required'); + } const malformed = await req('/user_management/magic_auth', { method: 'POST', diff --git a/src/workos/routes/magic-auth.ts b/src/workos/routes/magic-auth.ts index a5258b2..22fb6e4 100644 --- a/src/workos/routes/magic-auth.ts +++ b/src/workos/routes/magic-auth.ts @@ -5,7 +5,6 @@ import { generateCode, expiresIn, findUserByEmail, - isEmailShaped, requireEmailString, } from '../helpers.js'; @@ -23,17 +22,13 @@ export function magicAuthRoutes(ctx: RouteContext): void { const body = await parseJsonBody(c); // This handler now creates users, so its input guard is the only thing standing between a // typo and a permanent ghost account. A bare presence check was enough when the endpoint - // could only ever read. - const email = requireEmailString(body.email); + // could only ever read. A malformed address is reported apart from an absent one — the two + // have the same fix only if the caller is told which one happened, and "email is required" + // describes an address that was in fact supplied exactly backwards. + const email = requireEmailString(body.email, { requireShape: true }); if (!email) { throw new WorkOSApiError(400, 'email is required', 'invalid_request'); } - // Reported apart from absence: the two have the same fix only if the caller is told which - // one happened, and "email is required" describes an address that was in fact supplied - // exactly backwards. - if (!isEmailShaped(email)) { - throw new WorkOSApiError(400, 'email must be a valid email address', 'invalid_request'); - } // Magic Auth doubles as sign-up: production creates the user at code-creation // time (the response already carries its user_id), not at authenticate. diff --git a/src/workos/routes/users.spec.ts b/src/workos/routes/users.spec.ts index 1f1a592..dc4489d 100644 --- a/src/workos/routes/users.spec.ts +++ b/src/workos/routes/users.spec.ts @@ -48,6 +48,27 @@ describe('User routes', () => { expect(list.data).toHaveLength(0); }); + // `null` is how a JSON body spells absence, so it is reported as absence — the same answer the + // magic auth handler gives it. Classifying it as a type error instead had the two creation paths + // disagreeing about which of the two distinctions this route exists to draw it falls on. + it('reports an absent email as absent, including an explicit null', async () => { + for (const body of [{}, { email: null }]) { + const res = await req('/user_management/users', { method: 'POST', body: JSON.stringify(body) }); + expect(res.status).toBe(422); + const parsed = await json(res); + expect(parsed.message).toBe('email is required'); + expect(parsed.errors[0]).toMatchObject({ field: 'email', code: 'required' }); + } + }); + + it('names a non-string email as the wrong type, not as missing', async () => { + const res = await req('/user_management/users', { method: 'POST', body: JSON.stringify({ email: 123 }) }); + expect(res.status).toBe(422); + const body = await json(res); + expect(body.message).toBe('email must be a string'); + expect(body.errors[0]).toMatchObject({ field: 'email', code: 'invalid_type' }); + }); + // Case-insensitively, like the magic auth handler: two accounts differing only in case left the // two creation paths disagreeing about which one an address names, with magic auth's resolver // settling it by insertion order. diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index fb89f8c..aac86f2 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -12,9 +12,9 @@ import { formatIdentity, hashPassword, formatListResponse, - isEmailShaped, findUserByEmail, emailsMatch, + requireEmailField, } from '../helpers.js'; export function userRoutes(ctx: RouteContext): void { @@ -23,20 +23,12 @@ export function userRoutes(ctx: RouteContext): void { app.post('/user_management/users', async (c) => { const body = await parseJsonBody(c); - if (body.email !== undefined && typeof body.email !== 'string') { - throw validationError('email must be a string', [{ field: 'email', code: 'invalid_type' }]); - } - const email = body.email?.trim(); - if (!email) { - throw validationError('email is required', [{ field: 'email', code: 'required' }]); - } // The same guard the magic auth handler applies, for the same reason: this route creates // users, and an address that could only be a typo becomes an account nothing can reach. // Holding the two paths to one standard is what stops `{email: 'nope'}` being a 422 on one - // and a 201 on the other. - if (!isEmailShaped(email)) { - throw validationError('email must be a valid email address', [{ field: 'email', code: 'invalid' }]); - } + // and a 201 on the other — shared rather than restated, since a second copy is how the two + // drifted over `null` in the first place. + const email = requireEmailField(body.email, { requireShape: true }); // Case-insensitively, for the same reason the magic auth handler resolves that way: an // exact-match miss on 'User@x.test' vs 'user@x.test' let both be created, and then the two From 9518861d12337d4700cb2d87766d24ecbbeea241 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:03:14 -0400 Subject: [PATCH 10/14] fix(invitations): reject an email nothing can read back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was the one route accepting an email that the sweep to resolve them case-insensitively did not also guard. A non-string was survivable while every consumer of a stored address compared it with `!==`; `toLowerCase` throws, so listing invitations by email and accepting one both came back `server_error` — which in a consumer's suite reads as a defect in the emulator rather than a request it should never have accepted. The route answered 201 to the request that caused it. The typo guard comes with it, for the reason the user-creating routes have one. Acceptance resolves the recipient by this address, so an invitation to a malformed one is accepted, emits invitation.accepted, spends the invitation, and enrolls nobody — the failure this route's lookup was just fixed to stop, reachable again through the address the invitation was created with. --- src/workos/routes/invitations.spec.ts | 55 +++++++++++++++++++++++++++ src/workos/routes/invitations.ts | 21 +++++----- 2 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/workos/routes/invitations.spec.ts b/src/workos/routes/invitations.spec.ts index 0e9e756..6e93e92 100644 --- a/src/workos/routes/invitations.spec.ts +++ b/src/workos/routes/invitations.spec.ts @@ -165,6 +165,61 @@ describe('Invitation routes', () => { expect(list.data[0].email).toBe('Filter@X.test'); }); + // A non-string was survivable while every consumer of a stored email compared it with `!==`. + // Resolving the recipient case-insensitively means calling `toLowerCase` on it, so accepting a + // number here turned both the email filter and accepting the invitation into a 500 — a + // `server_error` in a consumer's suite reads as an emulator defect rather than a bad request. + it('rejects a non-string email rather than storing one nothing can read back', async () => { + for (const email of [123, { not: 'a string' }, ['a@x.test']]) { + const res = await req('/user_management/invitations', { method: 'POST', body: JSON.stringify({ email }) }); + expect(res.status).toBe(422); + const body = await json(res); + expect(body.code).toBe('unprocessable_entity'); + expect(body.message).toBe('email must be a string'); + } + + // The two reads that would have 500d on a stored non-string. + expect((await req('/user_management/invitations?email=a%40x.test')).status).toBe(200); + expect((await json(await req('/user_management/invitations'))).data).toHaveLength(0); + }); + + // Acceptance resolves the recipient by this address, so a typo is spent silently: 200, the + // invitation marked accepted, invitation.accepted emitted, and nobody enrolled. Held to the same + // standard as the two routes that create users, which reject for the same reason. + it('rejects an email that could only be a typo', async () => { + for (const email of ['', ' ', 'not-an-email', 'a b@test.com', '@test.com', 'nope@', 'two@at@test.com']) { + const res = await req('/user_management/invitations', { method: 'POST', body: JSON.stringify({ email }) }); + expect(res.status).toBe(422); + expect((await json(res)).message).toMatch(/email (is required|must be a valid email address)/); + } + expect((await json(await req('/user_management/invitations'))).data).toHaveLength(0); + }); + + // Absent and malformed have the same fix only if the caller is told which one happened, and + // `null` in a JSON body is how a caller spells absence. + it('reports an absent email as absent, including an explicit null', async () => { + for (const body of [{}, { email: null }]) { + const res = await req('/user_management/invitations', { method: 'POST', body: JSON.stringify(body) }); + expect(res.status).toBe(422); + const parsed = await json(res); + expect(parsed.message).toBe('email is required'); + expect(parsed.errors[0]).toMatchObject({ field: 'email', code: 'required' }); + } + }); + + it('trims a padded address before storing it', async () => { + const inv = await json( + await req('/user_management/invitations', { + method: 'POST', + body: JSON.stringify({ email: ' padded@x.test ' }), + }), + ); + expect(inv.email).toBe('padded@x.test'); + + const list = await json(await req('/user_management/invitations?email=padded%40x.test')); + expect(list.data).toHaveLength(1); + }); + it('revokes an invitation', async () => { const created = await json( await req('/user_management/invitations', { diff --git a/src/workos/routes/invitations.ts b/src/workos/routes/invitations.ts index 7bb123d..1b9a188 100644 --- a/src/workos/routes/invitations.ts +++ b/src/workos/routes/invitations.ts @@ -1,11 +1,4 @@ -import { - type RouteContext, - notFound, - validationError, - parseJsonBody, - WorkOSApiError, - parseListParams, -} from '../../core/index.js'; +import { type RouteContext, notFound, parseJsonBody, WorkOSApiError, parseListParams } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatInvitation, @@ -15,6 +8,7 @@ import { acceptInvitation, findUserByEmail, emailsMatch, + requireEmailField, } from '../helpers.js'; import type { EventBus } from '../event-bus.js'; import { STORE_KEYS, EVENTS } from '../constants.js'; @@ -25,10 +19,13 @@ export function invitationRoutes(ctx: RouteContext): void { app.post('/user_management/invitations', async (c) => { const body = await parseJsonBody(c); - const email = body.email as string | undefined; - if (!email) { - throw validationError('email is required', [{ field: 'email', code: 'required' }]); - } + // The same guard the two user-creation routes apply, for a related reason. Accepting a + // non-string here used to be survivable because everything downstream compared the address + // with `!==`; resolving the recipient case-insensitively means calling `toLowerCase` on it, + // so a stored number turned both the email filter and accepting the invitation into a 500. + // And an address that could only be a typo makes an invitation nobody can accept: acceptance + // resolves a user by this email, so a typo is spent silently, enrolling no one. + const email = requireEmailField(body.email, { requireShape: true }); const token = generateVerificationToken(); const inv = ws.invitations.insert({ From 4c573d0110cab492b94d48996f911e5c789f408a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:03:14 -0400 Subject: [PATCH 11/14] fix(seed): hold seeded emails to what the routes enforce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seed is the one creation path with no route in front of it, so whatever the routes reject it could still write. A malformed address became a user no lookup by email resolves — the state those guards exist to prevent, produced through the door left open, which is the same argument that made seeded emails unique case-insensitively. Padding was the other half. Seeding stored the raw value while both routes store it trimmed, so `' a@x.test '` was written under a spelling nothing resolves, and a membership naming the unpadded address was rejected at startup for a reference the running emulator would then have honoured. Validation normalizes the way the store does, so what it cross-references is what lands. --- src/workos/config-validator.ts | 65 ++++++++++++++++++++++------- src/workos/index.ts | 7 +++- src/workos/seed-memberships.spec.ts | 57 +++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 0df9edd..e1d1f23 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -3,6 +3,20 @@ */ import type { WorkOSSeedConfig } from './index.js'; import { validateJwtTemplateContent } from './jwt-template.js'; +import { normalizeEmail, type NormalizedEmail } from './helpers.js'; + +/** + * A seed is the one creation path that does not go through a route, so it is held to what the + * routes enforce: an address is trimmed and is shaped like an address. Anything looser and a seed + * is the remaining way to write a user under a spelling no lookup by email resolves — which is the + * state all of this exists to prevent. + * + * Returns the stored form, or the problem for the caller to word in its own terms: each site + * already says something more specific than "email" about what the address is for. + */ +function seedEmail(value: unknown): NormalizedEmail { + return normalizeEmail(value, { requireShape: true }); +} /** * A pinned id is addressed as a single path segment (`/organizations/:id`, @@ -28,16 +42,17 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe const errors: ConfigValidationError[] = []; // Seeded user ids are generated at insert time, so org memberships reference users - // by email — collect the emails defined in this config for cross-referencing. Lowercased, - // because every lookup by email is case-insensitive: a membership for 'a@x.test' names the - // user seeded as 'A@x.test', and resolving it any other way would reject a reference the - // running emulator then honours. + // by email — collect the emails defined in this config for cross-referencing. Normalized the way + // the store resolves them: lowercased, because every lookup by email is case-insensitive, and + // trimmed, because that is the form seeding writes. A membership for 'a@x.test' names the user + // seeded as ' A@x.test ', and resolving it any other way would reject a reference the running + // emulator then honours. const userEmails = new Set( Array.isArray(config.users) ? config.users - .map((u) => u.email) - .filter((e): e is string => typeof e === 'string') - .map((e) => e.toLowerCase()) + .map((u) => seedEmail(u.email)) + .filter((r): r is { ok: true; email: string } => r.ok) + .map((r) => r.email.toLowerCase()) : [], ); @@ -51,10 +66,17 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe }); } else { config.users.forEach((user, index) => { - if (!user.email || typeof user.email !== 'string') { + const email = seedEmail(user.email); + if (!email.ok) { errors.push({ path: `users[${index}].email`, - message: 'email is required and must be a string', + message: + email.problem === 'malformed' + ? // Same standard as the two routes that create users: an address that could only + // be a typo becomes an account nothing can reach, and a seed is the one creation + // path with no route in front of it to say so. + 'email must be a valid email address' + : 'email is required and must be a string', value: user.email, }); } @@ -88,8 +110,9 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe // manufacture the pair of accounts no lookup by email can tell apart. const seenEmails = new Set(); config.users.forEach((user, index) => { - if (!user.email || typeof user.email !== 'string') return; - const normalized = user.email.toLowerCase(); + const email = seedEmail(user.email); + if (!email.ok) return; + const normalized = email.email.toLowerCase(); if (seenEmails.has(normalized)) { errors.push({ path: `users[${index}].email`, @@ -189,7 +212,8 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe // The pre-rename key: it read as "pass a user_... id", which can never // resolve (ids are generated at startup) — point at `email` instead. const legacyUserId = (membership as { user_id?: unknown }).user_id; - if (!membership.email || typeof membership.email !== 'string') { + const memberEmail = seedEmail(membership.email); + if (!memberEmail.ok) { if (legacyUserId !== undefined) { errors.push({ path: `organizations[${index}].memberships[${mIndex}].user_id`, @@ -200,11 +224,14 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe } else { errors.push({ path: `organizations[${index}].memberships[${mIndex}].email`, - message: 'email is required and must be the email of a user defined in users', + message: + memberEmail.problem === 'malformed' + ? 'email must be a valid email address' + : 'email is required and must be the email of a user defined in users', value: membership.email, }); } - } else if (!userEmails.has(membership.email.toLowerCase())) { + } else if (!userEmails.has(memberEmail.email.toLowerCase())) { // A dangling reference would seed a membership whose embedded user // cannot resolve, which membership serialization rejects. errors.push({ @@ -404,10 +431,16 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe }); } else { config.invitations.forEach((inv, index) => { - if (!inv.email || typeof inv.email !== 'string') { + const email = seedEmail(inv.email); + if (!email.ok) { errors.push({ path: `invitations[${index}].email`, - message: 'email is required and must be a string', + message: + email.problem === 'malformed' + ? // As POST /user_management/invitations now answers: acceptance resolves the + // recipient by this address, so a typo is an invitation that enrolls nobody. + 'email must be a valid email address' + : 'email is required and must be a string', value: inv.email, }); } diff --git a/src/workos/index.ts b/src/workos/index.ts index 622c915..3547645 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -276,7 +276,10 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee ws.users.insert({ object: 'user', id: userConfig.id, - email: userConfig.email, + // Trimmed, as both routes that create users store it: a padded seed would otherwise be + // written under a spelling no lookup by email resolves. validateSeedConfig normalizes the + // same way, so what it cross-referenced is what lands here. + email: userConfig.email.trim(), name: userConfig.name ?? null, first_name: userConfig.first_name ?? null, last_name: userConfig.last_name ?? null, @@ -437,7 +440,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee const token = generateVerificationToken(); ws.invitations.insert({ object: 'invitation', - email: invConfig.email, + email: invConfig.email.trim(), state: 'pending', token, accept_invitation_url: `${_baseUrl}/user_management/invitations/accept?token=${token}`, diff --git a/src/workos/seed-memberships.spec.ts b/src/workos/seed-memberships.spec.ts index 7481242..72dd76f 100644 --- a/src/workos/seed-memberships.spec.ts +++ b/src/workos/seed-memberships.spec.ts @@ -69,6 +69,25 @@ describe('Seeding organization memberships', () => { expect(list.data[0].user).toMatchObject({ email: 'Admin@Acme.com' }); }); + // Seeding stores the trimmed address, so a padded seed is reachable by the address the caller + // actually has — and the membership that named it joins the same account. + it('stores a padded seeded address trimmed', async () => { + emulator = await createEmulator({ + port: 0, + seed: { + users: [{ email: ' padded@acme.com ' }], + organizations: [{ name: 'Acme Corp', memberships: [{ email: 'padded@acme.com', role: 'member' }] }], + }, + }); + + const res = await fetch(`${emulator.url}/user_management/users?email=padded%40acme.com`, { + headers: auth(emulator.apiKey), + }); + const list = (await res.json()) as any; + expect(list.data).toHaveLength(1); + expect(list.data[0].email).toBe('padded@acme.com'); + }); + it('rejects startup when a membership references an email with no seeded user', async () => { await expect( createEmulator({ @@ -122,6 +141,44 @@ describe('Seeding organization memberships', () => { expect(error.message).toContain('unique across users'); }); + // A seed is the one creation path with no route in front of it, so it is the remaining way to + // write a user under an address no lookup by email resolves — the state the two routes' typo + // guards exist to prevent. + it('rejects a seeded user email that could only be a typo', () => { + for (const email of [' ', 'not-an-email', 'a b@acme.com', '@acme.com', 'nope@', 'two@at@acme.com']) { + const error = findError({ users: [{ email }] }, 'users[0].email'); + expect(error.message).toMatch(/email (is required and must be a string|must be a valid email address)/); + } + }); + + it('rejects a seeded invitation email that could only be a typo', () => { + const error = findError({ invitations: [{ email: 'not-an-email' }] }, 'invitations[0].email'); + expect(error.message).toContain('must be a valid email address'); + }); + + it('rejects a membership email that could only be a typo', () => { + const error = findError( + { users: [{ email: 'admin@acme.com' }], organizations: [{ name: 'Acme', memberships: [{ email: 'nope' }] }] }, + 'organizations[0].memberships[0].email', + ); + expect(error.message).toContain('must be a valid email address'); + }); + + // Seeding trims, so the cross-reference has to: matching the raw value would reject a + // membership that resolves fine once both addresses are stored the way the store stores them. + it('accepts a membership email padded differently from its user', () => { + const { valid } = validateSeedConfig({ + users: [{ email: ' admin@acme.com' }], + organizations: [{ name: 'Acme', memberships: [{ email: 'admin@acme.com ' }] }], + }); + expect(valid).toBe(true); + }); + + it('rejects two seeded users whose emails differ only in padding', () => { + const error = findError({ users: [{ email: 'dup@acme.com' }, { email: ' dup@acme.com ' }] }, 'users[1].email'); + expect(error.message).toContain('unique across users'); + }); + it('rejects a membership when no users are defined at all', () => { findError( { organizations: [{ name: 'Acme', memberships: [{ email: 'admin@acme.com' }] }] }, From ab2e3300b97165aee60a14fc0ae6ad43207ef976 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:03:14 -0400 Subject: [PATCH 12/14] fix(sso): resolve a login_hint's profile like every other email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last lookup by email still matching exactly, so a `login_hint` differing only in case minted a second profile for the same federated person — the pair of records no lookup by email can tell apart, in profile form. Matching on the connection at the same time as the address rather than after closes a second leak in the same line: `findOneBy` returns the first profile for an address whatever connection it belongs to, so a second connection never matched its own profile and inserted another one on every authorize. --- src/workos/routes/sso.spec.ts | 50 +++++++++++++++++++++++++++++++++++ src/workos/routes/sso.ts | 10 +++++-- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 690f9e4..b29803f 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -14,10 +14,12 @@ function createTestApp() { describe('SSO routes', () => { let app: ReturnType['app']; + let store: Store; beforeEach(() => { const server = createTestApp(); app = server.app; + store = server.store; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -57,6 +59,54 @@ describe('SSO routes', () => { expect(url.searchParams.get('state')).toBe('abc'); }); + // The last exact-match lookup by email. A login_hint differing only in case is the same + // federated person, so it reuses the profile rather than minting a second one for the same + // connection — which is the pair of records no lookup by email can tell apart, in profile form. + it('reuses one profile across casings of the same login_hint', async () => { + const { conn } = await createOrgWithConnection(); + + for (const hint of ['Person%40sso.example.com', 'person%40sso.example.com', 'PERSON%40SSO.EXAMPLE.COM']) { + const res = await app.request( + `/sso/authorize?connection=${conn.id}&redirect_uri=http://localhost:3000/callback&login_hint=${hint}`, + ); + expect(res.status).toBe(302); + } + + const profiles = getWorkOSStore(store).ssoProfiles.all(); + expect(profiles).toHaveLength(1); + // Stored as first given, like every other address the emulator writes. + expect(profiles[0].email).toBe('Person@sso.example.com'); + }); + + // Matching on the connection at the same time as the email, not after: `findOneBy` returned the + // first profile for the address whatever connection it belonged to, so the second connection + // never matched its own profile and minted another on every authorize. + it('keeps one profile per connection for the same address', async () => { + const { conn } = await createOrgWithConnection(); + const org2 = await json(await req('/organizations', { method: 'POST', body: JSON.stringify({ name: 'Other' }) })); + const conn2 = await json( + await req('/connections', { + method: 'POST', + body: JSON.stringify({ + name: 'Other SSO', + organization_id: org2.id, + connection_type: 'GenericSAML', + domains: ['sso.example.com'], + }), + }), + ); + + for (const id of [conn.id, conn2.id, conn.id, conn2.id]) { + await app.request( + `/sso/authorize?connection=${id}&redirect_uri=http://localhost:3000/callback&login_hint=shared%40sso.example.com`, + ); + } + + const profiles = getWorkOSStore(store).ssoProfiles.all(); + expect(profiles).toHaveLength(2); + expect(new Set(profiles.map((p) => p.connection_id))).toEqual(new Set([conn.id, conn2.id])); + }); + it('sso token exchange returns profile and access_token', async () => { const { conn } = await createOrgWithConnection(); diff --git a/src/workos/routes/sso.ts b/src/workos/routes/sso.ts index f891c46..d8b3661 100644 --- a/src/workos/routes/sso.ts +++ b/src/workos/routes/sso.ts @@ -8,6 +8,7 @@ import { assertLocalRedirectUri, emitAuthenticationEvent, findUserByEmail, + emailsMatch, } from '../helpers.js'; import type { WorkOSConnection } from '../entities.js'; import type { EventBus } from '../event-bus.js'; @@ -49,8 +50,13 @@ export function ssoRoutes(ctx: RouteContext): void { } const email = loginHint ?? `user@${connection.domains[0]?.domain ?? 'example.com'}`; - let profile = ws.ssoProfiles.findOneBy('email', email); - if (!profile || profile.connection_id !== connection.id) { + // The last exact-match lookup by email, matched on the connection at the same time rather + // than after. `findOneBy` returns the first profile for the address whatever connection it + // belongs to, so a second connection never matched its own profile and minted another on + // every authorize. Case-insensitive for the reason the rest are: a login_hint differing only + // in case is the same federated person. + let profile = ws.ssoProfiles.all().find((p) => p.connection_id === connection.id && emailsMatch(p.email, email)); + if (!profile) { profile = ws.ssoProfiles.insert({ object: 'profile', connection_id: connection.id, From 7bfc95343eb1236b8daf4878c7eb191cada1ab2e Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 13:03:14 -0400 Subject: [PATCH 13/14] docs: document the guards every email now passes through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are consumer-visible: a suite that seeds a placeholder address, invites one, or reads `{email: null}` as a type error gets a different answer than it used to. Worth stating where the guards apply and, as much, where they deliberately do not — a read that resolves to nothing is still a 404 rather than a validation error. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 27a0958..76ef316 100644 --- a/README.md +++ b/README.md @@ -523,9 +523,11 @@ Redeeming a Magic Auth code sets `email_verified` to `true`, matching the live a An email is resolved case-insensitively (`User@x.test` and `user@x.test` are the same account, stored under whichever case created it), and one that could only be a typo is rejected rather than turned into an account nothing can reach. `POST /user_management/users` applies both — so it answers 409 for an address that differs from an existing one only in case, rather than creating a second account no lookup can tell apart from the first. -Every lookup by email is case-insensitive, not just Magic Auth's, so an account created by a Magic Auth sign-up is reachable by whatever casing the caller has: the password grant, `login_hint` on the authorize endpoints, `POST /user_management/password_reset`, the `email` filter on `GET /user_management/users` and `GET /user_management/invitations`, accepting an invitation, the `user_id` on SSO authentication events, and the email a seeded organization membership joins its user by. Seeded `users` are held to the same uniqueness the API enforces — two entries differing only in case are a config error, since a seed was otherwise the one way left to produce the pair of accounts no lookup can tell apart. +Every lookup by email is case-insensitive, not just Magic Auth's, so an account created by a Magic Auth sign-up is reachable by whatever casing the caller has: the password grant, `login_hint` on the authorize endpoints, `POST /user_management/password_reset`, the `email` filter on `GET /user_management/users` and `GET /user_management/invitations`, accepting an invitation, the `user_id` on SSO authentication events, the profile `/sso/authorize` resolves from a `login_hint`, and the email a seeded organization membership joins its user by. Seeded `users` are held to the same uniqueness the API enforces — two entries differing only in case are a config error, since a seed was otherwise the one way left to produce the pair of accounts no lookup can tell apart. -A field named `email` must be a string wherever it is accepted. A number or object is a `400` (`422` on `POST /user_management/users`, which keeps that route's validation shape) naming the type, distinct from the `email is required` reported for one that is genuinely absent. Addresses are trimmed before they are stored or resolved, so a padded copy of an address finds the account written under it. +A field named `email` must be a string wherever it is accepted. A number or object is a `400` (`422` on `POST /user_management/users` and `POST /user_management/invitations`, which keep those routes' validation shape) naming the type, distinct from the `email is required` reported for one that is genuinely absent — which includes an explicit `null`, since that is how a JSON body spells absence. Addresses are trimmed before they are stored or compared, so a padded copy of an address finds the account written under it. + +The typo guard applies wherever an address is written rather than looked up: both routes that create users, `POST /user_management/invitations` (acceptance resolves the recipient by email, so a typo is an invitation that is spent without enrolling anyone), and seeded `users`, `invitations`, and organization `memberships`. Read paths are left alone — an address that resolves to nothing is still a `404` you can act on, not a validation error. ### Emitted events From c04f13c08ea9b9e1a5137f24102359a05a87b3b8 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 7 Aug 2026 14:14:20 -0400 Subject: [PATCH 14/14] style: collapse the magic auth import oxfmt wants inlined --- src/workos/routes/magic-auth.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/workos/routes/magic-auth.ts b/src/workos/routes/magic-auth.ts index 22fb6e4..d752010 100644 --- a/src/workos/routes/magic-auth.ts +++ b/src/workos/routes/magic-auth.ts @@ -1,12 +1,6 @@ import { type RouteContext, notFound, parseJsonBody, WorkOSApiError } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { - formatMagicAuth, - generateCode, - expiresIn, - findUserByEmail, - requireEmailString, -} from '../helpers.js'; +import { formatMagicAuth, generateCode, expiresIn, findUserByEmail, requireEmailString } from '../helpers.js'; export function magicAuthRoutes(ctx: RouteContext): void { const { app, store } = ctx;