Skip to content
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,18 @@ 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.

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. `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.

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

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).
Expand Down
22 changes: 16 additions & 6 deletions src/workos/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
: [],
);

Expand Down Expand Up @@ -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<string>();
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
Expand Down Expand Up @@ -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({
Expand Down
46 changes: 46 additions & 0 deletions src/workos/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,52 @@ 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);
}

/**
* 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.
*/
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.
Expand Down
5 changes: 3 additions & 2 deletions src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
formatApiKeyRecord,
formatFeatureFlag,
generateClientId,
findUserByEmail,
} from './helpers.js';
import type {
WorkOSConnectionType,
Expand Down Expand Up @@ -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}')`);
}
Expand Down
203 changes: 203 additions & 0 deletions src/workos/routes/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' } };
Expand Down Expand Up @@ -462,6 +463,208 @@ 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');
// 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 () => {
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);
});

// 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<string, unknown> }) => 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', {
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');

// 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 () => {
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);
});

// 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;

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);
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 () => {
Expand Down
Loading