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
35 changes: 34 additions & 1 deletion server/public/chat.html
Original file line number Diff line number Diff line change
Expand Up @@ -3418,6 +3418,30 @@ <h2>Ask Addie</h2>
return fetch(url, { ...options, headers, credentials: 'include' });
}

async function isOrganizationSelectionConflict(response) {
if (response.status !== 403) return false;
try {
const data = await response.clone().json();
return data?.error === 'An unambiguous organization selection is required';
} catch (e) {
return false;
}
}

async function switchSessionOrganization(organizationId) {
if (typeof organizationId !== 'string' || !organizationId.trim() || nativeAuthToken) return false;
try {
const response = await authFetch('/auth/switch-organization', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_organization_id: organizationId.trim() }),
});
return response.ok;
} catch (e) {
return false;
}
}

function getSelectedOrganizationId() {
const urlOrg = new URLSearchParams(window.location.search).get('org');
if (urlOrg) return urlOrg;
Expand Down Expand Up @@ -5377,7 +5401,7 @@ <h2>Ask Addie</h2>

try {
const selectedOrganizationId = getSelectedOrganizationId();
const response = await authFetch('/api/addie/chat/stream', {
const postStream = () => authFetch('/api/addie/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand All @@ -5395,6 +5419,15 @@ <h2>Ask Addie</h2>
retry: retrying,
}),
});
let response = await postStream();
// The org picker only changes the local selection. When the session
// is bound to another org, rebind it once and resend: auth rejected
// the request before the chat route saw it.
if (await isOrganizationSelectionConflict(response)
&& await switchSessionOrganization(selectedOrganizationId)) {
requestId = crypto.randomUUID();
response = await postStream();
}
requestId = validChatRequestId(response.headers.get('X-Request-ID')) || requestId;

if (!response.ok) {
Expand Down
7 changes: 6 additions & 1 deletion server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import { resolveUserNameWithFallbacks, sanitizeName } from "./utils/resolve-user
import { scrubCommunityAuthorizedAgents } from "./utils/community-adagents.js";
import { formatPerspectiveUrlAsMarkdownDestination, normalizePerspectiveExternalUrl } from "./utils/perspective-url.js";
import { decodeHtmlEntities } from "./utils/html-entities.js";
import { requireAuth, requireAdmin, requireGlobalAdmin, optionalAuth, invalidateSessionCache, isDevModeEnabled, getDevUser, getAvailableDevUsers, getDevSessionCookieName, encodeDevSessionCookie, DEV_USERS, type DevUserConfig } from "./middleware/auth.js";
import { requireAuth, requireAdmin, requireGlobalAdmin, optionalAuth, invalidateSessionCache, switchSessionOrganization, isDevModeEnabled, getDevUser, getAvailableDevUsers, getDevSessionCookieName, encodeDevSessionCookie, DEV_USERS, type DevUserConfig } from "./middleware/auth.js";
import { invitationRateLimiter, brandCreationRateLimiter, notificationRateLimiter, emailPrefsRateLimiter, adminContentWriteRateLimiter, newsletterSubscribeRateLimiter, newsletterConfirmRateLimiter, agentCardValidationRateLimiter } from "./middleware/rate-limit.js";
import { findOrCreateUserByEmail } from "./auth/workos-client.js";
import { sendNewsletterConfirmation } from "./notifications/email.js";
Expand Down Expand Up @@ -8447,6 +8447,11 @@ ${p.category ? `<category>${p.category}</category>\n` : ''}<url>${publishedUrl}<
});


// POST /auth/switch-organization - Rebind the WorkOS session to another org
// the user belongs to. Client org pickers only change local selection, and
// an explicit selector that differs from the session org is rejected.
this.app.post('/auth/switch-organization', requireAuth, switchSessionOrganization);

