Enter the email address you want to link. We'll send a verification email to confirm ownership.
Existing accounts can't be combined through this form and require support assistance.
-
+
@@ -691,7 +691,7 @@
Link Another Email
-
+
@@ -989,11 +989,15 @@
Member directory
Linked emails
-
Link additional email addresses to consolidate duplicate accounts.
+
Link additional email addresses after verifying ownership. Existing accounts require support assistance.
setPrimaryEmail(btn.dataset.email);
});
});
+ applyLinkedEmailStatus(data, true);
+ if (!linkedEmailStatusKnown) {
+ showLinkedEmailStatus('Unable to check email change status. Reload this page or contact support.');
+ }
} catch (error) {
- console.error('Failed to load linked emails:', error);
+ if (loadVersion !== linkedEmailLoadVersion) return;
+ if (!linkedEmailReconciliationRequired) {
+ showLinkedEmailStatus('Unable to check email change status. Reload this page or contact support.');
+ }
+ }
+ }
+
+ function primaryEmailOperation(email) {
+ var key = 'member-primary-email:' + linkedEmailCredentialId;
+ var saved = window.sessionStorage.getItem(key);
+ var operation = saved ? JSON.parse(saved) : null;
+ var normalizedEmail = email.trim().toLowerCase();
+ if (!operation || operation.email !== normalizedEmail) {
+ operation = { operation_id: window.crypto.randomUUID(), email: normalizedEmail };
+ // Persist before sending: retries after a lost response or page reload
+ // must use the same caller operation ID, never start a second mutation.
+ window.sessionStorage.setItem(key, JSON.stringify(operation));
}
+ return { key: key, id: operation.operation_id };
}
async function setPrimaryEmail(email) {
+ if (!canMutateLinkedEmails()) return;
if (!confirm('Make ' + email + ' your primary email? You will need to use this email to sign in.')) return;
- var btns = document.querySelectorAll('.make-primary-btn');
- btns.forEach(function(b) { b.disabled = true; });
+ linkedEmailMutationPending = true;
+ ++linkedEmailLoadVersion;
+ updateLinkedEmailControls();
try {
+ var operation = primaryEmailOperation(email);
var response = await fetch('/api/me/linked-emails/primary', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email: email }),
+ body: JSON.stringify({ email: email, operation_id: operation.id }),
credentials: 'include'
});
var data = await response.json();
+ ++linkedEmailLoadVersion;
+ applyLinkedEmailStatus(data, false);
- if (!response.ok) {
- accountShowToast(data.error || 'Failed to update primary email', true);
+ if ((response.ok || data.reconciliation_required === false)
+ && data.reconciliation_required !== true && data.status_unknown !== true) {
+ window.sessionStorage.removeItem(operation.key);
+ }
+
+ if (!response.ok || linkedEmailReconciliationRequired || data.status_unknown === true) {
+ accountShowToast(data.message || (linkedEmailReconciliationRequired ? linkedEmailReconciliationMessage : data.error) || 'Failed to update primary email', true);
return;
}
accountShowToast('Primary email updated to ' + email);
- loadLinkedEmails();
+ await loadLinkedEmails();
} catch (error) {
- accountShowToast('Failed to update primary email', true);
+ ++linkedEmailLoadVersion;
+ linkedEmailStatusKnown = false;
+ var message = 'Unable to confirm your email change. Reload to check its status or contact support before retrying.';
+ showLinkedEmailStatus(message);
+ accountShowToast(message, true);
} finally {
- btns.forEach(function(b) { b.disabled = false; });
+ linkedEmailMutationPending = false;
+ updateLinkedEmailControls();
}
}
function openLinkEmailModal() {
+ if (!canMutateLinkedEmails()) return;
document.getElementById('linkEmailInput').value = '';
document.getElementById('linkEmailWarning').style.display = 'none';
document.getElementById('linkEmailWarningMessage').textContent = '';
- document.getElementById('linkEmailBtn').disabled = false;
- document.getElementById('linkEmailBtn').textContent = 'Send Verification';
+ document.getElementById('linkEmailBtn').textContent = 'Send verification';
+ updateLinkedEmailControls();
document.getElementById('linkEmailModal').classList.add('show');
}
@@ -1131,11 +1223,14 @@
You're unsubscribed from all emails
}
async function sendLinkVerification() {
+ if (!canMutateLinkedEmails()) return;
var email = document.getElementById('linkEmailInput').value.trim();
if (!email) return;
var btn = document.getElementById('linkEmailBtn');
- btn.disabled = true;
+ linkedEmailMutationPending = true;
+ ++linkedEmailLoadVersion;
+ updateLinkedEmailControls();
btn.textContent = 'Sending...';
try {
@@ -1147,9 +1242,11 @@
You're unsubscribed from all emails
});
var data = await response.json();
+ ++linkedEmailLoadVersion;
+ applyLinkedEmailStatus(data, false);
- if (!response.ok) {
- var message = data.message || data.error || 'Failed to send verification';
+ if (!response.ok || linkedEmailReconciliationRequired) {
+ var message = data.message || (linkedEmailReconciliationRequired ? linkedEmailReconciliationMessage : data.error) || 'Failed to send verification';
accountShowToast(message, true);
document.getElementById('linkEmailWarningMessage').textContent = message + ' ';
document.getElementById('linkEmailWarning').style.display = 'block';
@@ -1158,12 +1255,17 @@
You're unsubscribed from all emails
closeLinkEmailModal();
accountShowToast('Verification email sent! Check your inbox.');
- loadLinkedEmails();
+ await loadLinkedEmails();
} catch (error) {
- accountShowToast('Failed to send verification', true);
+ ++linkedEmailLoadVersion;
+ linkedEmailStatusKnown = false;
+ var message = 'Unable to confirm your email change. Reload to check its status or contact support before retrying.';
+ showLinkedEmailStatus(message);
+ accountShowToast(message, true);
} finally {
- btn.disabled = false;
- btn.textContent = 'Send Verification';
+ linkedEmailMutationPending = false;
+ updateLinkedEmailControls();
+ btn.textContent = 'Send verification';
}
}
diff --git a/server/src/addie/admin-status-lookup.ts b/server/src/addie/admin-status-lookup.ts
index daf2ed7711a..95852452898 100644
--- a/server/src/addie/admin-status-lookup.ts
+++ b/server/src/addie/admin-status-lookup.ts
@@ -15,6 +15,7 @@
*/
import { createLogger } from '../logger.js';
+import type { AuthorizationSnapshot } from '../db/user-authorization-snapshot-db.js';
import { WorkingGroupDatabase } from '../db/working-group-db.js';
import { getWebAdminStatusCache } from './admin-status-cache.js';
import {
@@ -77,7 +78,7 @@ export async function isWebUserAAOAdmin(workosUserId: string): Promise
*/
export async function resolveWebUserAAOAdminAccess(
workosUserId: string,
- email: string | null | undefined,
+ snapshot: AuthorizationSnapshot | null | undefined,
): Promise {
- return decideAAOAdminAccess(await isWebUserAAOAdmin(workosUserId), email);
+ return decideAAOAdminAccess(await isWebUserAAOAdmin(workosUserId), snapshot);
}
diff --git a/server/src/auth/admin-access.ts b/server/src/auth/admin-access.ts
index 8ffc9ba0ed4..0755f2431d7 100644
--- a/server/src/auth/admin-access.ts
+++ b/server/src/auth/admin-access.ts
@@ -1,3 +1,5 @@
+import type { AuthorizationSnapshot } from '../db/user-authorization-snapshot-db.js';
+
/**
* Shared platform-admin authority vocabulary.
*
@@ -17,7 +19,15 @@ export interface AAOAdminAccessDecision {
mechanism: AAOAdminAccessMechanism | null;
}
-export function isBreakGlassAdminEmail(email: string | null | undefined): boolean {
+/**
+ * Email-based authority comes only from this request's primary-DB snapshot.
+ * A pending intent can precede any epoch change, so epoch freshness alone is
+ * insufficient. Missing state and unverified credentials never grant access.
+ */
+export function isBreakGlassAdmin(snapshot: AuthorizationSnapshot | null | undefined): boolean {
+ if (!snapshot || snapshot.credential.emailMutationPending !== false
+ || snapshot.credential.emailVerified !== true) return false;
+ const email = snapshot.credential.email;
if (!email) return false;
const normalizedEmail = email.trim().toLowerCase();
if (!normalizedEmail) return false;
@@ -30,9 +40,9 @@ export function isBreakGlassAdminEmail(email: string | null | undefined): boolea
/** Classify a membership result without duplicating break-glass interpretation. */
export function decideAAOAdminAccess(
isAdminByWorkingGroup: boolean,
- email: string | null | undefined,
+ snapshot: AuthorizationSnapshot | null | undefined,
): AAOAdminAccessDecision {
if (isAdminByWorkingGroup) return { isAdmin: true, mechanism: 'aao_admin_working_group' };
- if (isBreakGlassAdminEmail(email)) return { isAdmin: true, mechanism: 'break_glass_admin_email' };
+ if (isBreakGlassAdmin(snapshot)) return { isAdmin: true, mechanism: 'break_glass_admin_email' };
return { isAdmin: false, mechanism: null };
}
diff --git a/server/src/auth/organization-principal.ts b/server/src/auth/organization-principal.ts
index dccf139df37..5f4ef47cb06 100644
--- a/server/src/auth/organization-principal.ts
+++ b/server/src/auth/organization-principal.ts
@@ -2,7 +2,7 @@ import type { WorkOSUser } from "../types.js";
export type OrgAuthorizationPrincipal = Pick<
WorkOSUser,
- "id" | "authWorkosUserId"
+ "id" | "authWorkosUserId" | "authorizationSnapshot"
>;
/**
diff --git a/server/src/auth/workos-client.ts b/server/src/auth/workos-client.ts
index fa155e88293..fb8755b581e 100644
--- a/server/src/auth/workos-client.ts
+++ b/server/src/auth/workos-client.ts
@@ -7,12 +7,14 @@ const OWNERLESS_PROMOTION_WORKOS_TIMEOUT_MS = 10_000;
const PIPES_WORKOS_TIMEOUT_MS = 10_000;
const AUTHORIZATION_OBSERVER_WORKOS_TIMEOUT_MS = 3_000;
const AUTHORIZATION_ENFORCEMENT_WORKOS_TIMEOUT_MS = 5_000;
+const EMAIL_MUTATION_WORKOS_TIMEOUT_MS = 10_000;
let _workos: WorkOS | null = null;
let _ownerlessPromotionWorkos: WorkOS | null = null;
let _pipesWorkos: WorkOS | null = null;
let _authorizationObserverWorkos: WorkOS | null = null;
let _authorizationEnforcementWorkos: WorkOS | null = null;
+let _emailMutationWorkos: WorkOS | null = null;
let _clientId = '';
/** Returns the shared WorkOS client. Constructed on first call; WORKOS_API_KEY and WORKOS_CLIENT_ID must be set by then. */
@@ -97,6 +99,20 @@ export function getAuthorizationEnforcementWorkos(): WorkOS {
return _authorizationEnforcementWorkos;
}
+/** One bounded attempt: an email mutation timeout requires reconciliation. */
+export function getEmailMutationWorkos(): WorkOS {
+ if (!_emailMutationWorkos) {
+ if (!process.env.WORKOS_API_KEY) throw new Error('WORKOS_API_KEY environment variable is required');
+ if (!process.env.WORKOS_CLIENT_ID) throw new Error('WORKOS_CLIENT_ID environment variable is required');
+ _emailMutationWorkos = new WorkOS(process.env.WORKOS_API_KEY, {
+ clientId: process.env.WORKOS_CLIENT_ID,
+ timeout: EMAIL_MUTATION_WORKOS_TIMEOUT_MS,
+ maxRetries: 0,
+ });
+ }
+ return _emailMutationWorkos;
+}
+
/**
* Get the authorization URL to redirect users to WorkOS for authentication
*/
diff --git a/server/src/db/client.ts b/server/src/db/client.ts
index cfc6dfb77f9..172688bccfa 100644
--- a/server/src/db/client.ts
+++ b/server/src/db/client.ts
@@ -147,8 +147,65 @@ export function withDatabaseDeadline(
return queryDeadline.run({ deadlineMs, readOnly: options.readOnly ?? true }, work);
}
+function databaseDeadlineExceededError(): Error & { code: '57014' } {
+ return Object.assign(new Error('Database query deadline exceeded'), { code: '57014' as const });
+}
+
+/**
+ * Check out a pooled client without allowing pool saturation to outlive the
+ * caller's absolute deadline. A client delivered after the timer wins is
+ * released immediately so a timed-out request cannot leak a pool slot.
+ */
+async function checkoutClientBeforeDeadline(deadlineMs: number): Promise {
+ const remainingMs = deadlineMs - Date.now();
+ if (remainingMs <= 0) throw databaseDeadlineExceededError();
+
+ const checkout = getPool().connect();
+ let timeout: ReturnType | undefined;
+ let timedOut = false;
+ const deadline = new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => {
+ timedOut = true;
+ reject(databaseDeadlineExceededError());
+ }, remainingMs);
+ });
+
+ try {
+ return await Promise.race([checkout, deadline]);
+ } catch (error) {
+ if (timedOut) {
+ void checkout.then(
+ (lateClient) => lateClient.release(),
+ () => undefined,
+ );
+ }
+ throw error;
+ } finally {
+ if (timeout !== undefined) clearTimeout(timeout);
+ }
+}
+
+async function getClientBeforeDeadline(
+ deadlineMs: number,
+ retryTransientCheckout: boolean,
+): Promise {
+ const attempts = retryTransientCheckout ? 2 : 1;
+ for (let attempt = 0; attempt < attempts; attempt++) {
+ try {
+ return await checkoutClientBeforeDeadline(deadlineMs);
+ } catch (error) {
+ if (attempt === attempts - 1 || !isTransientConnectionError(error) || Date.now() >= deadlineMs) {
+ throw error;
+ }
+ console.warn('Transient DB connection error, retrying client checkout:', (error as Error).message);
+ }
+ }
+ throw databaseDeadlineExceededError();
+}
+
/**
- * Execute one query with server-enforced statement and lock deadlines.
+ * Execute one query with deadline-bounded pool checkout plus server-enforced
+ * statement and lock deadlines.
*
* Use this for public read paths whose inputs can select unusually large
* registry fan-outs. The transaction-local settings ensure PostgreSQL stops
@@ -159,18 +216,22 @@ export async function queryWithTimeout(
text: string,
params: any[] | undefined,
timeoutMs: number,
+ options: { retryTransientCheckout?: boolean } = {},
): Promise> {
const inheritedDeadline = queryDeadline.getStore();
const deadlineMs = Math.min(
Date.now() + timeoutMs,
inheritedDeadline?.deadlineMs ?? Number.POSITIVE_INFINITY,
);
- const client = await getClient();
+ const client = await getClientBeforeDeadline(
+ deadlineMs,
+ options.retryTransientCheckout ?? true,
+ );
let transactionStarted = false;
try {
const effectiveTimeoutMs = deadlineMs - Date.now();
if (effectiveTimeoutMs <= 0) {
- throw Object.assign(new Error('Database query deadline exceeded'), { code: '57014' });
+ throw databaseDeadlineExceededError();
}
await client.query(inheritedDeadline?.readOnly === false ? 'BEGIN' : 'BEGIN READ ONLY');
transactionStarted = true;
diff --git a/server/src/db/migrations/592_email_mutations.sql b/server/src/db/migrations/592_email_mutations.sql
new file mode 100644
index 00000000000..e726bbaaa21
--- /dev/null
+++ b/server/src/db/migrations/592_email_mutations.sql
@@ -0,0 +1,316 @@
+-- Allocation re-audited 2026-09-13T14:47:22.073294+00:00 against main dd6b04964a000f82ebd687e817e8e748fe73eb5c.
+-- All 33 open PRs / 2891 files were fully paginated; heads were rechecked.
+-- 592 remains owned only by #7463 and has not been released on main.
+-- 588-591 belong to other slices; 591 exclusively PR1c (#7457).
+-- No dependency on those migrations. Exact ledger heads/checksums are in the PR body.
+-- Retain journal evidence without a users FK, including after account deletion.
+ALTER TABLE users ADD COLUMN email_mutation_version BIGINT NOT NULL DEFAULT 0
+ CHECK (email_mutation_version >= 0);
+
+CREATE TABLE email_mutations (
+ id UUID PRIMARY KEY,
+ workos_user_id TEXT NOT NULL,
+ actor_user_id TEXT NOT NULL,
+ payload_hash TEXT NOT NULL CHECK (payload_hash ~ '^[0-9a-f]{64}$'),
+ old_email TEXT NOT NULL,
+ old_email_verified BOOLEAN NOT NULL,
+ new_email TEXT NOT NULL,
+ expected_email_version BIGINT NOT NULL CHECK (expected_email_version >= 0),
+ applied_email_version BIGINT,
+ epoch_after BIGINT CHECK (epoch_after > 0),
+ state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'compensated', 'reconciliation_required')),
+ failure_code TEXT CHECK (failure_code ~ '^[a-z][a-z0-9_]{0,79}$'),
+ result_status INTEGER NOT NULL,
+ result_body JSONB NOT NULL CHECK (
+ jsonb_typeof(result_body) = 'object'
+ AND result_body @> jsonb_build_object('operation_id', id::text)
+ ),
+ reconciliation_attempts JSONB NOT NULL DEFAULT '[]'::jsonb
+ CHECK (jsonb_typeof(reconciliation_attempts) = 'array'),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (workos_user_id, id),
+ UNIQUE (workos_user_id, applied_email_version),
+ CHECK (
+ (state = 'pending' AND failure_code IS NULL)
+ OR (state IN ('compensated', 'reconciliation_required') AND failure_code IS NOT NULL)
+ OR (state = 'succeeded' AND failure_code IS NULL)
+ ),
+ CHECK (
+ (state = 'pending'
+ AND result_status = 409
+ AND result_body @> '{"reconciliation_required":true}'::jsonb
+ AND applied_email_version IS NULL AND epoch_after IS NULL)
+ OR
+ (state = 'reconciliation_required'
+ AND result_status = 409
+ AND result_body @> '{"reconciliation_required":true}'::jsonb
+ AND applied_email_version IS NULL AND epoch_after IS NOT NULL)
+ OR
+ (state = 'succeeded' AND result_status = 200
+ AND result_body @> jsonb_build_object('primary_email', new_email)
+ AND result_body->>'reconciliation_required' IS DISTINCT FROM 'true'
+ AND applied_email_version IS NOT NULL
+ AND applied_email_version = expected_email_version + 1 AND epoch_after IS NOT NULL)
+ OR
+ (state = 'compensated' AND result_status BETWEEN 400 AND 599
+ AND result_body ? 'error' AND jsonb_typeof(result_body->'error') = 'string'
+ AND result_body->>'reconciliation_required' IS DISTINCT FROM 'true'
+ AND applied_email_version IS NOT NULL
+ AND applied_email_version = expected_email_version + 1 AND epoch_after IS NOT NULL)
+ )
+);
+
+CREATE UNIQUE INDEX email_mutations_one_unresolved_credential
+ ON email_mutations (workos_user_id)
+ WHERE state IN ('pending', 'reconciliation_required');
+CREATE INDEX email_mutations_credential_history
+ ON email_mutations (workos_user_id, expected_email_version DESC);
+
+-- Every writer uses the saga's credential lock. A BEFORE row trigger already
+-- holds tuple locks: waiting here would invert the saga's advisory -> row order.
+CREATE FUNCTION lock_email_writer(credential_id TEXT) RETURNS VOID LANGUAGE plpgsql AS $$
+BEGIN
+ IF credential_id IS NOT NULL
+ AND NOT pg_try_advisory_xact_lock(hashtextextended(credential_id, 6827)) THEN
+ RAISE EXCEPTION 'Credential email mutation is busy' USING ERRCODE = '55P03';
+ END IF;
+END;
+$$;
+
+CREATE FUNCTION protect_email_mutation_intent() RETURNS TRIGGER LANGUAGE plpgsql AS $$
+BEGIN
+ PERFORM lock_email_writer(NEW.workos_user_id);
+ IF TG_OP = 'INSERT' THEN
+ IF NEW.state <> 'pending' OR NOT EXISTS (
+ SELECT 1 FROM users WHERE workos_user_id = NEW.workos_user_id
+ AND email = NEW.old_email AND email_verified = NEW.old_email_verified
+ AND email_mutation_version = NEW.expected_email_version
+ ) THEN
+ RAISE EXCEPTION 'Email mutation must start pending from the current credential version' USING ERRCODE = '23514';
+ END IF;
+ ELSE
+ IF ROW(NEW.id, NEW.workos_user_id, NEW.actor_user_id, NEW.payload_hash,
+ NEW.old_email, NEW.old_email_verified, NEW.new_email, NEW.expected_email_version)
+ IS DISTINCT FROM
+ ROW(OLD.id, OLD.workos_user_id, OLD.actor_user_id, OLD.payload_hash,
+ OLD.old_email, OLD.old_email_verified, OLD.new_email, OLD.expected_email_version) THEN
+ RAISE EXCEPTION 'Email mutation intent is immutable' USING ERRCODE = '23514';
+ END IF;
+ IF OLD.state IN ('succeeded', 'compensated') AND NEW IS DISTINCT FROM OLD THEN
+ RAISE EXCEPTION 'Email mutation terminal result is immutable' USING ERRCODE = '23514';
+ END IF;
+ IF NEW.state IN ('succeeded', 'compensated')
+ AND NEW.id::text IS DISTINCT FROM NULLIF(current_setting('adcp.email_mutation_id', true), '') THEN
+ RAISE EXCEPTION 'Email mutation terminal result requires its operation transaction' USING ERRCODE = '23514';
+ END IF;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+CREATE TRIGGER protect_email_mutation_intent
+ BEFORE INSERT OR UPDATE ON email_mutations
+ FOR EACH ROW EXECUTE FUNCTION protect_email_mutation_intent();
+
+CREATE FUNCTION fence_credential_email_writer() RETURNS TRIGGER LANGUAGE plpgsql AS $$
+DECLARE
+ canonical users%ROWTYPE;
+ operation email_mutations%ROWTYPE;
+ operation_setting TEXT := NULLIF(current_setting('adcp.email_mutation_id', true), '');
+ protected BOOLEAN;
+ previous_epoch BIGINT;
+ updated_epoch BIGINT;
+ affected_rows BIGINT;
+BEGIN
+ PERFORM lock_email_writer(NEW.workos_user_id);
+ IF TG_OP = 'UPDATE' THEN
+ canonical := OLD;
+ IF NEW.workos_user_id IS DISTINCT FROM OLD.workos_user_id THEN
+ PERFORM lock_email_writer(OLD.workos_user_id);
+ IF EXISTS (SELECT 1 FROM email_mutations WHERE workos_user_id IN (OLD.workos_user_id, NEW.workos_user_id)) THEN
+ RAISE EXCEPTION 'Journaled credential identity cannot be reassigned' USING ERRCODE = '23514';
+ END IF;
+ END IF;
+ ELSE
+ -- Also fence INSERT ... ON CONFLICT before it can apply a stale EXCLUDED
+ -- snapshot. This covers OAuth callbacks, webhooks, MCP and periodic resync.
+ SELECT * INTO canonical FROM users WHERE workos_user_id = NEW.workos_user_id;
+ END IF;
+ SELECT EXISTS (SELECT 1 FROM email_mutations WHERE workos_user_id = NEW.workos_user_id) INTO protected;
+ IF operation_setting IS NOT NULL AND TG_OP = 'UPDATE' THEN
+ SELECT * INTO operation FROM email_mutations
+ WHERE id::text = operation_setting AND workos_user_id = NEW.workos_user_id
+ AND state IN ('pending', 'reconciliation_required');
+ IF operation.id IS NULL
+ OR canonical.email_mutation_version <> operation.expected_email_version
+ OR NEW.email_mutation_version <> canonical.email_mutation_version
+ OR NOT COALESCE(((NEW.email = operation.new_email AND NEW.email_verified IS TRUE)
+ OR (NEW.email = operation.old_email AND NEW.email_verified = operation.old_email_verified)), FALSE) THEN
+ RAISE EXCEPTION 'Email mutation operation or version does not authorize this write' USING ERRCODE = '23514';
+ END IF;
+ -- Advance even when compensation rewrites the same pair after a rollback.
+ NEW.email_mutation_version := canonical.email_mutation_version + 1;
+ RETURN NEW;
+ END IF;
+ IF TG_OP = 'UPDATE' AND NEW.email_mutation_version <> canonical.email_mutation_version THEN
+ RAISE EXCEPTION 'Credential email version is managed by the writer fence' USING ERRCODE = '23514';
+ END IF;
+ IF protected AND (canonical.workos_user_id IS NULL
+ OR NEW.email IS DISTINCT FROM canonical.email
+ OR NEW.email_verified IS DISTINCT FROM canonical.email_verified) THEN
+ -- A provider's updated_at cannot order a delayed mutation/compensation.
+ -- The committed canonical pair stays authoritative until a new journaled
+ -- operation is resolved. This remains active after terminal outcomes.
+ RAISE EXCEPTION 'Credential email requires mutation reconciliation' USING ERRCODE = '23514';
+ END IF;
+ IF TG_OP = 'UPDATE' AND
+ (NEW.email IS DISTINCT FROM OLD.email OR NEW.email_verified IS DISTINCT FROM OLD.email_verified) THEN
+ NEW.email_mutation_version := OLD.email_mutation_version + 1;
+ SELECT epoch INTO previous_epoch FROM authorization_epochs
+ WHERE workos_user_id = NEW.workos_user_id FOR UPDATE;
+ INSERT INTO authorization_epochs (workos_user_id, epoch) VALUES (NEW.workos_user_id, 1)
+ ON CONFLICT (workos_user_id) DO UPDATE
+ SET epoch = authorization_epochs.epoch + 1, updated_at = NOW()
+ RETURNING epoch INTO updated_epoch;
+ GET DIAGNOSTICS affected_rows = ROW_COUNT;
+ IF affected_rows <> 1 OR updated_epoch IS DISTINCT FROM COALESCE(previous_epoch, 0) + 1
+ OR NOT EXISTS (SELECT 1 FROM authorization_epochs
+ WHERE workos_user_id = NEW.workos_user_id AND epoch = updated_epoch) THEN
+ RAISE EXCEPTION 'Credential email write lacks its authorization epoch' USING ERRCODE = '23514';
+ END IF;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+CREATE TRIGGER fence_credential_email_writer
+ BEFORE INSERT OR UPDATE OF email, email_verified, email_mutation_version, workos_user_id ON users
+ FOR EACH ROW EXECUTE FUNCTION fence_credential_email_writer();
+
+-- An authorized UPDATE cannot commit while its journal is still pending, or
+-- after a trigger silently skipped the journal/epoch write. Match the persisted
+-- monotonic version, never caller UUID order or potentially tied timestamps.
+CREATE FUNCTION verify_email_mutation_commit() RETURNS TRIGGER LANGUAGE plpgsql AS $$
+DECLARE
+ operation email_mutations%ROWTYPE;
+BEGIN
+ IF NEW.email_mutation_version = OLD.email_mutation_version THEN RETURN NULL; END IF;
+ IF NOT EXISTS (SELECT 1 FROM email_mutations WHERE workos_user_id = NEW.workos_user_id) THEN RETURN NULL; END IF;
+ SELECT * INTO operation FROM email_mutations
+ WHERE workos_user_id = NEW.workos_user_id AND applied_email_version = NEW.email_mutation_version
+ AND state IN ('succeeded', 'compensated');
+ IF operation.id IS NULL
+ OR (operation.state = 'succeeded' AND (NEW.email <> operation.new_email OR NEW.email_verified IS DISTINCT FROM TRUE))
+ OR (operation.state = 'compensated' AND (NEW.email <> operation.old_email OR NEW.email_verified IS DISTINCT FROM operation.old_email_verified))
+ OR NOT EXISTS (SELECT 1 FROM authorization_epochs
+ WHERE workos_user_id = NEW.workos_user_id AND epoch = operation.epoch_after) THEN
+ RAISE EXCEPTION 'Email mutation local commit lacks its terminal journal and epoch' USING ERRCODE = '23514';
+ END IF;
+ RETURN NULL;
+END;
+$$;
+CREATE CONSTRAINT TRIGGER verify_email_mutation_commit
+ AFTER UPDATE ON users DEFERRABLE INITIALLY DEFERRED
+ FOR EACH ROW EXECUTE FUNCTION verify_email_mutation_commit();
+
+CREATE FUNCTION verify_email_mutation_terminal() RETURNS TRIGGER LANGUAGE plpgsql AS $$
+BEGIN
+ IF NEW.state = 'reconciliation_required' AND NOT EXISTS (
+ SELECT 1 FROM users u JOIN authorization_epochs epoch USING (workos_user_id)
+ WHERE u.workos_user_id = NEW.workos_user_id
+ AND u.email_mutation_version = NEW.expected_email_version
+ AND u.email = NEW.old_email AND u.email_verified = NEW.old_email_verified
+ AND epoch.epoch >= NEW.epoch_after
+ ) THEN
+ RAISE EXCEPTION 'Email reconciliation lacks matching local evidence and revocation' USING ERRCODE = '23514';
+ END IF;
+ IF NEW.state IN ('succeeded', 'compensated') AND NOT EXISTS (
+ SELECT 1 FROM users u JOIN authorization_epochs epoch USING (workos_user_id)
+ WHERE u.workos_user_id = NEW.workos_user_id
+ AND u.email_mutation_version = NEW.applied_email_version
+ AND epoch.epoch = NEW.epoch_after
+ AND ((NEW.state = 'succeeded' AND u.email = NEW.new_email AND u.email_verified IS TRUE)
+ OR (NEW.state = 'compensated' AND u.email = NEW.old_email AND u.email_verified = NEW.old_email_verified))
+ ) THEN
+ RAISE EXCEPTION 'Email mutation terminal result lacks matching local evidence' USING ERRCODE = '23514';
+ END IF;
+ RETURN NULL;
+END;
+$$;
+CREATE CONSTRAINT TRIGGER verify_email_mutation_terminal
+ AFTER INSERT OR UPDATE ON email_mutations DEFERRABLE INITIALLY DEFERRED
+ FOR EACH ROW EXECUTE FUNCTION verify_email_mutation_terminal();
+
+CREATE FUNCTION fence_email_denormalization_writer() RETURNS TRIGGER LANGUAGE plpgsql AS $$
+DECLARE
+ canonical_email TEXT;
+BEGIN
+ PERFORM lock_email_writer(NEW.workos_user_id);
+ IF TG_OP = 'UPDATE' AND OLD.workos_user_id IS DISTINCT FROM NEW.workos_user_id THEN
+ PERFORM lock_email_writer(OLD.workos_user_id);
+ END IF;
+ IF EXISTS (SELECT 1 FROM email_mutations WHERE workos_user_id = NEW.workos_user_id) THEN
+ SELECT email INTO canonical_email FROM users WHERE workos_user_id = NEW.workos_user_id;
+ IF canonical_email IS NULL THEN
+ RAISE EXCEPTION 'Credential email denormalization requires reconciliation' USING ERRCODE = '23514';
+ END IF;
+ -- Keep role/status revocations from mixed provider snapshots effective;
+ -- only their stale denormalized email is replaced by the canonical value.
+ NEW.email := canonical_email;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+CREATE TRIGGER fence_membership_email_writer
+ BEFORE INSERT OR UPDATE OF email, workos_user_id ON organization_memberships
+ FOR EACH ROW EXECUTE FUNCTION fence_email_denormalization_writer();
+CREATE TRIGGER fence_person_email_writer
+ BEFORE INSERT OR UPDATE OF email, workos_user_id ON person_relationships
+ FOR EACH ROW EXECUTE FUNCTION fence_email_denormalization_writer();
+
+CREATE FUNCTION fence_email_alias_token_writer() RETURNS TRIGGER LANGUAGE plpgsql AS $$
+DECLARE
+ old_owner TEXT;
+ new_owner TEXT;
+ owner_id TEXT;
+ operation email_mutations%ROWTYPE;
+ operation_setting TEXT := NULLIF(current_setting('adcp.email_mutation_id', true), '');
+BEGIN
+ IF TG_TABLE_NAME = 'user_email_aliases' THEN
+ IF TG_OP <> 'INSERT' THEN old_owner := OLD.workos_user_id; END IF;
+ IF TG_OP <> 'DELETE' THEN new_owner := NEW.workos_user_id; END IF;
+ ELSE
+ IF TG_OP <> 'INSERT' THEN old_owner := OLD.primary_workos_user_id; END IF;
+ IF TG_OP <> 'DELETE' THEN new_owner := NEW.primary_workos_user_id; END IF;
+ END IF;
+ FOR owner_id IN SELECT DISTINCT owner FROM unnest(ARRAY[old_owner, new_owner]) owner WHERE owner IS NOT NULL ORDER BY owner LOOP
+ PERFORM lock_email_writer(owner_id);
+ -- A provider-authoritative users DELETE must finish its FK cascades so
+ -- memberships and sessions can be revoked. The deleted parent is absent
+ -- inside that transaction; a direct alias/token DELETE still sees it and
+ -- remains fenced. The journal has no users FK and retains the evidence.
+ IF TG_OP = 'DELETE' AND NOT EXISTS (SELECT 1 FROM users WHERE workos_user_id = owner_id) THEN
+ CONTINUE;
+ END IF;
+ SELECT * INTO operation FROM email_mutations WHERE workos_user_id = owner_id
+ AND state IN ('pending', 'reconciliation_required');
+ IF operation.id IS NOT NULL THEN
+ IF TG_TABLE_NAME <> 'user_email_aliases'
+ OR operation.id::text IS DISTINCT FROM operation_setting
+ OR (old_owner IS NOT NULL AND old_owner <> owner_id)
+ OR (new_owner IS NOT NULL AND new_owner <> owner_id)
+ OR (TG_OP <> 'INSERT' AND to_jsonb(OLD)->>'email' NOT IN (operation.old_email, operation.new_email))
+ OR (TG_OP <> 'DELETE' AND to_jsonb(NEW)->>'email' NOT IN (operation.old_email, operation.new_email)) THEN
+ RAISE EXCEPTION 'Credential alias or token mutation requires reconciliation' USING ERRCODE = '23514';
+ END IF;
+ END IF;
+ END LOOP;
+ IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
+ RETURN NEW;
+END;
+$$;
+CREATE TRIGGER fence_email_alias_writer
+ BEFORE INSERT OR UPDATE OR DELETE ON user_email_aliases
+ FOR EACH ROW EXECUTE FUNCTION fence_email_alias_token_writer();
+CREATE TRIGGER fence_email_link_token_writer
+ BEFORE INSERT OR UPDATE OR DELETE ON email_link_tokens
+ FOR EACH ROW EXECUTE FUNCTION fence_email_alias_token_writer();
diff --git a/server/src/db/user-authorization-snapshot-db.ts b/server/src/db/user-authorization-snapshot-db.ts
new file mode 100644
index 00000000000..2d6557c452a
--- /dev/null
+++ b/server/src/db/user-authorization-snapshot-db.ts
@@ -0,0 +1,199 @@
+import { isTransientConnectionError, queryWithTimeout } from './client.js';
+
+const SNAPSHOT_TIMEOUT_MS = 2_000;
+
+/** The exact credential and its authority, observed in one primary-database statement. */
+export interface AuthorizationSnapshot {
+ readonly authenticatedUserId: string;
+ readonly canonicalUserId: string;
+ readonly identityId: string | null;
+ readonly selectedOrganizationId: string | null;
+ /** PostgreSQL bigint text: converting to a number would lose revocations above 2^53. */
+ readonly authorizationEpoch: string;
+ readonly credential: Readonly<{
+ email: string | null;
+ emailVerified: boolean;
+ /** Durable intent must fence requests even before an epoch is advanced. */
+ emailMutationPending: boolean;
+ firstName: string | null;
+ lastName: string | null;
+ }>;
+ readonly credentialGrant: Readonly<{
+ id: string;
+ organizationId: string;
+ role: 'owner' | 'admin' | 'member';
+ effectiveFrom: string;
+ effectiveUntil: string | null;
+ }> | null;
+}
+
+/** Distinct from a missing credential or denied organization access. */
+export class AuthorizationSnapshotUnavailableError extends Error {
+ constructor(message = 'Authorization snapshot unavailable') {
+ super(message);
+ this.name = 'AuthorizationSnapshotUnavailableError';
+ }
+}
+
+interface SnapshotRow {
+ in_recovery: boolean;
+ authenticated_user_id: string | null;
+ canonical_user_id: string | null;
+ identity_id: string | null;
+ authorization_epoch: string;
+ email: string | null;
+ email_verified: boolean;
+ email_mutation_pending: boolean;
+ first_name: string | null;
+ last_name: string | null;
+ grant_id: string | null;
+ grant_organization_id: string | null;
+ grant_role: 'owner' | 'admin' | 'member' | null;
+ grant_effective_from: string | null;
+ grant_effective_until: string | null;
+}
+
+/** A retry may replace a broken connection, never renew the statement budget. */
+async function querySnapshot(statement: string, parameters: [string, string | null]) {
+ const deadlineMs = Date.now() + SNAPSHOT_TIMEOUT_MS;
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const remainingMs = deadlineMs - Date.now();
+ if (remainingMs <= 0) throw new AuthorizationSnapshotUnavailableError();
+ try {
+ const result = await queryWithTimeout(statement, parameters, remainingMs, {
+ // querySnapshot owns the one retry so checkout and statement failures
+ // share this request's single absolute authorization budget.
+ retryTransientCheckout: false,
+ });
+ if (Date.now() >= deadlineMs) throw new AuthorizationSnapshotUnavailableError();
+ return result;
+ } catch (error) {
+ if (attempt === 1 || !isTransientConnectionError(error) || Date.now() >= deadlineMs) throw error;
+ }
+ }
+ throw new AuthorizationSnapshotUnavailableError();
+}
+
+/**
+ * Never hydrate identity and epoch separately: a binding change can commit
+ * between the reads and stamp old attribution with the new epoch. One SELECT
+ * gives every field the same PostgreSQL MVCC snapshot. The bounded query wrapper
+ * uses the primary pool with transaction-local statement and lock timeouts;
+ * a recovering replica fails closed in this statement.
+ *
+ * Organization selection must be explicit. Neither linked credentials nor the
+ * organization_memberships cache supplies authority on a cold start/cache miss.
+ */
+export async function loadAuthorizationSnapshot(
+ authenticatedUserId: string,
+ selectedOrganizationId: string | null,
+): Promise {
+ const organizationId = selectedOrganizationId || null;
+ try {
+ const result = await querySnapshot(
+ `SELECT pg_catalog.pg_is_in_recovery() AS in_recovery,
+ credential.workos_user_id AS authenticated_user_id,
+ primary_binding.workos_user_id AS canonical_user_id,
+ binding.identity_id,
+ COALESCE(epoch.epoch, 0)::text AS authorization_epoch,
+ credential.email, credential.email_verified, credential.first_name, credential.last_name,
+ EXISTS (SELECT 1 FROM email_mutations mutation
+ WHERE mutation.workos_user_id = credential.workos_user_id
+ AND mutation.state IN ('pending', 'reconciliation_required')) AS email_mutation_pending,
+ credential_grant.id AS grant_id,
+ credential_grant.workos_organization_id AS grant_organization_id,
+ credential_grant.role AS grant_role,
+ to_char(credential_grant.effective_from AT TIME ZONE 'UTC',
+ 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS grant_effective_from,
+ to_char(credential_grant.effective_until AT TIME ZONE 'UTC',
+ 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS grant_effective_until
+ FROM (VALUES (1)) AS anchor(value)
+ LEFT JOIN users credential ON credential.workos_user_id = $1
+ LEFT JOIN identity_workos_users binding
+ ON binding.workos_user_id = credential.workos_user_id
+ LEFT JOIN identity_workos_users primary_binding
+ ON primary_binding.identity_id = binding.identity_id AND primary_binding.is_primary
+ LEFT JOIN authorization_epochs epoch
+ ON epoch.workos_user_id = credential.workos_user_id
+ LEFT JOIN organization_credential_grants credential_grant
+ ON credential_grant.workos_user_id = credential.workos_user_id
+ AND credential_grant.workos_organization_id = $2
+ AND credential_grant.revoked_at IS NULL
+ AND credential_grant.effective_from <= statement_timestamp()
+ AND (credential_grant.effective_until IS NULL
+ OR credential_grant.effective_until > statement_timestamp())`,
+ [authenticatedUserId, organizationId],
+ );
+ const row = result.rows[0];
+ if (!row || row.in_recovery !== false || typeof row.email_mutation_pending !== 'boolean') {
+ throw new AuthorizationSnapshotUnavailableError();
+ }
+ if (!row.authenticated_user_id) return null;
+ // Migration 460 backfills bindings and creates them on every user insert.
+ // Missing attribution is an integrity failure, not a singleton fallback.
+ if (!row.identity_id || !row.canonical_user_id) {
+ throw new AuthorizationSnapshotUnavailableError();
+ }
+
+ const credentialGrant = row.grant_id
+ ? Object.freeze({
+ id: row.grant_id,
+ organizationId: row.grant_organization_id!,
+ role: row.grant_role!,
+ effectiveFrom: row.grant_effective_from!,
+ effectiveUntil: row.grant_effective_until,
+ })
+ : null;
+ return Object.freeze({
+ authenticatedUserId: row.authenticated_user_id,
+ canonicalUserId: row.canonical_user_id,
+ identityId: row.identity_id,
+ selectedOrganizationId: organizationId,
+ authorizationEpoch: row.authorization_epoch,
+ credential: Object.freeze({
+ email: row.email,
+ emailVerified: row.email_verified,
+ emailMutationPending: row.email_mutation_pending,
+ firstName: row.first_name,
+ lastName: row.last_name,
+ }),
+ credentialGrant,
+ });
+ } catch (error) {
+ if (error instanceof AuthorizationSnapshotUnavailableError) throw error;
+ // Callers must distinguish unavailable from denied without exposing raw DB
+ // errors or reusing stale identity, organization, or grant cache entries.
+ throw new AuthorizationSnapshotUnavailableError();
+ }
+}
+
+export function sameAuthorizationIdentity(
+ previous: AuthorizationSnapshot,
+ current: AuthorizationSnapshot,
+): boolean {
+ return previous.authenticatedUserId === current.authenticatedUserId
+ && previous.canonicalUserId === current.canonicalUserId
+ && previous.identityId === current.identityId
+ && previous.authorizationEpoch === current.authorizationEpoch
+ && previous.credential.email === current.credential.email
+ && previous.credential.emailVerified === current.credential.emailVerified
+ && typeof previous.credential.emailMutationPending === 'boolean'
+ && previous.credential.emailMutationPending === current.credential.emailMutationPending;
+}
+
+/** Compare equality, not epoch ordering: deletions and replay can move backwards. */
+export function sameAuthorizationSnapshot(
+ previous: AuthorizationSnapshot,
+ current: AuthorizationSnapshot,
+): boolean {
+ if (!sameAuthorizationIdentity(previous, current)
+ || previous.selectedOrganizationId !== current.selectedOrganizationId) return false;
+ const before = previous.credentialGrant;
+ const after = current.credentialGrant;
+ if (!before || !after) return before === after;
+ return before.id === after.id
+ && before.organizationId === after.organizationId
+ && before.role === after.role
+ && before.effectiveFrom === after.effectiveFrom
+ && before.effectiveUntil === after.effectiveUntil;
+}
diff --git a/server/src/http.ts b/server/src/http.ts
index 773a82e1759..aac93d69a1b 100644
--- a/server/src/http.ts
+++ b/server/src/http.ts
@@ -107,7 +107,7 @@ import * as relationshipDb from "./db/relationship-db.js";
import * as personEvents from "./db/person-events-db.js";
import { isWebUserAAOAdmin } from "./addie/mcp/admin-tools.js";
import { resolveWebUserAAOAdminAccess } from "./addie/admin-status-lookup.js";
-import { isBreakGlassAdminEmail } from "./auth/admin-access.js";
+import { isBreakGlassAdmin } from "./auth/admin-access.js";
import { createSlackRouter } from "./routes/slack.js";
import { createWebhooksRouter } from "./routes/webhooks.js";
import { createWorkOSWebhooksRouter } from "./routes/workos-webhooks.js";
@@ -1079,15 +1079,15 @@ async function upsertInvoiceCache(
* Build app config object for injection into HTML pages.
* This allows nav.js to read config synchronously instead of making an async fetch.
*/
-function buildAppConfig(user?: { id?: string; email: string; firstName?: string | null; lastName?: string | null; isMember?: boolean; isAdmin?: boolean } | null) {
+function buildAppConfig(user?: { authorizationSnapshot?: import("./db/user-authorization-snapshot-db.js").AuthorizationSnapshot; id?: string; email: string; firstName?: string | null; lastName?: string | null; isMember?: boolean; isAdmin?: boolean } | null) {
// Trust a pre-resolved isAdmin (set by enrichUserWithAdmin / dev-user flag).
- // Fall back to ADMIN_EMAILS for callers that haven't enriched yet.
+ // The fallback requires authoritative credential and mutation state.
let isAdmin = false;
if (user) {
if (typeof user.isAdmin === 'boolean') {
isAdmin = user.isAdmin;
} else {
- isAdmin = isBreakGlassAdminEmail(user.email);
+ isAdmin = isBreakGlassAdmin(user.authorizationSnapshot);
}
}
@@ -8635,7 +8635,7 @@ ${p.category ? `${p.category}\n` : ''}${publishedUrl}<
// Match requireAdmin: working-group authority with an explicit,
// environment-managed break-glass fallback.
- const isAdmin = (await resolveWebUserAAOAdminAccess(user.id, user.email)).isAdmin;
+ const isAdmin = (await resolveWebUserAAOAdminAccess(user.id, user.authorizationSnapshot)).isAdmin;
// Check Slack sync status, seat type, and read DB names (user may have
// set a display name that differs from the WorkOS session values)
let isLinkedToSlack = false;
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index b1e3c756f70..86ef6573852 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -13,8 +13,7 @@ import { bansDb } from '../db/bans-db.js';
import { isWorkOSApiKeyFormat } from './api-key-format.js';
import { verifyWorkOSJWT, looksLikeJWT } from '../auth/workos-jwt.js';
import { storeRefreshedSession, getRefreshedSession, cleanExpiredRefreshes } from '../db/session-refresh-db.js';
-import { getPool } from '../db/client.js';
-import { getAuthorizationFingerprint } from '../db/authorization-epoch-db.js';
+import { loadAuthorizationSnapshot, AuthorizationSnapshotUnavailableError } from '../db/user-authorization-snapshot-db.js';
import { getOrganizationAuthorizationUserId } from '../auth/organization-principal.js';
import { constantTimeEqual } from '../utils/constant-time-equal.js';
import { resolveEffectiveMembership } from '../db/org-filters.js';
@@ -35,8 +34,8 @@ interface CachedSession {
accessToken: string;
expiresAt: number;
newSealedSession?: string; // Set if session was refreshed
- /** Persisted authorization epoch observed when this entry was stored (#6827). */
- authorizationFingerprint?: string;
+ /** Explicit organization from the authenticated provider session, never inferred. */
+ orgId?: string;
}
const sessionCache = new Map();
@@ -183,6 +182,14 @@ function isTransientAuthError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const name = err.name;
const code = (err as { code?: unknown }).code;
+ const status = (err as { status?: unknown; statusCode?: unknown }).status
+ ?? (err as { statusCode?: unknown }).statusCode;
+ if (typeof status === 'number' && (status === 408 || status === 429 || (status >= 500 && status < 600))) return true;
+ // jose does not retain the HTTP status when the remote JWKS endpoint fails.
+ // This exact error denotes key-service failure, not token signature failure.
+ if (name === 'JOSEError' && code === 'ERR_JOSE_GENERIC'
+ && (err.message === 'Expected 200 OK from the JSON Web Key Set HTTP response'
+ || err.message === 'Failed to parse the JSON Web Key Set HTTP response as JSON')) return true;
if (name === 'JWKSTimeout' || name === 'TimeoutError' || name === 'AbortError') return true;
if (typeof code === 'string') {
if (code === 'ERR_JWKS_TIMEOUT') return true;
@@ -313,19 +320,15 @@ export interface ValidatedBearerJWT {
/**
* Short-lived positive cache for successfully-validated bearer JWTs.
- * Keyed on SHA-256 of the raw token; value includes the synthesized user
+ * Keyed on SHA-256 of the raw token; value includes verified provider claims
* and an expiry that is the minimum of (token exp, now + BEARER_JWT_CACHE_TTL_MS).
*
- * Without this, every REST request with a Bearer JWT incurs a DB round-trip
- * for the local-user lookup — the cookie path has an equivalent cache
- * (`sessionCache`) and its absence here creates a DoS amplifier.
+ * Only signature-verified provider claims live here. Request authority is
+ * hydrated afresh from the primary database; no canonical user or grant is cached.
*/
interface CachedBearerJWT {
- user: WorkOSUser;
- orgId?: string;
+ verified: Awaited>;
expiresAt: number;
- /** Persisted authorization epoch observed when this entry was stored (#6827). */
- authorizationFingerprint?: string;
}
const bearerJwtCache = new Map();
const BEARER_JWT_CACHE_TTL_MS = 60 * 1000;
@@ -362,66 +365,38 @@ export async function validateWorkOSBearerJWT(req: Request): Promise>;
if (cached && cached.expiresAt > now) {
- if (await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint)) {
- return { user: cached.user, rawToken: token, orgId: cached.orgId };
+ verified = cached.verified;
+ } else {
+ try {
+ verified = await verifyWorkOSJWT(token);
+ } catch (err) {
+ if (isTransientAuthError(err)) throw err;
+ logger.debug({ err }, 'Bearer JWT verification failed');
+ return null;
}
- // Identity bindings changed since this entry was stored — re-verify.
- bearerJwtCache.delete(cacheKey);
- }
-
- let verified: Awaited>;
- try {
- verified = await verifyWorkOSJWT(token);
- } catch (err) {
- logger.debug({ err }, 'Bearer JWT verification failed');
- return null;
}
-
if (verified.isM2M || !verified.sub) return null;
- // Confirm the subject corresponds to a real local user. This catches
- // tokens from WorkOS accounts that have been deleted or never synced,
- // and gives us names for the synthesized WorkOSUser.
- const pool = getPool();
- const localUser = await pool.query<{
- first_name: string | null;
- last_name: string | null;
- email: string | null;
- }>(
- `SELECT first_name, last_name, email FROM users WHERE workos_user_id = $1`,
- [verified.sub],
- );
- if (localUser.rowCount === 0) {
- logger.warn({ sub: verified.sub }, 'Bearer JWT verified but user not found in local DB');
- return null;
- }
-
- const row = localUser.rows[0];
- const email = verified.email ?? row.email ?? '';
- const user: WorkOSUser = {
+ // Only provider authentication is cached. Identity, grants and the epoch
+ // always come from the same fresh primary-database snapshot, even on hits.
+ const user = await hydrateAuthenticatedUser({
id: verified.sub,
- email,
- firstName: row.first_name?.trim() || undefined,
- lastName: row.last_name?.trim() || undefined,
+ email: verified.email ?? '',
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
- };
+ }, selectedOrganizationForAuthentication(req, verified.orgId));
const tokenExpMs = verified.expiresAt ? verified.expiresAt * 1000 : Infinity;
const cacheUntil = Math.min(now + BEARER_JWT_CACHE_TTL_MS, tokenExpMs);
- if (cacheUntil > now) {
+ if ((!cached || cached.expiresAt <= now) && cacheUntil > now) {
if (bearerJwtCache.size >= BEARER_JWT_CACHE_MAX_SIZE) {
const oldest = bearerJwtCache.keys().next().value;
if (oldest) bearerJwtCache.delete(oldest);
}
- bearerJwtCache.set(cacheKey, {
- user,
- orgId: verified.orgId,
- expiresAt: cacheUntil,
- authorizationFingerprint: await authorizationFingerprintFor(user),
- });
+ bearerJwtCache.set(cacheKey, { verified, expiresAt: cacheUntil });
}
return { user, rawToken: token, orgId: verified.orgId };
@@ -711,128 +686,97 @@ function hasValidAdminApiKey(req: Request): boolean {
return constantTimeEqual(token, ADMIN_API_KEY);
}
-/**
- * True if `id` is a synthetic user (static admin API key or per-org WorkOS
- * API key). Synthetic users don't represent a person, so they have no
- * identity binding.
- */
-function isSyntheticUser(id: string): boolean {
- return id === 'admin_api_key' || id.startsWith('api_key_');
-}
-
/**
* Drop any cached sessions whose WorkOS user id matches one of the given
- * ids, on either auth credential (post-swap canonical or actual auth).
- * Called when an identity binding changes (mergeUsers, admin rebind) so
- * subsequent requests re-resolve identity instead of serving a stale swap.
+ * ids. This saves a provider round trip after local mutations; correctness
+ * comes from the fresh database snapshot on every request, on every instance.
*/
export function invalidateSessionsForUsers(workosUserIds: string[]): void {
if (workosUserIds.length === 0) return;
const ids = new Set(workosUserIds);
for (const [key, value] of sessionCache.entries()) {
- if (ids.has(value.user.id) || (value.user.authWorkosUserId && ids.has(value.user.authWorkosUserId))) {
+ if (ids.has(value.user.id)) {
sessionCache.delete(key);
}
}
for (const [key, value] of bearerJwtCache.entries()) {
- if (ids.has(value.user.id) || (value.user.authWorkosUserId && ids.has(value.user.authWorkosUserId))) {
+ if (ids.has(value.verified.sub)) {
bearerJwtCache.delete(key);
}
}
}
-/**
- * Resolve the identity for a WorkOS user. Sets `user.identityId` and, when
- * the authenticated user is a non-primary binding, swaps `user.id` to the
- * identity's primary workos_user_id so app-state reads land on the right
- * person. The original authenticated id is preserved on `user.authWorkosUserId`.
- *
- * Skipped for synthetic users (admin API key, WorkOS API key) — they don't
- * represent a person. Failures are swallowed: identity resolution must
- * never block an authenticated request, and a degraded request that sees
- * only the auth user's slice of data is still better than a 500.
- */
-async function attachIdentityId(user: WorkOSUser): Promise {
- if (isSyntheticUser(user.id)) return;
- try {
- const result = await getPool().query<{
- identity_id: string;
- primary_workos_user_id: string | null;
- }>(
- `SELECT iwu.identity_id, primary_iwu.workos_user_id AS primary_workos_user_id
- FROM identity_workos_users iwu
- LEFT JOIN identity_workos_users primary_iwu
- ON primary_iwu.identity_id = iwu.identity_id
- AND primary_iwu.is_primary = TRUE
- WHERE iwu.workos_user_id = $1`,
- [user.id]
- );
- const row = result.rows[0];
- if (!row) return;
-
- user.identityId = row.identity_id;
-
- if (row.primary_workos_user_id && row.primary_workos_user_id !== user.id) {
- // Non-primary binding signed in. Swap id so app-state reads see the
- // canonical person; preserve the actual auth user on authWorkosUserId.
- logger.debug(
- { authWorkosUserId: user.id, canonicalUserId: row.primary_workos_user_id, identityId: row.identity_id },
- 'Identity id-swap: routing non-primary binding to canonical user'
- );
- user.authWorkosUserId = user.id;
- user.id = row.primary_workos_user_id;
- }
- } catch (err) {
- logger.warn({ err, userId: user.id }, 'Failed to resolve identity_id');
+class InvalidAuthorizationCredentialError extends Error {}
+class ConflictingOrganizationSelectionError extends Error {}
+
+/** Every explicit selector must agree with the authenticated provider selection.
+ * Request fields cannot switch an organization-bound credential to another org.
+ * Neither memberships nor caches pick an organization. */
+function selectedOrganizationForAuthentication(req: Request, providerOrg?: string): string | null {
+ const selectors = [
+ providerOrg,
+ req.headers['x-organization-id'], req.query?.org, req.query?.organization_id,
+ req.body?.organization_id, req.body?.organizationId,
+ req.params?.orgId, req.params?.organizationId,
+ ].filter((value) => value !== undefined);
+ if (selectors.some((value) => typeof value !== 'string' || !value.trim())) {
+ throw new ConflictingOrganizationSelectionError();
}
+ const ids = new Set(selectors.map((value) => (value as string).trim()));
+ if (ids.size > 1) throw new ConflictingOrganizationSelectionError();
+ return ids.values().next().value ?? null;
}
-/**
- * Authorization fingerprint for the credential that actually authenticated
- * this session. `attachIdentityId` may swap `user.id` to the identity's
- * canonical credential, so the authenticated credential is read through
- * `getOrganizationAuthorizationUserId` — that value is stable across the
- * swap, and every identity-binding mutation bumps the epoch for each
- * credential bound to the identity, so a change always moves this value.
- *
- * Synthetic principals (admin API key, WorkOS API key) have no credential
- * row and no identity binding; they return '' and never mismatch.
- */
-async function authorizationFingerprintFor(
- user: WorkOSUser,
-): Promise {
- if (isSyntheticUser(user.id)) return '';
+/** Hydrate a new request object; never mutate the provider object in a cache. */
+async function hydrateAuthenticatedUser(user: WorkOSUser, organizationId: string | null): Promise {
+ const authenticatedUserId = getOrganizationAuthorizationUserId(user);
+ const snapshot = await loadAuthorizationSnapshot(authenticatedUserId, organizationId);
+ if (!snapshot) throw new InvalidAuthorizationCredentialError();
+ const hydrated: WorkOSUser = {
+ ...user,
+ id: snapshot.canonicalUserId,
+ authWorkosUserId: snapshot.canonicalUserId !== authenticatedUserId ? authenticatedUserId : undefined,
+ identityId: snapshot.identityId ?? undefined,
+ email: snapshot.credential.email ?? '',
+ emailVerified: snapshot.credential.emailVerified,
+ firstName: snapshot.credential.firstName?.trim() || user.firstName?.trim() || undefined,
+ lastName: snapshot.credential.lastName?.trim() || user.lastName?.trim() || undefined,
+ };
+ // Server-only authority must not leak through JSON responses or user spreads.
+ Object.defineProperty(hydrated, 'authorizationSnapshot', { value: snapshot });
+ return hydrated;
+}
+
+/** Local dev login is an explicit synthetic-auth bypass. Keep attribution useful
+ * against seeded databases without making a database mandatory for dev fixtures. */
+async function hydrateDevUser(user: WorkOSUser): Promise {
try {
- return await getAuthorizationFingerprint([getOrganizationAuthorizationUserId(user)]);
- } catch (err) {
- // Never fail authentication over the stamp: an unstamped entry reads as
- // stale on its next hit, so the session is re-validated rather than
- // served from a fingerprint we could not confirm.
- logger.warn({ err }, 'Authorization epoch stamp failed — session will not be served from cache');
- return undefined;
+ return await hydrateAuthenticatedUser(user, null);
+ } catch {
+ return user;
}
}
-/**
- * True when a cached entry still reflects persisted authorization state.
- *
- * Entries cached before an identity-binding change carry the pre-change
- * fingerprint and must not be served — on any instance, not just the one
- * that ran the mutation. A lookup failure returns false so the request
- * falls through to full re-validation rather than serving state we could
- * not confirm; that degrades the cache, it does not sign anyone out.
- */
-async function isAuthorizationFingerprintCurrent(
- user: WorkOSUser,
- stamped: string | undefined,
-): Promise {
- if (isSyntheticUser(user.id)) return true;
- try {
- return (await authorizationFingerprintFor(user)) === (stamped ?? '');
- } catch (err) {
- logger.warn({ err }, 'Authorization epoch check failed — bypassing session cache');
- return false;
+function sendAuthorizationStateError(error: unknown, res: Response, includeTransientErrors = false): boolean {
+ if (includeTransientErrors && isTransientAuthError(error)) {
+ logger.warn({ err: error }, 'Authentication service unavailable');
+ res.status(503).json({ error: 'Authentication service temporarily unavailable' });
+ return true;
+ }
+ if (error instanceof AuthorizationSnapshotUnavailableError) {
+ logger.warn({ err: error }, 'Primary authorization snapshot unavailable');
+ res.status(503).json({ error: 'Authorization service temporarily unavailable' });
+ return true;
+ }
+ if (error instanceof InvalidAuthorizationCredentialError) {
+ res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' });
+ return true;
}
+ if (error instanceof ConflictingOrganizationSelectionError) {
+ res.status(403).json({ error: 'An unambiguous organization selection is required' });
+ return true;
+ }
+ return false;
}
/**
@@ -889,7 +833,13 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
// Check for OAuth-issued user JWT (user SSO'd via AuthKit through the
// MCP OAuth flow and is now calling the REST API with that token).
- const jwtAuth = await validateWorkOSBearerJWT(req);
+ let jwtAuth: ValidatedBearerJWT | null;
+ try {
+ jwtAuth = await validateWorkOSBearerJWT(req);
+ } catch (error) {
+ if (sendAuthorizationStateError(error, res, true)) return;
+ throw error;
+ }
if (jwtAuth) {
logger.debug({ path: req.path, userId: jwtAuth.user.id }, 'Authenticated via OAuth user JWT');
req.user = jwtAuth.user;
@@ -897,8 +847,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
try {
const userBan = await checkPlatformBan(
- `user:${jwtAuth.user.id}`,
- () => bansDb.checkPlatformBan(jwtAuth.user.id),
+ `user:${getOrganizationAuthorizationUserId(jwtAuth.user)}`,
+ () => bansDb.checkPlatformBan(getOrganizationAuthorizationUserId(jwtAuth.user)),
);
if (userBan) {
logger.info({ userId: jwtAuth.user.id, banId: userBan.id }, 'User request blocked by platform ban');
@@ -908,7 +858,6 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
logger.warn({ err: banError, userId: jwtAuth.user.id, path: req.path }, 'Ban check failed — allowing request through');
}
- await attachIdentityId(req.user);
return next();
}
@@ -916,14 +865,13 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
if (DEV_MODE_ENABLED) {
const devUser = createDevUser(req);
if (devUser) {
- req.user = devUser;
+ req.user = await hydrateDevUser(devUser);
req.accessToken = 'dev-mode-token';
// Carry over dev config flags (isMember, isAdmin) so enrichUserWithMembership skips DB lookup
const devConfig = getDevUser(req);
if (devConfig) {
(req.user as unknown as Record).isMember = devConfig.isMember;
}
- await attachIdentityId(req.user);
return next();
}
// No dev session - redirect to dev login page
@@ -978,19 +926,12 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
deadSessionCache.delete(cacheKey);
}
- if (
- cached &&
- cached.expiresAt > now &&
- !(await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint))
- ) {
- // Identity bindings changed since this entry was stored. Drop it so
- // this request re-resolves identity and organization context instead
- // of inheriting pre-change authority.
- sessionCache.delete(cacheKey);
- } else if (cached && cached.expiresAt > now) {
+ if (cached && cached.expiresAt > now) {
// Cache hit - use cached session data
logger.debug({ userId: cached.user.id }, 'Using cached session');
- req.user = cached.user;
+ req.user = await hydrateAuthenticatedUser(
+ cached.user, selectedOrganizationForAuthentication(req, cached.orgId),
+ );
req.accessToken = cached.accessToken;
// If session was refreshed, update the cookie
@@ -1165,54 +1106,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
impersonator?: { email: string; reason: string | null };
};
- let firstName = result.user.firstName ?? undefined;
- let lastName = result.user.lastName ?? undefined;
-
- // Verify the user exists in our local DB. This catches stale sessions
- // from deleted/merged WorkOS accounts whose JWTs haven't expired yet.
- // Also resolves names from local DB when WorkOS doesn't provide them.
- try {
- const pool = getPool();
- const localUser = await pool.query<{
- first_name: string | null;
- last_name: string | null;
- }>(
- `SELECT first_name, last_name FROM users WHERE workos_user_id = $1`,
- [result.user.id]
- );
- if (localUser.rows.length === 0) {
- logger.warn({ userId: result.user.id, email: result.user.email, path: req.path },
- 'Authenticated user not found in local DB — forcing re-login');
- sessionCache.delete(cacheKey);
- if (deadSessionCache.size >= DEAD_SESSION_MAX_SIZE) {
- const oldest = deadSessionCache.keys().next().value;
- if (oldest) deadSessionCache.delete(oldest);
- }
- deadSessionCache.set(cacheKey, Date.now());
- if (isHtmlRequest) {
- return res.redirect(`/auth/login?return_to=${encodeURIComponent(req.originalUrl)}`);
- }
- return res.status(401).json({
- error: 'Invalid session',
- message: 'Your account could not be verified. Please log in again.',
- login_url: '/auth/login',
- });
- }
- if (!firstName?.trim()) {
- const row = localUser.rows[0];
- if (row.first_name?.trim()) firstName = row.first_name;
- if (row.last_name?.trim()) lastName = row.last_name;
- }
- } catch (err) {
- // Fail open: DB errors should not block authenticated users
- logger.warn({ err, userId: result.user.id }, 'Local user check failed — allowing request through');
- }
-
const user: WorkOSUser = {
id: result.user.id,
email: result.user.email,
- firstName,
- lastName,
+ firstName: result.user.firstName ?? undefined,
+ lastName: result.user.lastName ?? undefined,
emailVerified: result.user.emailVerified,
createdAt: result.user.createdAt,
updatedAt: result.user.updatedAt,
@@ -1227,19 +1125,20 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
);
}
- // Resolve identityId once before caching so cache hits inherit it.
- await attachIdentityId(user);
+ const hydrated = await hydrateAuthenticatedUser(
+ user, selectedOrganizationForAuthentication(req, result.organizationId),
+ );
- // Cache the validated session
+ // Retain authentication only; derived request authority is never cached.
sessionCache.set(cacheKey, {
- user,
+ user: Object.freeze(user),
accessToken: result.accessToken,
expiresAt: now + SESSION_CACHE_TTL_MS,
newSealedSession,
- authorizationFingerprint: await authorizationFingerprintFor(user),
+ orgId: result.organizationId,
});
- req.user = user;
+ req.user = hydrated;
req.accessToken = result.accessToken;
// Check platform ban for cookie-authenticated user (fail-open: DB timeout should not log users out)
@@ -1258,6 +1157,7 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
next();
} catch (error) {
+ if (sendAuthorizationStateError(error, res)) return;
if (isTransientAuthError(error)) {
logger.warn({ err: error, path: req.path }, 'Transient auth upstream failure — returning 503 without clearing session');
if (isHtmlRequest) {
@@ -1544,9 +1444,8 @@ export async function requireAdmin(req: Request, res: Response, next: NextFuncti
if (!req.user) {
const mockUser = createDevUser(req);
if (mockUser) {
- req.user = mockUser;
+ req.user = await hydrateDevUser(mockUser);
req.accessToken = 'dev-mode-token';
- await attachIdentityId(req.user);
}
}
@@ -1607,7 +1506,7 @@ export async function requireAdmin(req: Request, res: Response, next: NextFuncti
const decision = decideAAOAdminAccess(
await isWebUserAAOAdmin(req.user.id),
- req.user.email,
+ req.user.authorizationSnapshot,
);
if (!decision.isAdmin) {
@@ -1747,7 +1646,7 @@ export function createRequireWorkingGroupLeader(
// isWebUserAAOAdmin fails closed to false when its membership lookup fails.
const decision = decideAAOAdminAccess(
await isWebUserAAOAdmin(req.user.id),
- req.user.email,
+ req.user.authorizationSnapshot,
);
if (decision.isAdmin) {
@@ -1816,7 +1715,7 @@ export function createRequireWorkingGroupMember(
// isWebUserAAOAdmin fails closed to false when its membership lookup fails.
const decision = decideAAOAdminAccess(
await isWebUserAAOAdmin(req.user.id),
- req.user.email,
+ req.user.authorizationSnapshot,
);
if (decision.isAdmin) {
@@ -1856,29 +1755,26 @@ export function createRequireWorkingGroupMember(
* Native apps send: Authorization: Bearer
*/
function extractSealedSession(req: Request): string | undefined {
- // First check for cookie (web browsers)
- const sessionCookie = req.cookies['wos-session'];
- if (sessionCookie) {
- return sessionCookie;
- }
-
- // Then check Authorization header (native apps like Tauri)
+ // An explicitly supplied bearer credential takes precedence over a cookie.
+ // A failed JWT must never be retried as a sealed session or replaced by a cookie.
// Format: Authorization: Bearer
- // Note: We differentiate from WorkOS API keys by checking prefix
const authHeader = req.headers.authorization;
- if (authHeader?.startsWith('Bearer ')) {
+ if (authHeader !== undefined) {
+ if (!authHeader.startsWith('Bearer ')) return undefined;
const token = authHeader.slice(7);
- // WorkOS API keys use known prefixes, sealed sessions don't
- if (!isWorkOSApiKeyFormat(token)) {
+ if (token.trim() && !isWorkOSApiKeyFormat(token) && !looksLikeJWT(token)) {
return token;
}
+ return undefined;
}
-
- return undefined;
+ const sessionCookie = req.cookies?.['wos-session'];
+ return typeof sessionCookie === 'string' && sessionCookie ? sessionCookie : undefined;
}
/**
- * Optional auth middleware - loads user if authenticated, but doesn't require it
+ * Optional auth permits anonymous requests only when no credential is supplied.
+ * Invalid presented credentials return 401; verification or authorization
+ * outages return 503. Neither case falls back to anonymous or another credential.
* Uses in-memory cache to reduce WorkOS API calls for session refresh
* Automatically refreshes expired access tokens using the refresh token
* Supports both cookie-based auth (web) and Authorization header (native apps)
@@ -1919,7 +1815,13 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
return next();
}
- const jwtAuth = await validateWorkOSBearerJWT(req);
+ let jwtAuth: ValidatedBearerJWT | null;
+ try {
+ jwtAuth = await validateWorkOSBearerJWT(req);
+ } catch (error) {
+ if (sendAuthorizationStateError(error, res, true)) return;
+ throw error;
+ }
if (jwtAuth) {
logger.debug({ path: req.path, userId: jwtAuth.user.id }, 'Authenticated via OAuth user JWT (optional auth)');
req.user = jwtAuth.user;
@@ -1927,8 +1829,8 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
try {
const userBan = await checkPlatformBan(
- `user:${jwtAuth.user.id}`,
- () => bansDb.checkPlatformBan(jwtAuth.user.id),
+ `user:${getOrganizationAuthorizationUserId(jwtAuth.user)}`,
+ () => bansDb.checkPlatformBan(getOrganizationAuthorizationUserId(jwtAuth.user)),
);
if (userBan) {
logger.info({ userId: jwtAuth.user.id, banId: userBan.id }, 'User optional-auth request blocked by platform ban');
@@ -1938,7 +1840,6 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
logger.warn({ err: banError, userId: jwtAuth.user.id, path: req.path }, 'Ban check failed — allowing optional-auth request through');
}
- await attachIdentityId(req.user);
return next();
}
@@ -1946,22 +1847,26 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
if (DEV_MODE_ENABLED) {
const devUser = createDevUser(req);
if (devUser) {
- req.user = devUser;
+ req.user = await hydrateDevUser(devUser);
req.accessToken = 'dev-mode-token';
// Carry over dev config flags (isMember, isAdmin) so enrichUserWithMembership skips DB lookup
const devConfig = getDevUser(req);
if (devConfig) {
(req.user as unknown as Record).isMember = devConfig.isMember;
}
+ return next();
}
- // No dev session = not logged in (which is fine for optional auth)
- return next();
}
const sessionCookie = extractSealedSession(req);
+ // Only complete credential absence permits anonymous access. Keep one return
+ // for the entire branch so CodeQL recognizes this as an early-exit guard;
+ // supplied but unusable credentials still return 401 without a fallback.
if (!sessionCookie) {
- return next();
+ return req.headers.authorization !== undefined || req.cookies?.['wos-session'] !== undefined
+ ? res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' })
+ : next();
}
try {
@@ -1974,21 +1879,17 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
const deadAt = deadSessionCache.get(cacheKey);
if (deadAt) {
if (now - deadAt < DEAD_SESSION_TTL_MS) {
- return next(); // optional auth: just proceed without user
+ return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' });
}
deadSessionCache.delete(cacheKey);
}
- if (
- cached &&
- cached.expiresAt > now &&
- !(await isAuthorizationFingerprintCurrent(cached.user, cached.authorizationFingerprint))
- ) {
- sessionCache.delete(cacheKey);
- } else if (cached && cached.expiresAt > now) {
+ if (cached && cached.expiresAt > now) {
// Cache hit - use cached session data
logger.debug({ userId: cached.user.id }, 'Using cached session (optional auth)');
- req.user = cached.user;
+ req.user = await hydrateAuthenticatedUser(
+ cached.user, selectedOrganizationForAuthentication(req, cached.orgId),
+ );
req.accessToken = cached.accessToken;
// If session was refreshed, update the cookie
@@ -2019,6 +1920,9 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
cookiePassword: WORKOS_COOKIE_PASSWORD,
});
+ if (!refreshResult.authenticated && 'retryable' in refreshResult && refreshResult.retryable) {
+ return res.status(503).json({ error: 'Authentication service temporarily unavailable' });
+ }
if (refreshResult.authenticated && refreshResult.sealedSession) {
logger.debug('Session refreshed successfully (optional auth)');
newSealedSession = refreshResult.sealedSession;
@@ -2036,6 +1940,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
refreshFailed = true;
}
} catch (refreshError) {
+ if (isTransientAuthError(refreshError)) throw refreshError;
logger.debug({ err: refreshError }, 'Optional auth refresh failed');
refreshFailed = true;
}
@@ -2057,6 +1962,9 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
const sharedRefresh = await sharedSessionObj.refresh({
cookiePassword: WORKOS_COOKIE_PASSWORD,
});
+ if (!sharedRefresh.authenticated && 'retryable' in sharedRefresh && sharedRefresh.retryable) {
+ return res.status(503).json({ error: 'Authentication service temporarily unavailable' });
+ }
if (sharedRefresh.authenticated && sharedRefresh.sealedSession) {
newSealedSession = sharedRefresh.sealedSession;
setSessionCookie(res, sharedRefresh.sealedSession);
@@ -2070,6 +1978,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
result = await refreshedObj.authenticate();
}
} catch (innerRefreshErr) {
+ if (isTransientAuthError(innerRefreshErr)) throw innerRefreshErr;
logger.debug({ err: innerRefreshErr }, 'Shared session refresh also failed (optional auth)');
}
} else {
@@ -2079,6 +1988,7 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
}
} catch (dbError) {
logger.debug({ err: dbError }, 'Failed to look up shared session (optional auth)');
+ return res.status(503).json({ error: 'Authentication service temporarily unavailable' });
}
}
}
@@ -2092,6 +2002,8 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
if (oldest) deadSessionCache.delete(oldest);
}
deadSessionCache.set(cacheKey, Date.now());
+ sessionCache.delete(cacheKey);
+ return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' });
}
if (result.authenticated && 'user' in result && result.user) {
@@ -2119,26 +2031,26 @@ export async function optionalAuth(req: Request, res: Response, next: NextFuncti
);
}
- // Resolve identityId before caching — sessionCache is shared with
- // requireAuth, so skipping it here would let an optionalAuth request
- // poison subsequent requireAuth cache hits with identityId=undefined.
- await attachIdentityId(user);
+ const hydrated = await hydrateAuthenticatedUser(
+ user, selectedOrganizationForAuthentication(req, result.organizationId),
+ );
- // Cache the validated session
+ // Retain authentication only; derived request authority is never cached.
sessionCache.set(cacheKey, {
- user,
+ user: Object.freeze(user),
accessToken: result.accessToken,
expiresAt: now + SESSION_CACHE_TTL_MS,
newSealedSession,
- authorizationFingerprint: await authorizationFingerprintFor(user),
+ orgId: result.organizationId,
});
- req.user = user;
+ req.user = hydrated;
req.accessToken = result.accessToken;
}
} catch (error) {
- // Silently fail for optional auth
+ if (sendAuthorizationStateError(error, res, true)) return;
logger.debug({ err: error }, 'Optional auth failed');
+ return res.status(401).json({ error: 'Invalid session', login_url: '/auth/login' });
}
next();
diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts
index 48cb015f9f9..32795e02218 100644
--- a/server/src/middleware/rate-limit.ts
+++ b/server/src/middleware/rate-limit.ts
@@ -2,7 +2,7 @@ import rateLimit from 'express-rate-limit';
import type { IncrementResponse, Options, Store } from 'express-rate-limit';
import type { Request, Response } from 'express';
import { createLogger } from '../logger.js';
-import { isBreakGlassAdminEmail } from '../auth/admin-access.js';
+import { isBreakGlassAdmin } from '../auth/admin-access.js';
import { CachedPostgresStore, PostgresStore, type WeightedIncrementStore } from './pg-rate-limit-store.js';
const logger = createLogger('rate-limit');
@@ -101,12 +101,12 @@ export const agentCardValidationRateLimiter = rateLimit({
* env var for emergency access, matching requireAdmin semantics.
*/
async function skipForAdmins(req: Request): Promise {
- const user = (req as any).user as { id?: string; email?: string; isAdmin?: boolean } | undefined;
+ const user = (req as any).user as { id?: string; email?: string; isAdmin?: boolean; authorizationSnapshot?: import("../db/user-authorization-snapshot-db.js").AuthorizationSnapshot } | undefined;
if (!user) return false;
if (user.isAdmin === true) return true;
- if (isBreakGlassAdminEmail(user.email)) {
+ if (isBreakGlassAdmin(user.authorizationSnapshot)) {
return true;
}
diff --git a/server/src/routes/account-linking.ts b/server/src/routes/account-linking.ts
index 1e9853a3051..cfcf3a65b0d 100644
--- a/server/src/routes/account-linking.ts
+++ b/server/src/routes/account-linking.ts
@@ -2,18 +2,33 @@ import express, { Router, Request, Response } from 'express';
import crypto from 'crypto';
import rateLimit from 'express-rate-limit';
import { createLogger } from '../logger.js';
-import { requireAuth } from '../middleware/auth.js';
+import { requireAuth, type ValidatedApiKey } from '../middleware/auth.js';
import { query, getPool } from '../db/client.js';
-import { mergeUsers } from '../db/user-merge-db.js';
import { sendEmailLinkVerification } from '../notifications/email.js';
-import { getWorkos } from '../auth/workos-client.js';
import { CachedPostgresStore } from '../middleware/pg-rate-limit-store.js';
-import { isEmailUnavailable } from './account-linking-errors.js';
+import { EmailMutationError, getEmailMutationStatus, setPrimaryEmail } from '../services/email-mutation.js';
const logger = createLogger('account-linking');
const TOKEN_EXPIRY_HOURS = 24;
+async function sendEmailMutationFailure(res: Response, userId: string, error: unknown): Promise {
+ try {
+ const status = await getEmailMutationStatus(userId);
+ if (status.reconciliation_required) return res.status(503).json(status);
+ if ((error as { code?: unknown } | null)?.code === '55P03') {
+ return res.status(409).json({
+ error: 'credential_busy', retryable: true,
+ message: 'An email change is already in progress. Please retry.',
+ });
+ }
+ } catch { /* Status is unknown; do not invent a durable operation. */ }
+ return res.status(503).json({
+ error: 'Failed to update email', status_unknown: true,
+ message: 'We could not confirm the email change. Reload to check its status or contact support before trying again.',
+ });
+}
+
// Rate limiter for sending verification emails (per authenticated user)
const sendVerificationLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
@@ -36,7 +51,7 @@ const verifyViewLimiter = rateLimit({
validate: { keyGeneratorIpFallback: false },
});
-// Rate limiter for verification execution (strict — destructive action)
+// Rate limiter for verification and email mutation execution
const verifyExecuteLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
@@ -57,8 +72,10 @@ export function createAccountLinkingRouter(): Router {
// GET /api/me/linked-emails — list linked emails and pending tokens
router.get('/', requireAuth, async (req: Request, res: Response) => {
try {
- const userId = req.user!.id;
+ const userId = req.user!.authWorkosUserId ?? req.user!.id;
+ const mutationStatus = await getEmailMutationStatus(userId);
+ const credential = await query('SELECT email FROM users WHERE workos_user_id = $1', [userId]);
const aliases = await query(
`SELECT id, email, verified_at, created_at
FROM user_email_aliases
@@ -76,12 +93,14 @@ export function createAccountLinkingRouter(): Router {
);
return res.json({
- primary_email: req.user!.email,
+ credential_id: userId,
+ primary_email: credential.rows[0]?.email,
+ ...mutationStatus,
aliases: aliases.rows,
pending: pending.rows,
});
- } catch (error) {
- logger.error({ error }, 'Failed to list linked emails');
+ } catch {
+ logger.error('Failed to list linked emails');
return res.status(500).json({ error: 'Failed to list linked emails' });
}
});
@@ -89,7 +108,9 @@ export function createAccountLinkingRouter(): Router {
// POST /api/me/linked-emails — initiate email link verification
router.post('/', requireAuth, sendVerificationLimiter, async (req: Request, res: Response) => {
try {
- const userId = req.user!.id;
+ const userId = req.user!.authWorkosUserId ?? req.user!.id;
+ const mutationStatus = await getEmailMutationStatus(userId);
+ if (mutationStatus.reconciliation_required) return res.status(409).json(mutationStatus);
const { email } = req.body;
if (!email || typeof email !== 'string') {
@@ -108,7 +129,8 @@ export function createAccountLinkingRouter(): Router {
}
// Can't link your own email
- if (normalizedEmail === req.user!.email.toLowerCase()) {
+ const credential = await query('SELECT email FROM users WHERE workos_user_id = $1', [userId]);
+ if (normalizedEmail === credential.rows[0]?.email.toLowerCase()) {
return res.status(400).json({ error: 'This is already your primary email' });
}
@@ -195,7 +217,7 @@ export function createAccountLinkingRouter(): Router {
to: normalizedEmail,
token,
primaryUserName: primaryName,
- primaryEmail: req.user!.email,
+ primaryEmail: credential.rows[0]?.email ?? req.user!.email,
});
logger.info(
@@ -209,139 +231,32 @@ export function createAccountLinkingRouter(): Router {
status: 'verification_sent',
});
} catch (error) {
- logger.error({ error }, 'Failed to initiate email link');
- return res.status(500).json({ error: 'Failed to initiate email link' });
+ logger.error('Failed to initiate email link');
+ return sendEmailMutationFailure(res, req.user!.authWorkosUserId ?? req.user!.id, error);
}
});
- // PUT /api/me/linked-emails/primary — swap a linked alias to be the primary email
+ // Email ownership and the provider mutation are scoped to the credential
+ // that authenticated, even when the session also carries a canonical person.
router.put('/primary', requireAuth, verifyExecuteLimiter, async (req: Request, res: Response) => {
+ // Only a real member credential hydrated from the primary database can
+ // change email. Synthetic/API-key principals are not email credentials.
+ const snapshot = req.user?.authorizationSnapshot;
+ if ((req as Request & { apiKey?: ValidatedApiKey }).apiKey || !snapshot
+ || snapshot.authenticatedUserId !== (req.user!.authWorkosUserId ?? req.user!.id)) {
+ return res.status(403).json({ error: 'A member login is required to change primary email' });
+ }
+ const userId = snapshot.authenticatedUserId;
try {
- const userId = req.user!.id;
- const { email } = req.body;
-
- if (!email || typeof email !== 'string') {
- return res.status(400).json({ error: 'Email is required' });
- }
-
- const normalizedEmail = email.trim().toLowerCase();
- const currentPrimary = req.user!.email.toLowerCase();
-
- if (normalizedEmail === currentPrimary) {
- return res.status(400).json({ error: 'This is already your primary email' });
- }
-
- const oldPrimary = req.user!.email;
-
- // Verify the alias exists before touching WorkOS so we don't change
- // WorkOS state for an email the user hasn't actually verified.
- const aliasRead = await query(
- `SELECT email FROM user_email_aliases
- WHERE workos_user_id = $1 AND LOWER(email) = $2 AND verified_at IS NOT NULL`,
- [userId, normalizedEmail]
- );
- if (aliasRead.rows.length === 0) {
- return res.status(404).json({ error: 'Email is not linked to your account' });
- }
- const aliasEmail: string = aliasRead.rows[0].email;
-
- // WorkOS first (source of truth for auth). If WorkOS rejects, our DB is
- // unchanged and we surface a clear error.
- //
- // If WorkOS accepts but the local swap below fails, `users.email` and
- // `organization_memberships.email` will eventually re-sync via the
- // user.updated webhook. `user_email_aliases` and `person_relationships`
- // are NOT touched by the webhook, so a partial failure here can leave
- // the alias list stale (new email remains as a verified alias, old
- // primary is not recorded) and the relationship row's email lagging.
- // UNIQUE(LOWER(email)) on aliases prevents anyone else from claiming
- // the abandoned email; cleanup is manual.
- try {
- // Mutate the actually-signed-in WorkOS user's email, not the
- // post-swap canonical. Email is per-credential.
- const workosTargetUserId = req.user!.authWorkosUserId ?? userId;
- await getWorkos().userManagement.updateUser({ userId: workosTargetUserId, email: aliasEmail });
- } catch (workosError: any) {
- if (isEmailUnavailable(workosError)) {
- return res.status(409).json({ error: 'This email is already associated with another account in our auth system' });
- }
- throw workosError;
- }
-
- const pool = getPool();
- const client = await pool.connect();
- try {
- await client.query('BEGIN');
-
- // Re-lock and re-verify in case the alias changed between the read
- // above and now.
- const alias = await client.query(
- `SELECT email FROM user_email_aliases
- WHERE workos_user_id = $1 AND LOWER(email) = $2 AND verified_at IS NOT NULL
- FOR UPDATE`,
- [userId, normalizedEmail]
- );
-
- if (alias.rows.length === 0) {
- await client.query('ROLLBACK');
- // WorkOS already points at the new email; the user.updated webhook
- // will re-sync users.email and organization_memberships.email. The
- // alias table will remain stale until manual cleanup.
- logger.warn(
- { userId, normalizedEmail },
- 'Alias disappeared between WorkOS update and DB swap; users.email will reconcile via webhook, aliases need manual cleanup'
- );
- return res.status(409).json({ error: 'Email link state changed; please refresh and try again' });
- }
-
- await client.query(
- `UPDATE users SET email = $1, updated_at = NOW() WHERE workos_user_id = $2`,
- [aliasEmail, userId]
- );
-
- await client.query(
- `DELETE FROM user_email_aliases WHERE workos_user_id = $1 AND LOWER(email) = $2`,
- [userId, normalizedEmail]
- );
-
- await client.query(
- `INSERT INTO user_email_aliases (workos_user_id, email)
- VALUES ($1, $2)
- ON CONFLICT DO NOTHING`,
- [userId, oldPrimary]
- );
-
- await client.query(
- `UPDATE organization_memberships SET email = $1, updated_at = NOW()
- WHERE workos_user_id = $2
- AND email IS DISTINCT FROM $1`,
- [aliasEmail, userId]
- );
-
- await client.query(
- `UPDATE person_relationships SET email = $1, updated_at = NOW()
- WHERE workos_user_id = $2
- AND email IS DISTINCT FROM $1`,
- [aliasEmail, userId]
- );
-
- await client.query('COMMIT');
- } catch (err) {
- await client.query('ROLLBACK');
- throw err;
- } finally {
- client.release();
- }
-
- logger.info(
- { userId, oldPrimary, newPrimary: aliasEmail },
- 'Primary email changed'
- );
-
- return res.json({ status: 'primary_updated', primary_email: aliasEmail });
- } catch (error: any) {
- logger.error({ error }, 'Failed to set primary email');
- return res.status(500).json({ error: 'Failed to update primary email' });
+ const result = await setPrimaryEmail({
+ userId, email: req.body?.email, actorUserId: userId, operationId: req.body?.operation_id,
+ });
+ return res.json(result);
+ } catch (error) {
+ if (error instanceof EmailMutationError) return res.status(error.status).json(error.body);
+ // Provider exceptions may contain request headers, tokens, or bodies.
+ logger.error('Failed to set primary email');
+ return sendEmailMutationFailure(res, userId, error);
}
});
@@ -350,7 +265,7 @@ export function createAccountLinkingRouter(): Router {
/**
* Public verify endpoints — no auth required (opened from email inbox).
- * GET renders a confirmation page, POST executes the merge.
+ * GET renders a confirmation page, POST records a verified email alias.
*/
export function handleEmailLinkVerification(app: {
get: (path: string, ...handlers: any[]) => void;
@@ -395,13 +310,13 @@ export function handleEmailLinkVerification(app: {
hasMerge: !!tokenRecord.target_workos_user_id,
token: token as string,
});
- } catch (error) {
- logger.error({ error }, 'Email link verification page failed');
+ } catch {
+ logger.error('Email link verification page failed');
return renderVerifyPage(res, { success: false, message: 'Something went wrong. Please try again or contact support.' });
}
});
- // POST /verify-email-link — execute the merge (protected by FOR UPDATE lock)
+ // POST /verify-email-link — verify the alias (protected by FOR UPDATE lock)
app.post('/verify-email-link', express.urlencoded({ extended: false }), verifyExecuteLimiter, async (req: Request, res: Response) => {
const { token } = req.body;
@@ -442,6 +357,12 @@ export function handleEmailLinkVerification(app: {
return renderVerifyPage(res, { success: false, message: 'This verification link has expired. Please request a new one from your dashboard settings.' });
}
+ const mutationStatus = await getEmailMutationStatus(tokenRecord.primary_workos_user_id, client);
+ if (mutationStatus.reconciliation_required) {
+ await client.query('ROLLBACK');
+ return renderVerifyPage(res, { success: false, message: mutationStatus.message! });
+ }
+
// Mark token as processing (prevents concurrent attempts, but allows retry on failure)
await client.query(
`UPDATE email_link_tokens SET status = 'processing' WHERE id = $1`,
@@ -493,8 +414,8 @@ export function handleEmailLinkVerification(app: {
await query(
`UPDATE email_link_tokens SET status = 'pending' WHERE id = $1`,
[tokenRecord.id]
- ).catch((resetErr) => {
- logger.error({ error: resetErr, tokenId: tokenRecord.id }, 'Failed to reset token status after verify error — user cannot retry');
+ ).catch(() => {
+ logger.error({ tokenId: tokenRecord.id }, 'Failed to reset token status after verify error — user cannot retry');
});
throw verifyError;
}
@@ -507,9 +428,9 @@ export function handleEmailLinkVerification(app: {
);
return renderVerifyPage(res, { success: true, message });
- } catch (error: any) {
+ } catch {
await client.query('ROLLBACK').catch(() => {});
- logger.error({ error, errorMessage: error?.message, errorCode: error?.code }, 'Email link verification failed');
+ logger.error('Email link verification failed');
return renderVerifyPage(res, { success: false, message: 'Something went wrong during verification. Please try again or contact support.' });
} finally {
client.release();
@@ -524,7 +445,7 @@ function escapeHtml(str: string): string {
function renderConfirmPage(res: Response, opts: { targetEmail: string; hasMerge: boolean; token: string }): void {
const mergeWarning = opts.hasMerge
? `
- An existing account was found with this email. Confirming will combine the two accounts so that signing in with either email leads to the same workspace.
+ An existing account was found with this email. These accounts remain separate. Please contact support for assistance.