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'); + }); +});