// GET /auth/logout - Clear session and redirect
this.app.get('/auth/logout', async (req, res) => {
// Dev mode: clear dev-session cookie and redirect to home
Expand Down
56 changes: 56 additions & 0 deletions server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,62 @@ function sendAuthorizationStateError(error: unknown, res: Response, includeTrans
return false;
}

/**
* Rebind the caller's sealed WorkOS session to another organization.
* Request fields never select authority on their own: WorkOS verifies the
* membership during the refresh, and later requests carry the new provider org.
* Mount behind requireAuth. The target uses its own field so it is not read as
* a conflicting organization selector by the auth middleware.
*/
export async function switchSessionOrganization(req: Request, res: Response) {
const target = req.body?.target_organization_id;
if (typeof target !== 'string' || !target.trim()) {
return res.status(400).json({ error: 'target_organization_id is required' });
}
const sessionCookie = req.headers.authorization === undefined ? req.cookies?.['wos-session'] : undefined;
if (!sessionCookie) {
return res.status(400).json({ error: 'Organization switching requires a browser session' });
}

const cacheKey = hashSessionCookie(sessionCookie);
try {
// The cookie's refresh token may already be rotated by a concurrent refresh.
const current = sessionCache.get(cacheKey)?.newSealedSession
?? await getRefreshedSession(cacheKey)
?? sessionCookie;
const result = await workos.userManagement.loadSealedSession({
sessionData: current,
cookiePassword: WORKOS_COOKIE_PASSWORD,
}).refresh({ cookiePassword: WORKOS_COOKIE_PASSWORD, organizationId: target.trim() });

if (!result.authenticated || !result.sealedSession) {
if ('retryable' in result && result.retryable) {
return res.status(503).json({ error: 'Authentication service temporarily unavailable' });
}
logger.info(
{ userId: req.user?.id, reason: 'reason' in result ? result.reason : undefined },
'Session organization switch rejected',
);
return res.status(403).json({ error: 'Unable to switch to that organization' });
}

sessionCache.delete(cacheKey);
setSessionCookie(res, result.sealedSession);
// Other instances holding the old cookie adopt the switched session.
storeRefreshedSession(cacheKey, result.sealedSession).catch((err) => {
logger.error({ err }, 'Failed to store switched session — other instances may force re-auth');
});
return res.json({ organization_id: target.trim() });
} catch (error) {
if (isTransientAuthError(error)) {
logger.warn({ err: error }, 'Authentication service unavailable during organization switch');
return res.status(503).json({ error: 'Authentication service temporarily unavailable' });
}
logger.warn({ err: error, userId: req.user?.id }, 'Session organization switch failed');
return res.status(403).json({ error: 'Unable to switch to that organization' });
}
}

/**
* Middleware to require authentication
* Checks for WorkOS session cookie and loads user info
Expand Down
1 change: 1 addition & 0 deletions server/tests/integration/content-my-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ vi.mock('../../src/middleware/auth.js', () => {
invalidateSessionCache: vi.fn(),
invalidateBanCache: vi.fn(),
invalidateSessionsForUsers: vi.fn(),
switchSessionOrganization: vi.fn(),
isDevModeEnabled: () => false,
getDevUser: () => null,
getAvailableDevUsers: () => ({}),
Expand Down
1 change: 1 addition & 0 deletions server/tests/integration/event-speakers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ vi.mock('../../src/middleware/auth.js', () => {
invalidateSessionCache: vi.fn(),
invalidateBanCache: vi.fn(),
invalidateSessionsForUsers: vi.fn(),
switchSessionOrganization: vi.fn(),
isDevModeEnabled: () => false,
getDevUser: () => null,
getAvailableDevUsers: () => ({}),
Expand Down
1 change: 1 addition & 0 deletions server/tests/integration/events-draft-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ vi.mock('../../src/middleware/auth.js', () => {
invalidateSessionCache: vi.fn(),
invalidateBanCache: vi.fn(),
invalidateSessionsForUsers: vi.fn(),
switchSessionOrganization: vi.fn(),
isDevModeEnabled: () => false,
getDevUser: () => null,
getAvailableDevUsers: () => ({}),
Expand Down
1 change: 1 addition & 0 deletions server/tests/integration/perspective-assets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ vi.mock('../../src/middleware/auth.js', () => {
invalidateSessionCache: vi.fn(),
invalidateBanCache: vi.fn(),
invalidateSessionsForUsers: vi.fn(),
switchSessionOrganization: vi.fn(),
isDevModeEnabled: () => false,
getDevUser: () => null,
getAvailableDevUsers: () => ({}),
Expand Down
56 changes: 52 additions & 4 deletions server/tests/unit/chat-request-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ const sse = (event: string, body: unknown) => new Response(
{ headers: { 'Content-Type': 'text/event-stream', 'X-Request-ID': id } },
);

async function openChat(reply: (attempt: number) => Response, organization?: string, csrfRetry = false) {
async function openChat(
reply: (attempt: number) => Response, organization?: string, csrfRetry = false,
switchReply: () => Response = () => json({}, 404),
) {
const requests: Array<{ body: Record<string, unknown>; headers: Headers }> = [];
const switches: Array<Record<string, unknown>> = [];
dom = new JSDOM(html, {
url: 'https://example.test/chat', runScripts: 'dangerously', pretendToBeVisual: true,
beforeParse(window) {
Expand All @@ -34,6 +38,10 @@ async function openChat(reply: (attempt: number) => Response, organization?: str
if (url.startsWith('/api/me/addie-home')) return json({ html: '', css: '' });
if (url === '/api/addie/chat/threads') return json({ conversations: [] });
if (url === '/api/si/sessions/user') return json({ sessions: [] });
if (url === '/auth/switch-organization') {
switches.push(JSON.parse(String(options?.body)));
return switchReply();
}
if (url === '/api/addie/chat/stream') {
requests.push({ body: JSON.parse(String(options?.body)), headers: new Headers(options?.headers) });
return reply(requests.length);
Expand All @@ -51,19 +59,21 @@ async function openChat(reply: (attempt: number) => Response, organization?: str
await vi.waitFor(() => expect((window.document.getElementById('sendButton') as HTMLButtonElement).disabled).toBe(false));
window.document.getElementById('sendButton')!.click();
await vi.waitFor(() => expect(requests.length).toBeGreaterThan(0));
return { document: window.document, requests };
return { document: window.document, requests, switches };
}

describe('chat requests and actionable failure UI', () => {
it.each([undefined, '', ' '])('omits an absent/empty organization selector (%s)', async (org) => {
const { requests } = await openChat(() => json({}, 403), org);
const { document, requests } = await openChat(() => json({}, 403), org);
await vi.waitFor(() => expect(document.querySelector('.chat-request-error')).not.toBeNull());
expect(requests[0].body).not.toHaveProperty('organization_id');
expect(requests[0].headers.get('X-Request-ID')).toMatch(/^[0-9a-f-]{36}$/);
expect(requests[0].body.client_request_id).not.toBe(requests[0].headers.get('X-Request-ID'));
});

it('retains a selected organization without inferring another one', async () => {
const { requests } = await openChat(() => json({}, 403), 'org_selected');
const { document, requests } = await openChat(() => json({}, 403), 'org_selected');
await vi.waitFor(() => expect(document.querySelector('.chat-request-error')).not.toBeNull());
expect(requests[0].body.organization_id).toBe('org_selected');
});

Expand Down Expand Up @@ -127,6 +137,44 @@ describe('chat requests and actionable failure UI', () => {
expect(document.querySelector('.chat-request-reference')?.textContent).toContain(id);
});

const conflict = () => json({ error: 'An unambiguous organization selection is required' }, 403, { 'X-Request-ID': id });

it('rebinds the session to the selected organization and resends once', async () => {
const { document, requests, switches } = await openChat(
(attempt) => attempt === 1 ? conflict() : sse('text', { text: 'Hello from your organization' }),
'org_selected', false, () => json({ organization_id: 'org_selected' }),
);
await vi.waitFor(() => expect(requests).toHaveLength(2));
// The retried stream is the one consumed: its EOF offers recovery, not the org error.
await vi.waitFor(() => expect(document.querySelector('.reply-recovery__action')).not.toBeNull());
expect(switches).toEqual([{ target_organization_id: 'org_selected' }]);
expect(requests).toHaveLength(2);
expect(requests[1].body).toEqual(requests[0].body);
expect(requests[1].headers.get('X-Request-ID')).not.toBe(requests[0].headers.get('X-Request-ID'));
expect(document.querySelector('.chat-request-error')?.textContent ?? '').not.toContain('Choose your organization again');
});

it('shows the organization error when the session cannot be rebound', async () => {
const { document, requests, switches } = await openChat(
conflict, 'org_selected', false, () => json({ error: 'Unable to switch to that organization' }, 403),
);
await vi.waitFor(() => expect(document.querySelector('.chat-request-error')?.textContent).toContain('Choose your organization again'));
expect(switches).toHaveLength(1);
expect(requests).toHaveLength(1);
});

it('does not switch organizations without a selection or for other 403s', async () => {
const withoutSelection = await openChat(conflict);
await vi.waitFor(() => expect(withoutSelection.document.querySelector('.chat-request-error')).not.toBeNull());
expect(withoutSelection.switches).toHaveLength(0);
dom.window.close();

const otherForbidden = await openChat(() => json({ error: 'Forbidden' }, 403), 'org_selected');
await vi.waitFor(() => expect(otherForbidden.document.querySelector('.chat-request-error')).not.toBeNull());
expect(otherForbidden.switches).toHaveLength(0);
expect(otherForbidden.requests).toHaveLength(1);
});

it('retries a CSRF cookie-expiry 403 once using its fresh token and preserves correlation', async () => {
const token = 'a'.repeat(64);
const { document, requests } = await openChat((attempt) => attempt === 1
Expand Down
119 changes: 119 additions & 0 deletions server/tests/unit/session-organization-switch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/** Switching orgs rebinds the WorkOS session; request fields never grant authority. */
import { createHash } from 'node:crypto';
import express from 'express';
import supertest from 'supertest';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({
loadSealedSession: vi.fn(), refresh: vi.fn(),
getRefreshedSession: vi.fn(), storeRefreshedSession: vi.fn(),
}));
vi.hoisted(() => {
process.env.DEV_USER_EMAIL = '';
process.env.DEV_USER_ID = '';
process.env.WORKOS_API_KEY ??= 'sk_test';
process.env.WORKOS_CLIENT_ID ??= 'client_test';
process.env.WORKOS_COOKIE_PASSWORD ??= 'placeholder-cookie-password-32-bytes-min';
});
vi.mock('@workos-inc/node', () => ({
WorkOS: vi.fn(function WorkOS() {
return {
userManagement: { loadSealedSession: mocks.loadSealedSession },
apiKeys: { createValidation: vi.fn() },
};
}),
}));
vi.mock('../../src/db/session-refresh-db.js', () => ({
getRefreshedSession: mocks.getRefreshedSession,
storeRefreshedSession: mocks.storeRefreshedSession,
cleanExpiredRefreshes: vi.fn().mockResolvedValue(0),
}));
import { stopAuthTimers, switchSessionOrganization } from '../../src/middleware/auth.js';

const COOKIE = 'sealed-session-bound-to-org-pinnacle';
const hash = (value: string) => createHash('sha256').update(value).digest('hex');

function app(headers: Record<string, string> = {}, cookie: string | null = COOKIE) {
const server = express();
server.use(express.json());
server.use((req, _res, next) => {
req.cookies = cookie === null ? {} : { 'wos-session': cookie };
Object.assign(req.headers, headers);
next();
});
server.post('/auth/switch-organization', switchSessionOrganization);
return supertest(server);
}

beforeEach(() => {
vi.clearAllMocks();
mocks.loadSealedSession.mockReturnValue({ refresh: mocks.refresh });
mocks.getRefreshedSession.mockResolvedValue(undefined);
mocks.storeRefreshedSession.mockResolvedValue(undefined);
mocks.refresh.mockResolvedValue({ authenticated: true, sealedSession: 'sealed-session-bound-to-org-other' });
});
afterAll(() => stopAuthTimers());

describe('switchSessionOrganization', () => {
it('asks WorkOS to rebind the session and replaces the cookie', async () => {
const response = await app().post('/auth/switch-organization').send({ target_organization_id: ' org_other ' });

expect(response.status).toBe(200);
expect(response.body).toEqual({ organization_id: 'org_other' });
expect(mocks.loadSealedSession).toHaveBeenCalledWith(expect.objectContaining({ sessionData: COOKIE }));
expect(mocks.refresh).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 'org_other' }));
expect(response.headers['set-cookie']?.[0]).toMatch(/^wos-session=sealed-session-bound-to-org-other;.*HttpOnly/);
expect(mocks.storeRefreshedSession).toHaveBeenCalledWith(hash(COOKIE), 'sealed-session-bound-to-org-other');
});

