From 95bb2dd3a541aca4f3233bf0f30f0f7cbab9c3e9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 14 Apr 2026 14:22:34 +0200 Subject: [PATCH 1/2] fix: auto-link users to orgs by verified domain when WorkOS membership missing When a user authenticates but has no WorkOS organization memberships, check whether their email domain matches a verified domain on an org with an active subscription. If so, create the membership in WorkOS automatically. This closes a gap where subscriptions were provisioned without the corresponding WorkOS membership (e.g. ResponsiveAds escalation #231). Applied to /api/me, GET/POST/PUT /api/me/member-profile routes. Includes integration tests for the autoLinkByVerifiedDomain function. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fancy-chefs-behave.md | 4 + server/src/db/membership-db.ts | 75 +++++++++ server/src/http.ts | 14 +- server/src/routes/member-profiles.ts | 38 ++++- .../integration/membership-webhook.test.ts | 149 +++++++++++++++++- 5 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 .changeset/fancy-chefs-behave.md diff --git a/.changeset/fancy-chefs-behave.md b/.changeset/fancy-chefs-behave.md new file mode 100644 index 0000000000..db8eb5f7ff --- /dev/null +++ b/.changeset/fancy-chefs-behave.md @@ -0,0 +1,4 @@ +--- +--- + +fix: auto-link users to organizations by verified email domain when WorkOS membership is missing diff --git a/server/src/db/membership-db.ts b/server/src/db/membership-db.ts index 85fdcd0972..2884b0ca56 100644 --- a/server/src/db/membership-db.ts +++ b/server/src/db/membership-db.ts @@ -5,6 +5,7 @@ * by integration tests against a real PostgreSQL instance. */ +import type { WorkOS } from '@workos-inc/node'; import { getPool } from './client.js'; import { createLogger } from '../logger.js'; @@ -196,3 +197,77 @@ export async function setMembershipRole( [userId, organizationId, role], ); } + +// ── Auto-link by verified domain ──────────────────────────────────── + +export interface DomainLinkResult { + organizationId: string; + organizationName: string; + role: string; +} + +/** + * When a user has no WorkOS organization memberships, check whether their + * email domain matches a verified domain on an organization with an active + * subscription. If so, create the WorkOS membership automatically. + * + * This closes the gap where a subscription is purchased for an org but the + * user was never added as a member in WorkOS (e.g. webhook failure, manual + * provisioning that skipped the membership step). + */ +export async function autoLinkByVerifiedDomain( + workos: WorkOS, + userId: string, + email: string, +): Promise { + const pool = getPool(); + const emailDomain = email.split('@')[1]?.toLowerCase(); + if (!emailDomain) return null; + + // Find an org with a verified domain matching the user's email and an active subscription + const result = await pool.query<{ + workos_organization_id: string; + org_name: string; + has_admin: boolean; + }>(` + SELECT + od.workos_organization_id, + o.name AS org_name, + EXISTS ( + SELECT 1 FROM organization_memberships om + WHERE om.workos_organization_id = od.workos_organization_id + AND om.role IN ('admin', 'owner') + ) AS has_admin + FROM organization_domains od + JOIN organizations o ON o.workos_organization_id = od.workos_organization_id + WHERE LOWER(od.domain) = $1 + AND od.verified = true + AND o.subscription_status = 'active' + AND o.subscription_canceled_at IS NULL + LIMIT 1 + `, [emailDomain]); + + if (result.rows.length === 0) return null; + + const { workos_organization_id: orgId, org_name: orgName, has_admin: hasAdmin } = result.rows[0]; + const role = hasAdmin ? 'member' : 'owner'; + + try { + await workos.userManagement.createOrganizationMembership({ + userId, + organizationId: orgId, + roleSlug: role, + }); + + logger.info({ userId, email, orgId, orgName, role }, 'Auto-linked user to organization via verified domain'); + return { organizationId: orgId, organizationName: orgName, role }; + } catch (err: any) { + if (err?.code === 'organization_membership_already_exists') { + // Membership exists but wasn't returned by list — return as success + logger.info({ userId, orgId }, 'Auto-link skipped: membership already exists in WorkOS'); + return { organizationId: orgId, organizationName: orgName, role: 'member' }; + } + logger.warn({ err, userId, orgId }, 'Failed to auto-link user to organization'); + return null; + } +} diff --git a/server/src/http.ts b/server/src/http.ts index 49dbcf9e7f..37c201730d 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -36,6 +36,7 @@ import { PropertyDatabase } from "./db/property-db.js"; import * as manifestRefsDb from "./db/manifest-refs-db.js"; import { JoinRequestDatabase } from "./db/join-request-db.js"; import { SlackDatabase } from "./db/slack-db.js"; +import { autoLinkByVerifiedDomain } from "./db/membership-db.js"; import { syncSlackUsers, getSyncStatus, tryAutoLinkWebsiteUserToSlack } from "./slack/sync.js"; import { isSlackConfigured, testSlackConnection } from "./slack/client.js"; import { handleSlashCommand } from "./slack/commands.js"; @@ -6600,11 +6601,22 @@ Disallow: /api/admin/ } // Get user's WorkOS organization memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ + let memberships = await workos!.userManagement.listOrganizationMemberships({ userId: user.id, statuses: ['active'], }); + // Auto-link: if no memberships, check for verified domain match + if (memberships.data.length === 0) { + const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); + if (linked) { + memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + statuses: ['active'], + }); + } + } + // Map memberships to organization details with roles // Fetch organization details separately since membership.organization may be undefined const organizations = await Promise.all( diff --git a/server/src/routes/member-profiles.ts b/server/src/routes/member-profiles.ts index 69e0bb142a..4507e5fc4c 100644 --- a/server/src/routes/member-profiles.ts +++ b/server/src/routes/member-profiles.ts @@ -20,6 +20,7 @@ import { BrandDatabase, resolveBrandFromJson } from "../db/brand-db.js"; import { BrandManager } from "../brand-manager.js"; import { OrganizationDatabase } from "../db/organization-db.js"; import { OrgKnowledgeDatabase } from "../db/org-knowledge-db.js"; +import { autoLinkByVerifiedDomain } from "../db/membership-db.js"; import { AAO_HOST } from "../config/aao.js"; import { VALID_MEMBER_OFFERINGS } from "../types.js"; import type { MemberBrandInfo } from "../types.js"; @@ -102,10 +103,21 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro } // Get user's organization memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ + let memberships = await workos!.userManagement.listOrganizationMemberships({ userId: user.id, }); + // Auto-link: if no memberships, check for verified domain match + if (memberships.data.length === 0) { + const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); + if (linked) { + // Re-fetch memberships after auto-link + memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); + } + } + if (memberships.data.length === 0) { logger.info({ userId: user.id, durationMs: Date.now() - startTime }, 'GET /api/me/member-profile: no organization'); return res.status(404).json({ @@ -221,10 +233,20 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro logger.info({ userId: user.id, orgId: targetOrgId }, 'POST /api/me/member-profile: dev mode bypass'); } else { // Get user's organization memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ + let memberships = await workos!.userManagement.listOrganizationMemberships({ userId: user.id, }); + // Auto-link: if no memberships, check for verified domain match + if (memberships.data.length === 0) { + const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); + if (linked) { + memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); + } + } + if (memberships.data.length === 0) { return res.status(404).json({ error: 'No organization', @@ -390,10 +412,20 @@ export function createMemberProfileRouter(config: MemberProfileRoutesConfig): Ro logger.info({ userId: user.id, orgId: targetOrgId }, 'PUT /api/me/member-profile: dev mode bypass'); } else { // Get user's organization memberships - const memberships = await workos!.userManagement.listOrganizationMemberships({ + let memberships = await workos!.userManagement.listOrganizationMemberships({ userId: user.id, }); + // Auto-link: if no memberships, check for verified domain match + if (memberships.data.length === 0) { + const linked = await autoLinkByVerifiedDomain(workos!, user.id, user.email); + if (linked) { + memberships = await workos!.userManagement.listOrganizationMemberships({ + userId: user.id, + }); + } + } + if (memberships.data.length === 0) { return res.status(404).json({ error: 'No organization', diff --git a/server/tests/integration/membership-webhook.test.ts b/server/tests/integration/membership-webhook.test.ts index d6cf70b9bb..bbc505a7f4 100644 --- a/server/tests/integration/membership-webhook.test.ts +++ b/server/tests/integration/membership-webhook.test.ts @@ -6,7 +6,7 @@ * broke all organization_membership webhooks in production). */ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { initializeDatabase, closeDatabase } from '../../src/db/client.js'; import { runMigrations } from '../../src/db/migrate.js'; import { @@ -15,10 +15,13 @@ import { consumeInvitationSeatType, findSuccessorForPromotion, setMembershipRole, + autoLinkByVerifiedDomain, } from '../../src/db/membership-db.js'; +import type { WorkOS } from '@workos-inc/node'; import type { Pool } from 'pg'; const TEST_ORG_ID = 'org_webhook_membership_test'; +const TEST_AUTOLINK_ORG_ID = 'org_autolink_test'; const TEST_USER_1 = 'user_wh_test_1'; const TEST_USER_2 = 'user_wh_test_2'; @@ -367,4 +370,148 @@ describe('Membership webhook DB operations', () => { expect(row.rows[0].role).toBe('member'); }); }); + + // ========================================================================= + // AUTO-LINK BY VERIFIED DOMAIN + // ========================================================================= + + describe('autoLinkByVerifiedDomain', () => { + const AUTOLINK_USER = 'user_autolink_1'; + + beforeEach(async () => { + await pool.query('DELETE FROM organization_memberships WHERE workos_organization_id = $1', [TEST_AUTOLINK_ORG_ID]); + await pool.query('DELETE FROM organization_domains WHERE workos_organization_id = $1', [TEST_AUTOLINK_ORG_ID]); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [TEST_AUTOLINK_ORG_ID]); + }); + + afterAll(async () => { + await pool.query('DELETE FROM organization_memberships WHERE workos_organization_id = $1', [TEST_AUTOLINK_ORG_ID]); + await pool.query('DELETE FROM organization_domains WHERE workos_organization_id = $1', [TEST_AUTOLINK_ORG_ID]); + await pool.query('DELETE FROM organizations WHERE workos_organization_id = $1', [TEST_AUTOLINK_ORG_ID]); + }); + + function makeWorkOSMock(opts?: { shouldFail?: boolean; errorCode?: string }) { + return { + userManagement: { + createOrganizationMembership: opts?.shouldFail + ? vi.fn().mockRejectedValue(Object.assign(new Error('fail'), { code: opts.errorCode })) + : vi.fn().mockResolvedValue({ id: 'om_auto_1' }), + }, + } as unknown as WorkOS; + } + + async function seedOrgWithVerifiedDomain(domain: string, subscriptionStatus = 'active', canceled = false) { + await pool.query( + `INSERT INTO organizations (workos_organization_id, name, subscription_status, subscription_canceled_at, created_at, updated_at) + VALUES ($1, 'AutoLink Corp', $2, $3, NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO UPDATE SET subscription_status = $2, subscription_canceled_at = $3`, + [TEST_AUTOLINK_ORG_ID, subscriptionStatus, canceled ? new Date() : null], + ); + await pool.query( + `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) + VALUES ($1, $2, true, true, 'workos', NOW(), NOW()) + ON CONFLICT (domain) DO UPDATE SET verified = true`, + [TEST_AUTOLINK_ORG_ID, domain], + ); + } + + it('creates membership when email domain matches verified domain with active subscription', async () => { + await seedOrgWithVerifiedDomain('autolink.com'); + const workos = makeWorkOSMock(); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + + expect(result).not.toBeNull(); + expect(result!.organizationId).toBe(TEST_AUTOLINK_ORG_ID); + expect(result!.organizationName).toBe('AutoLink Corp'); + expect(workos.userManagement.createOrganizationMembership).toHaveBeenCalledWith({ + userId: AUTOLINK_USER, + organizationId: TEST_AUTOLINK_ORG_ID, + roleSlug: 'owner', // no existing admin/owner + }); + }); + + it('assigns member role when org already has an admin', async () => { + await seedOrgWithVerifiedDomain('autolink.com'); + // Add an existing owner + await pool.query( + `INSERT INTO organization_memberships (workos_user_id, workos_organization_id, email, role, seat_type, created_at, updated_at, synced_at) + VALUES ('user_existing_owner', $1, 'boss@autolink.com', 'owner', 'contributor', NOW(), NOW(), NOW())`, + [TEST_AUTOLINK_ORG_ID], + ); + const workos = makeWorkOSMock(); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + + expect(result).not.toBeNull(); + expect(workos.userManagement.createOrganizationMembership).toHaveBeenCalledWith( + expect.objectContaining({ roleSlug: 'member' }), + ); + }); + + it('returns null when no matching verified domain exists', async () => { + const workos = makeWorkOSMock(); + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@nomatch.com'); + expect(result).toBeNull(); + expect(workos.userManagement.createOrganizationMembership).not.toHaveBeenCalled(); + }); + + it('returns null when domain exists but subscription is not active', async () => { + await seedOrgWithVerifiedDomain('autolink.com', 'canceled'); + const workos = makeWorkOSMock(); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + expect(result).toBeNull(); + }); + + it('returns null when subscription is active but canceled', async () => { + await seedOrgWithVerifiedDomain('autolink.com', 'active', true); + const workos = makeWorkOSMock(); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + expect(result).toBeNull(); + }); + + it('returns null when domain exists but is not verified', async () => { + await pool.query( + `INSERT INTO organizations (workos_organization_id, name, subscription_status, created_at, updated_at) + VALUES ($1, 'Unverified Corp', 'active', NOW(), NOW()) + ON CONFLICT (workos_organization_id) DO NOTHING`, + [TEST_AUTOLINK_ORG_ID], + ); + await pool.query( + `INSERT INTO organization_domains (workos_organization_id, domain, verified, is_primary, source, created_at, updated_at) + VALUES ($1, 'unverified.com', false, true, 'manual', NOW(), NOW()) + ON CONFLICT (domain) DO UPDATE SET verified = false`, + [TEST_AUTOLINK_ORG_ID], + ); + const workos = makeWorkOSMock(); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@unverified.com'); + expect(result).toBeNull(); + }); + + it('handles membership_already_exists gracefully', async () => { + await seedOrgWithVerifiedDomain('autolink.com'); + const workos = makeWorkOSMock({ shouldFail: true, errorCode: 'organization_membership_already_exists' }); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + expect(result).not.toBeNull(); + expect(result!.organizationId).toBe(TEST_AUTOLINK_ORG_ID); + }); + + it('returns null on other WorkOS errors', async () => { + await seedOrgWithVerifiedDomain('autolink.com'); + const workos = makeWorkOSMock({ shouldFail: true, errorCode: 'internal_error' }); + + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'matt@autolink.com'); + expect(result).toBeNull(); + }); + + it('handles email with no domain gracefully', async () => { + const workos = makeWorkOSMock(); + const result = await autoLinkByVerifiedDomain(workos, AUTOLINK_USER, 'nodomain'); + expect(result).toBeNull(); + }); + }); }); From 6a06126a184edb36d5d0f1017255dc94f296ea44 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 14 Apr 2026 15:48:44 +0200 Subject: [PATCH 2/2] fix: reject sessions for users not found in local DB WorkOS session JWTs are validated locally without an API call, so a deleted/merged user's JWT can remain valid until expiry. This caused "not a member" errors when users authenticated with stale sessions from cleaned-up duplicate accounts. On session cache miss, verify the user exists in the local users table. If not found, kill the session and force re-login. Fails open on DB errors to avoid blocking auth during outages. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/src/middleware/auth.ts | 54 +++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 2c34e1ad84..730597352a 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -748,26 +748,44 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio let firstName = result.user.firstName ?? undefined; let lastName = result.user.lastName ?? undefined; - // When WorkOS doesn't provide a name, resolve from our DB (which may have - // names backfilled from Slack or previous profile updates) - if (!firstName?.trim()) { - try { - const pool = getPool(); - const nameResult = 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 (nameResult.rows.length > 0) { - const row = nameResult.rows[0]; - if (row.first_name?.trim()) firstName = row.first_name; - if (row.last_name?.trim()) lastName = row.last_name; + // 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)}`); } - } catch (err) { - logger.warn({ err, userId: result.user.id }, 'Name lookup failed — using WorkOS values'); + 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 = {