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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,20 @@ The emulator issues a new refresh token on every refresh and invalidates the one

`/sso/token` is OAuth-shaped throughout, matching its spec definition.

### 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, 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` 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

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
69 changes: 56 additions & 13 deletions src/workos/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -28,10 +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.
// 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')
? config.users
.map((u) => seedEmail(u.email))
.filter((r): r is { ok: true; email: string } => r.ok)
.map((r) => r.email.toLowerCase())
: [],
);

Expand All @@ -45,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,
});
}
Expand Down Expand Up @@ -76,18 +104,23 @@ 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 email = seedEmail(user.email);
if (!email.ok) return;
const normalized = email.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 @@ -179,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`,
Expand All @@ -190,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)) {
} 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({
Expand Down Expand Up @@ -394,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,
});
}
Expand Down
109 changes: 108 additions & 1 deletion src/workos/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { randomBytes, createHash, createCipheriv } from 'node:crypto';
import { isIPv6 } from 'node:net';
import { domainToASCII } from 'node:url';
import { WorkOSApiError, generateId, type CursorPaginatedResult, type Entity, type Store } from '../core/index.js';
import {
WorkOSApiError,
validationError,
generateId,
type CursorPaginatedResult,
type Entity,
type Store,
} from '../core/index.js';
import { EVENTS, STORE_KEYS, type AuthenticationEventData, type WorkOSEventName } from './constants.js';
import type { WorkOSStore } from './store.js';
import type { EventBus } from './event-bus.js';
Expand Down Expand Up @@ -337,6 +344,106 @@ 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);
}

/** 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<EmailProblem, string> = {
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<EmailProblem, string> = {
missing: 'required',
not_a_string: 'invalid_type',
malformed: 'invalid',
};

export type NormalizedEmail = { ok: true; email: string } | { ok: false; problem: EmailProblem };

/**
* 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.
*
* `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.
*
* `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 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 };
}

/**
* 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.trim().toLowerCase() === b.trim().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;
return ws.users.all().find((u) => emailsMatch(u.email, email));
}

/**
* Hash password using SHA256.
* NOTE: This is intentionally weak for emulator/testing only.
Expand Down
12 changes: 8 additions & 4 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 @@ -275,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,
Expand Down Expand Up @@ -325,8 +329,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 Expand Up @@ -436,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}`,
Expand Down
Loading
Loading