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
55 changes: 52 additions & 3 deletions src/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@ describe('auth', () => {
},
};

// What the browser is allowed to see: mockAuthResponse minus session material.
const safeAuth = {
user: mockAuthResponse.user,
sessionId: mockAuthResponse.sessionId,
organizationId: mockAuthResponse.organizationId,
role: mockAuthResponse.role,
roles: mockAuthResponse.roles,
permissions: mockAuthResponse.permissions,
entitlements: mockAuthResponse.entitlements,
featureFlags: mockAuthResponse.featureFlags,
impersonator: mockAuthResponse.impersonator,
};

beforeEach(() => {
refreshSession.mockResolvedValue(mockAuthResponse);
});
Expand All @@ -149,15 +162,15 @@ describe('auth', () => {
const result = await switchToOrganization(request, organizationId);

expect(data).toHaveBeenCalledWith(
{ success: true, auth: mockAuthResponse },
{ success: true, auth: safeAuth },
{
headers: {
'Set-Cookie': 'new-cookie-value',
},
},
);
expect(result).toEqual({
data: { success: true, auth: mockAuthResponse },
data: { success: true, auth: safeAuth },
init: {
headers: {
'Set-Cookie': 'new-cookie-value',
Expand All @@ -166,6 +179,42 @@ describe('auth', () => {
});
});

it('never ships accessToken, sealedSession, or Set-Cookie in the data payload (VULN-1244)', async () => {
const result = await switchToOrganization(request, organizationId);

const serialized = JSON.stringify(result.data);
expect(serialized).not.toContain('new-access-token');
expect(serialized).not.toContain('sealed-session-data');
expect(serialized).not.toContain('new-cookie-value');
expect(result.data.auth).not.toHaveProperty('accessToken');
expect(result.data.auth).not.toHaveProperty('sealedSession');
expect(result.data.auth).not.toHaveProperty('headers');
// The session still travels in the response header.
expect(result.init?.headers).toEqual({ 'Set-Cookie': 'new-cookie-value' });
});

it.each([
'https://evil.example.com/phish',
'//evil.example.com',
'/\\evil.example.com',
'/dashboard\r\nSet-Cookie: x=y',
'javascript:alert(1)',
])('rejects off-origin returnTo %s and redirects to / (VULN-1264)', async (returnTo) => {
const result = await switchToOrganization(request, organizationId, { returnTo });

expect(redirect).toHaveBeenCalledWith('/', { headers: { 'Set-Cookie': 'new-cookie-value' } });
assertIsResponse(result);
expect(result.headers.get('Location')).toBe('/');
});

it('preserves a same-origin returnTo path with query and hash', async () => {
const returnTo = '/settings/orgs?tab=members#top';
const result = await switchToOrganization(request, organizationId, { returnTo });

assertIsResponse(result);
expect(result.headers.get('Location')).toBe(returnTo);
});

it('should redirect to returnTo when provided', async () => {
const returnTo = '/dashboard';
const result = await switchToOrganization(request, organizationId, { returnTo });
Expand Down Expand Up @@ -293,7 +342,7 @@ describe('auth', () => {

await switchToOrganization(request, organizationId);

expect(data).toHaveBeenCalledWith({ success: true, auth: mockResponseWithoutCookie }, undefined);
expect(data).toHaveBeenCalledWith({ success: true, auth: safeAuth }, undefined);
});

it('omits the Set-Cookie response header on returnTo when refreshSession returns none', async () => {
Expand Down
42 changes: 36 additions & 6 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getAuthorizationUrl } from './get-authorization-url.js';
import { getClaimsFromAccessToken, getSessionFromCookie, refreshSession, terminateSession } from './session.js';
import { GetAuthURLResult, NoUserInfo, UserInfo } from './interfaces.js';
import { getConfig } from './config.js';
import { sanitizeReturnPathname } from './return-pathname.js';

/**
* Build a sign-in URL and the short-lived PKCE / CSRF cookie that must travel
Expand Down Expand Up @@ -111,27 +112,56 @@ export async function withAuth(args: LoaderFunctionArgs): Promise<UserInfo | NoU
* Switches the current session to a different organization.
* @param request - The incoming request object.
* @param organizationId - The ID of the organization to switch to.
* @param options - Optional parameters.
* @returns A redirect response to the specified returnTo URL or a data response with the updated auth data.
* @param options - Optional parameters. `returnTo` must be a same-origin
* pathname (e.g. `/dashboard`); anything else redirects to `/`.
* @returns A redirect response to the specified returnTo path or a data response with the updated auth data.
*/
export async function switchToOrganization(
request: Request,
organizationId: string,
{ returnTo }: { returnTo?: string } = {},
) {
try {
const auth = await refreshSession(request, { organizationId });
const {
user,
sessionId,
organizationId: newOrganizationId,
role,
roles,
permissions,
entitlements,
featureFlags,
impersonator,
headers,
} = await refreshSession(request, { organizationId });

// Only display-safe claims go back to the browser. `accessToken`,
// `sealedSession`, and the raw `Set-Cookie` string stay server-side; the
// session travels via the response header alone (same invariant as
// authkitLoader post-CVE-2025-55008).
const auth = {
user,
sessionId,
organizationId: newOrganizationId,
role,
roles,
permissions,
entitlements,
featureFlags,
impersonator,
};

// `refreshSession` always returns a `Set-Cookie` header for a successful
// refresh; guard with `as Record<string, string>` to satisfy the wider
// typing on AuthLoaderSuccessData without silently emitting an empty
// `Set-Cookie` header if the invariant ever changes.
const setCookie = (auth.headers as Record<string, string> | undefined)?.['Set-Cookie'];
const setCookie = (headers as Record<string, string> | undefined)?.['Set-Cookie'];
const responseHeaders = setCookie ? { 'Set-Cookie': setCookie } : undefined;

// if returnTo is provided, redirect to there
// if returnTo is provided, redirect there. Same-origin pathname only, so a
// request-controlled value can't turn this into an open redirect.
if (returnTo) {
return redirect(returnTo, responseHeaders ? { headers: responseHeaders } : undefined);
return redirect(sanitizeReturnPathname(returnTo), responseHeaders ? { headers: responseHeaders } : undefined);
}

// otherwise return the updated auth data
Expand Down