diff --git a/server/public/chat.html b/server/public/chat.html
index 3c00f184403..ba0ee210dc1 100644
--- a/server/public/chat.html
+++ b/server/public/chat.html
@@ -3418,6 +3418,30 @@
Ask Addie
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;
@@ -5377,7 +5401,7 @@ Ask Addie
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',
@@ -5395,6 +5419,15 @@ Ask Addie
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) {
diff --git a/server/src/http.ts b/server/src/http.ts
index bf44b11c815..a43a1b460f6 100644
--- a/server/src/http.ts
+++ b/server/src/http.ts
@@ -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";
@@ -8447,6 +8447,11 @@ ${p.category ? `${p.category}\n` : ''}${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
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index b33cc04c1ef..701134ae643 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -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
diff --git a/server/tests/integration/content-my-content.test.ts b/server/tests/integration/content-my-content.test.ts
index bcd5dbe1190..138b6d7f3a3 100644
--- a/server/tests/integration/content-my-content.test.ts
+++ b/server/tests/integration/content-my-content.test.ts
@@ -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: () => ({}),
diff --git a/server/tests/integration/event-speakers.test.ts b/server/tests/integration/event-speakers.test.ts
index 0a19a7ba00b..12812f61b41 100644
--- a/server/tests/integration/event-speakers.test.ts
+++ b/server/tests/integration/event-speakers.test.ts
@@ -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: () => ({}),
diff --git a/server/tests/integration/events-draft-preview.test.ts b/server/tests/integration/events-draft-preview.test.ts
index fe4193ea5cc..75e5434c110 100644
--- a/server/tests/integration/events-draft-preview.test.ts
+++ b/server/tests/integration/events-draft-preview.test.ts
@@ -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: () => ({}),
diff --git a/server/tests/integration/perspective-assets.test.ts b/server/tests/integration/perspective-assets.test.ts
index ab6e03cce61..617eb0ca3da 100644
--- a/server/tests/integration/perspective-assets.test.ts
+++ b/server/tests/integration/perspective-assets.test.ts
@@ -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: () => ({}),
diff --git a/server/tests/unit/chat-request-errors.test.ts b/server/tests/unit/chat-request-errors.test.ts
index 205e111f41d..f39ac6e6673 100644
--- a/server/tests/unit/chat-request-errors.test.ts
+++ b/server/tests/unit/chat-request-errors.test.ts
@@ -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; headers: Headers }> = [];
+ const switches: Array> = [];
dom = new JSDOM(html, {
url: 'https://example.test/chat', runScripts: 'dangerously', pretendToBeVisual: true,
beforeParse(window) {
@@ -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);
@@ -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');
});
@@ -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
diff --git a/server/tests/unit/session-organization-switch.test.ts b/server/tests/unit/session-organization-switch.test.ts
new file mode 100644
index 00000000000..ac610cf7667
--- /dev/null
+++ b/server/tests/unit/session-organization-switch.test.ts
@@ -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 = {}, 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');
+ });
+});