it('refreshes from a session another instance already rotated', async () => {
mocks.getRefreshedSession.mockResolvedValue('rotated-sealed-session');
await app().post('/auth/switch-organization').send({ target_organization_id: 'org_other' });
expect(mocks.loadSealedSession).toHaveBeenCalledWith(expect.objectContaining({ sessionData: 'rotated-sealed-session' }));
});

it.each([{}, { target_organization_id: '' }, { target_organization_id: ' ' }, { target_organization_id: ['org_other'] }])(
'rejects a missing or unusable target %j', async (body) => {
const response = await app().post('/auth/switch-organization').send(body);
expect(response.status).toBe(400);
expect(mocks.refresh).not.toHaveBeenCalled();
},
);

it('does not rebind bearer-authenticated callers', async () => {
const response = await app({ authorization: 'Bearer sk_org_key' })
.post('/auth/switch-organization').send({ target_organization_id: 'org_other' });
expect(response.status).toBe(400);
expect(mocks.refresh).not.toHaveBeenCalled();
});

it('requires a browser session cookie', async () => {
const response = await app({}, null).post('/auth/switch-organization').send({ target_organization_id: 'org_other' });
expect(response.status).toBe(400);
expect(mocks.refresh).not.toHaveBeenCalled();
});

it('keeps the current session when WorkOS rejects the organization', async () => {
mocks.refresh.mockResolvedValue({ authenticated: false, reason: 'invalid_grant' });
const response = await app().post('/auth/switch-organization').send({ target_organization_id: 'org_not_mine' });

expect(response.status).toBe(403);
expect(response.body).toEqual({ error: 'Unable to switch to that organization' });
expect(response.headers['set-cookie']).toBeUndefined();
expect(mocks.storeRefreshedSession).not.toHaveBeenCalled();
});

it('reports a retryable WorkOS failure as unavailable', async () => {
mocks.refresh.mockResolvedValue({ authenticated: false, reason: 'network', retryable: true });
const response = await app().post('/auth/switch-organization').send({ target_organization_id: 'org_other' });
expect(response.status).toBe(503);
expect(response.headers['set-cookie']).toBeUndefined();
});

it('does not leak provider errors', async () => {
mocks.refresh.mockRejectedValue(new Error('WorkOS said: membership om_123 missing'));
const response = await app().post('/auth/switch-organization').send({ target_organization_id: 'org_other' });
expect(response.status).toBe(403);
expect(JSON.stringify(response.body)).not.toContain('om_123');
});
});
Loading