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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/fancy-chefs-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

fix: auto-link users to organizations by verified email domain when WorkOS membership is missing
75 changes: 75 additions & 0 deletions server/src/db/membership-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<DomainLinkResult | null> {
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;
}
}
14 changes: 13 additions & 1 deletion server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
54 changes: 36 additions & 18 deletions server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
38 changes: 35 additions & 3 deletions server/src/routes/member-profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading