Skip to content
Open
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
2 changes: 2 additions & 0 deletions .changeset/tame-snakes-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export const createLongRunningApps = () => {
* Tanstack apps - basic flows
*/
{ id: 'tanstack.react-start', config: tanstack.reactStart, env: envs.withEmailCodes },
{ id: 'tanstack.react-start.withCustomRoles', config: tanstack.reactStart, env: envs.withCustomRoles },
{ id: 'tanstack.react-start.withEmailCodesProxy', config: tanstack.reactStart, env: envs.withEmailCodesProxy },

/**
* Various apps - basic flows
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Show, SignIn, SignOutButton, UserButton } from '@clerk/tanstack-react-start';
import { OrganizationSwitcher, Show, SignIn, SignOutButton, UserButton } from '@clerk/tanstack-react-start';
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/')({
Expand All @@ -15,6 +15,9 @@ function Home() {
<p>View your profile here</p>
<UserButton />
</div>
<div>
<OrganizationSwitcher />
</div>
<div>
<SignOutButton />
</div>
Expand Down
36 changes: 36 additions & 0 deletions integration/templates/tanstack-react-start/src/routes/me.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createFileRoute } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { auth } from '@clerk/tanstack-react-start/server';

const fetchAuthData = createServerFn({ method: 'GET' }).handler(async () => {
const { userId, sessionId, orgId, orgRole, orgSlug } = await auth();
return { userId, sessionId, orgId, orgRole, orgSlug };
});

export const Route = createFileRoute('/me')({
component: MePage,
beforeLoad: async () => await fetchAuthData(),
loader: async ({ context }) => {
return {
userId: context.userId,
sessionId: context.sessionId,
orgId: context.orgId,
orgRole: context.orgRole,
orgSlug: context.orgSlug,
};
},
});

function MePage() {
const state = Route.useLoaderData();

return (
<div>
<p data-testid='userId'>{state.userId ?? ''}</p>
<p data-testid='sessionId'>{state.sessionId ?? ''}</p>
<p data-testid='orgId'>{state.orgId ?? ''}</p>
<p data-testid='orgRole'>{state.orgRole ?? ''}</p>
<p data-testid='orgSlug'>{state.orgSlug ?? ''}</p>
</div>
);
}
35 changes: 35 additions & 0 deletions integration/tests/tanstack-start/error-handling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../../presets';
import { testAgainstRunningApps } from '../../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })(
'error handling tests for @tanstack-react-start',
({ app }) => {
test.describe.configure({ mode: 'parallel' });

test('request with invalid Authorization header is handled gracefully', async () => {
const url = new URL('/me', app.serverUrl);
const res = await fetch(url.toString(), {
headers: {
Authorization: 'Bearer invalid_token_here',
},
});

// Clerk middleware treats an invalid bearer token as unauthenticated (not a crash)
expect(res.status).toBe(200);
});

test('request with malformed cookie is handled gracefully', async () => {
const url = new URL('/me', app.serverUrl);
const res = await fetch(url.toString(), {
headers: {
Cookie: '__session=malformed_jwt_value; __client_uat=0',
},
});

// Clerk middleware handles malformed cookies gracefully, treating the request as unauthenticated
expect(res.status).toBe(200);
});
},
);
117 changes: 117 additions & 0 deletions integration/tests/tanstack-start/organizations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { OrganizationMembershipRole } from '@clerk/backend';
import { expect, test } from '@playwright/test';

import { appConfigs } from '../../presets';
import type { FakeOrganization, FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })(
'organization auth tests for @tanstack-react-start',
({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeAdmin: FakeUser;
let fakeViewer: FakeUser;
let fakeNonMember: FakeUser;
let fakeOrganization: FakeOrganization;

test.beforeAll(async () => {
const m = createTestUtils({ app });
fakeAdmin = m.services.users.createFakeUser();
const admin = await m.services.users.createBapiUser(fakeAdmin);
fakeOrganization = await m.services.users.createFakeOrganization(admin.id);
fakeViewer = m.services.users.createFakeUser();
const viewer = await m.services.users.createBapiUser(fakeViewer);
await m.services.clerk.organizations.createOrganizationMembership({
organizationId: fakeOrganization.organization.id,
role: 'org:viewer' as OrganizationMembershipRole,
userId: viewer.id,
});
fakeNonMember = m.services.users.createFakeUser();
await m.services.users.createBapiUser(fakeNonMember);
});

test.afterAll(async () => {
await fakeOrganization.delete();
await fakeNonMember.deleteIfExists();
await fakeViewer.deleteIfExists();
await fakeAdmin.deleteIfExists();
await app.teardown();
});

test('admin auth object includes orgId, orgRole, orgSlug after selecting org', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/');

await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeAdmin.email,
password: fakeAdmin.password,
});

await u.po.userButton.waitForMounted();

await u.po.organizationSwitcher.waitForMounted();
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();

await u.page.goToRelative('/me');

const userId = await u.page.getByTestId('userId').textContent();
const orgId = await u.page.getByTestId('orgId').textContent();
const orgRole = await u.page.getByTestId('orgRole').textContent();
const orgSlug = await u.page.getByTestId('orgSlug').textContent();

expect(userId).toBeTruthy();
expect(orgId).toBe(fakeOrganization.organization.id);
expect(orgRole).toBe('org:admin');
expect(orgSlug).toBeTruthy();
});

test('non-member auth object has null orgId', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/');

await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeNonMember.email,
password: fakeNonMember.password,
});

await u.po.userButton.waitForMounted();

await u.page.goToRelative('/me');

const userId = await u.page.getByTestId('userId').textContent();
const orgId = await u.page.getByTestId('orgId').textContent();

expect(userId).toBeTruthy();
expect(orgId).toBe('');
});

test('viewer org role is correctly reflected in auth response', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/');

await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeViewer.email,
password: fakeViewer.password,
});

await u.po.userButton.waitForMounted();

await u.po.organizationSwitcher.waitForMounted();
await u.po.organizationSwitcher.waitForAnOrganizationToSelected();

await u.page.goToRelative('/me');

const userId = await u.page.getByTestId('userId').textContent();
const orgId = await u.page.getByTestId('orgId').textContent();
const orgRole = await u.page.getByTestId('orgRole').textContent();

expect(userId).toBeTruthy();
expect(orgId).toBe(fakeOrganization.organization.id);
expect(orgRole).toBe('org:viewer');
});
},
);
74 changes: 74 additions & 0 deletions integration/tests/tanstack-start/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../../presets';
import type { FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodesProxy] })(
'frontend API proxy tests for @tanstack-react-start',
({ app }) => {
test.describe.configure({ mode: 'parallel' });

let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser();
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('handshake redirect uses forwarded headers for proxyUrl, not localhost', async () => {
// This test proves that the SDK must derive proxyUrl from x-forwarded-* headers.
// When a reverse proxy sits in front of the app, the raw request URL is localhost,
// but the handshake redirect must point to the public origin.
//
// We simulate a behind-proxy scenario by sending x-forwarded-proto and x-forwarded-host
// headers, with a __client_uat cookie (non-zero) but no session cookie, which forces
// a handshake. The handshake redirect Location should use the forwarded origin.
const url = new URL('/me', app.serverUrl);
const res = await fetch(url.toString(), {
headers: {
'x-forwarded-proto': 'https',
'x-forwarded-host': 'myapp.example.com',
'sec-fetch-dest': 'document',
Accept: 'text/html',
Cookie: '__clerk_db_jwt=needstobeset; __client_uat=1',
},
redirect: 'manual',
});

// The server should respond with a 307 handshake redirect
expect(res.status).toBe(307);
const location = res.headers.get('location') ?? '';
// The redirect must point to the public origin (from forwarded headers),
// NOT to http://localhost:PORT. If the SDK uses requestUrl.origin instead
// of forwarded headers, this assertion will fail.
const decoded = decodeURIComponent(location);
expect(decoded).toContain('https://myapp.example.com');
expect(decoded).not.toContain('localhost');
});

test('auth works correctly with proxy enabled', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/');

await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({
email: fakeUser.email,
password: fakeUser.password,
});

await u.po.userButton.waitForMounted();

await u.page.goToRelative('/me');

const userId = await u.page.getByTestId('userId').textContent();
expect(userId).toBeTruthy();
});
},
);
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router",
"test:integration:sessions": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=sessions-prod-2 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @sessions",
"test:integration:sessions:staging": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=clerkstage-sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=clerkstage-sessions-prod-2 E2E_SESSIONS_APP_1_HOST=clerkstage-sessions-prod-1-e2e.clerk.app pnpm test:integration:base --grep @sessions",
"test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start pnpm test:integration:base --grep @tanstack-react-start",
"test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start* pnpm test:integration:base --grep @tanstack-react-start",
"test:integration:vue": "E2E_APP_ID=vue.vite pnpm test:integration:base --grep @vue",
"test:typedoc": "pnpm typedoc:generate && cd ./.typedoc && vitest run",
"turbo:clean": "turbo daemon clean",
Expand Down
Loading