diff --git a/.changeset/olive-trainers-heal.md b/.changeset/olive-trainers-heal.md
new file mode 100644
index 00000000000..67ce2a39eb5
--- /dev/null
+++ b/.changeset/olive-trainers-heal.md
@@ -0,0 +1,5 @@
+---
+"@clerk/nextjs": patch
+---
+
+Introduces `organizationSyncOptions` option to `clerkMiddleware`, which syncs an active organization or personal account from a URL to the Clerk session.
diff --git a/integration/templates/next-app-router/src/app/organizations-by-id/[id]/page.tsx b/integration/templates/next-app-router/src/app/organizations-by-id/[id]/page.tsx
new file mode 100644
index 00000000000..41eb746d5e0
--- /dev/null
+++ b/integration/templates/next-app-router/src/app/organizations-by-id/[id]/page.tsx
@@ -0,0 +1,18 @@
+import { auth } from '@clerk/nextjs/server';
+
+export default function Home({ params }: { params: { id: string } }) {
+ const { orgId } = auth();
+
+ if (params.id != orgId) {
+ console.log('Mismatch - returning nothing for now...', params.id, orgId);
+ }
+
+ console.log("I'm the server and I got this id: ", orgId);
+
+ return (
+ <>
+
Org-specific home
+ From auth(), I know your org id is: {orgId}
+ >
+ );
+}
diff --git a/integration/templates/next-app-router/src/app/organizations-by-id/[id]/settings/page.tsx b/integration/templates/next-app-router/src/app/organizations-by-id/[id]/settings/page.tsx
new file mode 100644
index 00000000000..8d1adf2e244
--- /dev/null
+++ b/integration/templates/next-app-router/src/app/organizations-by-id/[id]/settings/page.tsx
@@ -0,0 +1,18 @@
+import { auth } from '@clerk/nextjs/server';
+
+export default function Home({ params }: { params: { id: string } }) {
+ const { orgId } = auth();
+
+ if (params.id != orgId) {
+ console.log('Mismatch - returning nothing for now...', params.id, orgId);
+ }
+
+ console.log("I'm the server and I got this id: ", orgId);
+
+ return (
+ <>
+ Org-specific settings
+ From auth(), I know your org id is: {orgId}
+ >
+ );
+}
diff --git a/integration/templates/next-app-router/src/app/organizations-by-slug/[slug]/page.tsx b/integration/templates/next-app-router/src/app/organizations-by-slug/[slug]/page.tsx
new file mode 100644
index 00000000000..1847d88f181
--- /dev/null
+++ b/integration/templates/next-app-router/src/app/organizations-by-slug/[slug]/page.tsx
@@ -0,0 +1,18 @@
+import { auth } from '@clerk/nextjs/server';
+
+export default function Home({ params }: { params: { slug: string } }) {
+ const { orgSlug } = auth();
+
+ if (params.slug != orgSlug) {
+ console.log('Mismatch - returning nothing for now...', params.slug, orgSlug);
+ }
+
+ console.log("I'm the server and I got this slug: ", orgSlug);
+
+ return (
+ <>
+ Org-specific home
+ From auth(), I know your org slug is: {orgSlug}
+ >
+ );
+}
diff --git a/integration/templates/next-app-router/src/app/organizations-by-slug/[slug]/settings/page.tsx b/integration/templates/next-app-router/src/app/organizations-by-slug/[slug]/settings/page.tsx
new file mode 100644
index 00000000000..f2613fdbcc4
--- /dev/null
+++ b/integration/templates/next-app-router/src/app/organizations-by-slug/[slug]/settings/page.tsx
@@ -0,0 +1,18 @@
+import { auth } from '@clerk/nextjs/server';
+
+export default function Home({ params }: { params: { slug: string } }) {
+ const { orgSlug } = auth();
+
+ if (params.slug != orgSlug) {
+ console.log('Mismatch - returning nothing for now...', params.slug, orgSlug);
+ }
+
+ console.log("I'm the server and I got this slug: ", orgSlug);
+
+ return (
+ <>
+ Org-specific settings
+ From auth(), I know your org slug is: {orgSlug}
+ >
+ );
+}
diff --git a/integration/templates/next-app-router/src/app/personal-account/page.tsx b/integration/templates/next-app-router/src/app/personal-account/page.tsx
new file mode 100644
index 00000000000..fdd6a1460d0
--- /dev/null
+++ b/integration/templates/next-app-router/src/app/personal-account/page.tsx
@@ -0,0 +1,15 @@
+import { auth } from '@clerk/nextjs/server';
+
+export default function Home(): {} {
+ const { orgId } = auth();
+
+ if (orgId != null) {
+ console.log('Oh no, this page should only activate on the personal account!');
+ }
+
+ return (
+ <>
+ Welcome to your personal account
+ >
+ );
+}
diff --git a/integration/testUtils/handshake.ts b/integration/testUtils/handshake.ts
index 63c4fbf2d64..74c4cea2026 100644
--- a/integration/testUtils/handshake.ts
+++ b/integration/testUtils/handshake.ts
@@ -104,7 +104,13 @@ export function generateConfig({ mode, matchedKeys = true }: { mode: 'test' | 'l
exp: number;
nbf: number;
};
- const generateToken = ({ state }: { state: 'active' | 'expired' | 'early' }) => {
+ const generateToken = ({
+ state,
+ extraClaims,
+ }: {
+ state: 'active' | 'expired' | 'early';
+ extraClaims?: Map;
+ }) => {
const claims = { sub: 'user_12345' } as Claims;
const now = Math.floor(Date.now() / 1000);
@@ -121,6 +127,14 @@ export function generateConfig({ mode, matchedKeys = true }: { mode: 'test' | 'l
claims.nbf = now - 10 + 600;
claims.exp = now + 60 + 600;
}
+
+ // Merge claims with extraClaims
+ if (extraClaims) {
+ for (const [key, value] of extraClaims) {
+ claims[key] = value;
+ }
+ }
+
return {
token: jwt.sign(claims, rsa.private, {
algorithm: 'RS256',
diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts
index 887b45b0a40..29b561cb6de 100644
--- a/integration/tests/handshake.test.ts
+++ b/integration/tests/handshake.test.ts
@@ -2,6 +2,7 @@ import * as http from 'node:http';
import { expect, test } from '@playwright/test';
+import type { OrganizationSyncOptions } from '../../packages/backend/src/tokens/types';
import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { generateConfig, getJwksFromSecretKey } from '../testUtils/handshake';
@@ -885,3 +886,525 @@ test.describe('Client handshake @generic', () => {
expect(res.status).toBe(200);
});
});
+
+test.describe('Client handshake with organization activation @nextjs', () => {
+ test.describe.configure({ mode: 'parallel' });
+
+ const devBrowserCookie = '__clerk_db_jwt=needstobeset;';
+
+ const jwksServer = http.createServer(function (req, res) {
+ const sk = req.headers.authorization?.replace('Bearer ', '');
+ if (!sk) {
+ console.log('No SK to', req.url, req.headers);
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.write(JSON.stringify(getJwksFromSecretKey(sk)));
+ res.end();
+ });
+
+ let app: Application;
+
+ test.beforeAll('setup local jwks server', async () => {
+ // Start the jwks server
+ await new Promise(resolve => jwksServer.listen(0, resolve));
+ app = await startAppWithOrganizationSyncOptions(`http://localhost:${jwksServer.address().port}`);
+ });
+
+ test.afterAll('setup local Clerk API mock', async () => {
+ await app.teardown();
+ return new Promise(resolve => jwksServer.close(() => resolve()));
+ });
+
+ type TestCase = {
+ name: string;
+ when: When;
+ then: Then;
+ };
+ type When = {
+ // With this initial state...
+ initialAuthState: 'active' | 'expired' | 'early';
+ initialSessionClaims: Map;
+
+ // When the customer app specifies these orgSyncOptions to middleware...
+ orgSyncOptions: OrganizationSyncOptions;
+
+ // And a request arrives to the app at this path...
+ appRequestPath: string;
+
+ // With a token specified in...
+ tokenAppearsIn: 'header' | 'cookie';
+
+ // And the Sec-fetch-dest header is...
+ secFetchDestHeader: string | null;
+ };
+
+ type Then = {
+ // A handshake should (or should not) occur:
+ expectStatus: number;
+
+ // The middleware should redirect to fapi with this query param value:
+ fapiOrganizationIdParamValue: string | null;
+ };
+
+ const cookieAuthCases: TestCase[] = [
+ // ---------------- Session active vs expired tests ----------------
+ // Note: it would be possible to run _every_ test with both active and expired initial states
+ // and expect the same results, but we're avoiding that to save some test execution time.
+ {
+ name: 'Expired session, no org in session, but org a requested by ID => attempts to activate org A',
+ when: {
+ initialAuthState: 'expired',
+ initialSessionClaims: new Map([
+ // Intentionally empty
+ ]),
+ orgSyncOptions: {
+ organizationPatterns: ['/organizations-by-id/:id'],
+ },
+ appRequestPath: '/organizations-by-id/org_a',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 307,
+ fapiOrganizationIdParamValue: 'org_a',
+ },
+ },
+ {
+ name: 'Active session, no org in session, but org a requested by ID => attempts to activate org A',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([
+ // Intentionally empty
+ ]),
+ orgSyncOptions: {
+ organizationPatterns: ['/organizations-by-id/:id'],
+ },
+ appRequestPath: '/organizations-by-id/org_a',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 307,
+ fapiOrganizationIdParamValue: 'org_a',
+ },
+ },
+
+ // ---------------- Header-based auth tests ----------------
+ // Header-based auth requests come from non-browser actors, which don't have the __client cookie.
+ // Handshaking depends on a redirect that includes that __client cookie, so we should not handshake
+ // for this auth method, even if there's an org mismatch
+ {
+ name: 'Header-based auth should not handshake with active auth',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([
+ // Intentionally empty
+ ]),
+ orgSyncOptions: {
+ organizationPatterns: ['/organizations-by-id/:id'],
+ },
+ appRequestPath: '/organizations-by-id/org_a',
+ tokenAppearsIn: 'header',
+ secFetchDestHeader: null,
+ },
+ then: {
+ expectStatus: 200,
+ fapiOrganizationIdParamValue: null,
+ },
+ },
+ {
+ name: 'Header-based auth should not handshake with expired auth',
+ when: {
+ initialAuthState: 'expired',
+ initialSessionClaims: new Map([
+ // Intentionally empty
+ ]),
+ orgSyncOptions: {
+ organizationPatterns: ['/organizations-by-id/:id'],
+ },
+ appRequestPath: '/organizations-by-id/org_a',
+ tokenAppearsIn: 'header',
+ secFetchDestHeader: null,
+ },
+ then: {
+ expectStatus: 307, // Should redirect to sign-in
+ fapiOrganizationIdParamValue: null,
+ },
+ },
+
+ // ---------------- Existing session active org tests ----------------
+ {
+ name: 'Active session, org A active in session, but org B is requested by ID => attempts to activate org B',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([['org_id', 'org_a']]),
+ orgSyncOptions: {
+ organizationPatterns: ['/organizations-by-id/:id', '/organizations-by-id/:id/(.*)'],
+ },
+ appRequestPath: '/organizations-by-id/org_b',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 307,
+ fapiOrganizationIdParamValue: 'org_b',
+ },
+ },
+ {
+ name: 'Active session, no active org in session, but org B is requested by slug => attempts to activate org B',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([
+ // Intentionally empty
+ ]),
+ orgSyncOptions: {
+ organizationPatterns: [
+ '/organizations-by-id/:id',
+ '/organizations-by-id/:id/(.*)',
+ '/organizations-by-slug/:slug',
+ '/organizations-by-slug/:id/(.*)',
+ ],
+ },
+ appRequestPath: '/organizations-by-slug/bcorp',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 307,
+ fapiOrganizationIdParamValue: 'bcorp',
+ },
+ },
+ {
+ name: 'Active session, org a in session, but *an org B subresource* is requested by slug => attempts to activate org B',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([['org_id', 'org_a']]),
+ orgSyncOptions: {
+ organizationPatterns: [
+ '/organizations-by-slug/:slug',
+ '/organizations-by-slug/:id/(.*)',
+ '/organizations-by-id/:id',
+ '/organizations-by-id/:id/(.*)',
+ ],
+ },
+ appRequestPath: '/organizations-by-slug/bcorp/settings',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 307,
+ fapiOrganizationIdParamValue: 'bcorp',
+ },
+ },
+ {
+ // This case ensures that, for the prototypical nextjs app, we permanent redirect before attempting the handshake logic.
+ // If this wasn't the case, we'd need to recommend adding an additional pattern with a trailing slash to our docs.
+ name: 'When org A is active in a signed-out session but an org B is requested by ID with a trailing slash, permanent redirects to the non-slash route without error.',
+ when: {
+ initialAuthState: 'expired',
+ initialSessionClaims: new Map([['org_id', 'org_a']]),
+ orgSyncOptions: {
+ organizationPatterns: ['/organizations-by-id/:id', '/organizations-by-id/:id/(.*)'],
+ },
+ appRequestPath: '/organizations-by-id/org_b/',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 308, // Handshake never 308's - this points to `/organizations-by-id/org_b` (no trailing slash)
+ fapiOrganizationIdParamValue: null,
+ },
+ },
+
+ // ---------------- Personal account tests ----------------
+ {
+ name: 'Active session, org a in session, but *the personal account* is requested => attempts to activate PWS',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([['org_id', 'org_a']]),
+ orgSyncOptions: {
+ organizationPatterns: [
+ '/organizations-by-id/:id',
+ '/organizations-by-id/:id/(.*)',
+ '/organizations-by-slug/:slug',
+ '/organizations-by-slug/:id/(.*)',
+ ],
+ personalAccountPatterns: ['/personal-account', '/personal-account/(.*)'],
+ },
+ appRequestPath: '/personal-account',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 307,
+ fapiOrganizationIdParamValue: '', // <-- Empty string indicates personal account
+ },
+ },
+
+ // ---------------- No activation required tests ----------------
+ {
+ name: 'Active session, nothing session, and the personal account is requested => nothing to activate!',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([
+ // Intentionally empty
+ ]),
+ orgSyncOptions: {
+ organizationPatterns: ['/organizations-by-slug/:slug', '/organizations-by-slug/:id/(.*)'],
+ personalAccountPatterns: ['/personal-account', '/personal-account/(.*)'],
+ },
+ appRequestPath: '/personal-account',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 200,
+ fapiOrganizationIdParamValue: null,
+ },
+ },
+ {
+ name: 'Active session, org a active in session, and org a is requested => nothing to activate!',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([['org_id', 'org_a']]),
+ orgSyncOptions: {
+ organizationPatterns: ['/organizations-by-id/:id', '/organizations-by-id/:id/(.*)'],
+ personalAccountPatterns: ['/personal-account', '/personal-account/(.*)'],
+ },
+ appRequestPath: '/organizations-by-id/org_a',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 200,
+ fapiOrganizationIdParamValue: null,
+ },
+ },
+ {
+ // NOTE(izaak): Would we prefer 500ing in this case?
+ name: 'No config => nothing to activate, return 200',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([['org_id', 'org_a']]),
+ orgSyncOptions: null,
+ appRequestPath: '/organizations-by-id/org_a',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 200,
+ fapiOrganizationIdParamValue: null,
+ },
+ },
+
+ // ---------------- Invalid permutation tests ----------------
+ {
+ name: 'Invalid config => ignore it and return 200',
+ when: {
+ initialAuthState: 'active',
+ initialSessionClaims: new Map([['org_id', 'org_a']]),
+ orgSyncOptions: {
+ organizationPatterns: ['i am not valid config'],
+ },
+ appRequestPath: '/organizations-by-id/org_a',
+ tokenAppearsIn: 'cookie',
+ secFetchDestHeader: 'document',
+ },
+ then: {
+ expectStatus: 200,
+ fapiOrganizationIdParamValue: null,
+ },
+ },
+ ];
+
+ for (const testCase of cookieAuthCases) {
+ test(`${testCase.name}`, async () => {
+ const config = generateConfig({
+ mode: 'test',
+ });
+ // Create a new map with an org_id key
+ const { token, claims } = config.generateToken({
+ state: testCase.when.initialAuthState, // <-- Critical
+ extraClaims: testCase.when.initialSessionClaims,
+ });
+
+ const headers = new Headers({
+ 'X-Publishable-Key': config.pk,
+ 'X-Secret-Key': config.sk,
+
+ // NOTE(izaak): To avoid needing to start a server with every test, we're passing in
+ // organization options to the app via a header.
+ 'x-organization-sync-options': JSON.stringify(testCase.when.orgSyncOptions),
+ });
+
+ if (testCase.when.secFetchDestHeader) {
+ headers.set('Sec-Fetch-Dest', testCase.when.secFetchDestHeader);
+ }
+
+ switch (testCase.when.tokenAppearsIn) {
+ case 'cookie':
+ headers.set('Cookie', `${devBrowserCookie} __client_uat=${claims.iat}; __session=${token}`);
+ break;
+ case 'header':
+ headers.set('Authorization', `Bearer ${token}`);
+ break;
+ }
+
+ const res = await fetch(app.serverUrl + testCase.when.appRequestPath, {
+ headers: headers,
+ redirect: 'manual',
+ });
+
+ expect(res.status).toBe(testCase.then.expectStatus);
+ const redirectSearchParams = new URLSearchParams(res.headers.get('location'));
+ expect(redirectSearchParams.get('organization_id')).toBe(testCase.then.fapiOrganizationIdParamValue);
+ });
+ }
+});
+
+test.describe('Client handshake with an organization activation avoids infinite loops @nextjs', () => {
+ const devBrowserCookie = '__clerk_db_jwt=needstobeset;';
+
+ const jwksServer = http.createServer(function (req, res) {
+ const sk = req.headers.authorization?.replace('Bearer ', '');
+ if (!sk) {
+ console.log('No SK to', req.url, req.headers);
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.write(JSON.stringify(getJwksFromSecretKey(sk)));
+ res.end();
+ });
+
+ // define app as an application
+ let thisApp: Application;
+
+ test.beforeAll('setup local jwks server', async () => {
+ // Start the jwks server
+ await new Promise(resolve => jwksServer.listen(0, resolve));
+
+ thisApp = await startAppWithOrganizationSyncOptions(`http://localhost:${jwksServer.address().port}`);
+ });
+
+ test.afterAll('setup local Clerk API mock', async () => {
+ await thisApp.teardown();
+ return new Promise(resolve => jwksServer.close(() => resolve()));
+ });
+
+ // -------------- Test begin ------------
+
+ const config = generateConfig({
+ mode: 'test',
+ });
+
+ const organizationSyncOptions = {
+ organizationPatterns: ['/organizations-by-id/:id', '/organizations-by-id/:id/(.*)'],
+ personalAccountPatterns: ['/personal-account', '/personal-account/(.*)'],
+ };
+
+ test('Sets the redirect loop tracking cookie', async () => {
+ // Create a new map with an org_id key
+ const { token, claims } = config.generateToken({
+ state: 'active',
+ extraClaims: new Map([]),
+ });
+
+ const headers = new Headers({
+ 'X-Publishable-Key': config.pk,
+ 'X-Secret-Key': config.sk,
+ 'Sec-Fetch-Dest': 'document',
+ 'x-organization-sync-options': JSON.stringify(organizationSyncOptions),
+ });
+ headers.set('Cookie', `${devBrowserCookie} __client_uat=${claims.iat}; __session=${token}`);
+
+ const res = await fetch(thisApp.serverUrl + '/organizations-by-id/org_a', {
+ headers: headers,
+ redirect: 'manual',
+ });
+
+ expect(res.status).toBe(307);
+ const redirectSearchParams = new URLSearchParams(res.headers.get('location'));
+ expect(redirectSearchParams.get('organization_id')).toBe('org_a');
+
+ // read the set-cookie directives
+ const setCookie = res.headers.get('set-cookie');
+
+ expect(setCookie).toContain(`__clerk_redirect_count=1`); // <-- Critical
+ });
+
+ test('Ignores organization config when being redirected to', async () => {
+ // Create a new map with an org_id key
+ const { token, claims } = config.generateToken({
+ state: 'active', // Must be active - handshake logic only runs once session is determined to be active
+ extraClaims: new Map([]),
+ });
+
+ const headers = new Headers({
+ 'X-Publishable-Key': config.pk,
+ 'X-Secret-Key': config.sk,
+ 'Sec-Fetch-Dest': 'document',
+ 'x-organization-sync-options': JSON.stringify(organizationSyncOptions),
+ });
+
+ // Critical cookie: __clerk_redirect_count
+ headers.set(
+ 'Cookie',
+ `${devBrowserCookie} __client_uat=${claims.iat}; __session=${token}; __clerk_redirect_count=1`,
+ );
+
+ const res = await fetch(thisApp.serverUrl + '/organizations-by-id/org_a', {
+ headers: headers,
+ redirect: 'manual',
+ });
+
+ expect(res.status).toBe(200);
+ const redirectSearchParams = new URLSearchParams(res.headers.get('location'));
+ expect(redirectSearchParams.get('organization_id')).toBe(null);
+
+ expect(res.headers.get('set-cookie')).toBe(null);
+ });
+});
+
+/**
+ * Start the nextjs sample app with the given organization sync options
+ * organization sync options can be passed to the app via the
+ * "x-organization-sync-options" header
+ */
+const startAppWithOrganizationSyncOptions = async (clerkAPIUrl: string): Promise => {
+ const env = appConfigs.envs.withEmailCodes.clone().setEnvVariable('private', 'CLERK_API_URL', clerkAPIUrl);
+
+ const middlewareFile = `import { authMiddleware } from '@clerk/nextjs/server';
+ // Set the paths that don't require the user to be signed in
+ const publicPaths = ['/', /^(\\/(sign-in|sign-up|app-dir|custom)\\/*).*$/];
+ export const middleware = (req, evt) => {
+ const orgSyncOptions = req.headers.get("x-organization-sync-options")
+ return authMiddleware({
+ publicRoutes: publicPaths,
+ publishableKey: req.headers.get("x-publishable-key"),
+ secretKey: req.headers.get("x-secret-key"),
+ proxyUrl: req.headers.get("x-proxy-url"),
+ domain: req.headers.get("x-domain"),
+ isSatellite: req.headers.get('x-satellite') === 'true',
+ signInUrl: req.headers.get("x-sign-in-url"),
+
+ // Critical
+ organizationSyncOptions: JSON.parse(req.headers.get("x-organization-sync-options")),
+
+ })(req, evt)
+ };
+ export const config = {
+ matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
+ };
+ `;
+
+ const app = await appConfigs.next.appRouter
+ .clone()
+ .addFile('src/middleware.ts', () => middlewareFile)
+ .commit();
+
+ await app.setup();
+ await app.withEnv(env);
+ await app.dev();
+ return app;
+};
diff --git a/packages/backend/src/internal.ts b/packages/backend/src/internal.ts
index 11978dae045..af12ba0655a 100644
--- a/packages/backend/src/internal.ts
+++ b/packages/backend/src/internal.ts
@@ -7,7 +7,7 @@ export { createAuthenticateRequest } from './tokens/factory';
export { debugRequestState } from './tokens/request';
-export type { AuthenticateRequestOptions } from './tokens/types';
+export type { AuthenticateRequestOptions, OrganizationSyncOptions } from './tokens/types';
export type { SignedInAuthObjectOptions, SignedInAuthObject, SignedOutAuthObject } from './tokens/authObjects';
export { makeAuthObjectSerializable, signedOutAuthObject, signedInAuthObject } from './tokens/authObjects';
diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts
index 7cd1ff3f4a7..61b5c893398 100644
--- a/packages/backend/src/tokens/__tests__/request.test.ts
+++ b/packages/backend/src/tokens/__tests__/request.test.ts
@@ -13,8 +13,14 @@ import {
import runtime from '../../runtime';
import { jsonOk } from '../../util/testUtils';
import { AuthErrorReason, type AuthReason, AuthStatus, type RequestState } from '../authStatus';
-import { authenticateRequest, RefreshTokenErrorReason } from '../request';
-import type { AuthenticateRequestOptions } from '../types';
+import {
+ authenticateRequest,
+ computeOrganizationSyncTargetMatchers,
+ getOrganizationSyncTarget,
+ type OrganizationSyncTarget,
+ RefreshTokenErrorReason,
+} from '../request';
+import type { AuthenticateRequestOptions, OrganizationSyncOptions } from '../types';
const PK_TEST = 'pk_test_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA';
const PK_LIVE = 'pk_live_Y2xlcmsuaW5zcGlyZWQucHVtYS03NC5sY2wuZGV2JA';
@@ -165,6 +171,182 @@ export default (QUnit: QUnit) => {
return mockRequest({ cookie: cookieStr, ...headers }, requestUrl);
};
+ // Tests both getOrganizationSyncTarget and the organizationSyncOptions usage patterns
+ // that are recommended for typical use.
+ module('tokens.getOrganizationSyncTarget(url,options)', _ => {
+ type testCase = {
+ name: string;
+ // When the customer app specifies these orgSyncOptions to middleware...
+ whenOrgSyncOptions: OrganizationSyncOptions | undefined;
+ // And the path arrives at this URL path...
+ whenAppRequestPath: string;
+ // A handshake should (or should not) occur:
+ thenExpectActivationEntity: OrganizationSyncTarget | null;
+ };
+
+ const testCases: testCase[] = [
+ {
+ name: 'none activates nothing',
+ whenOrgSyncOptions: undefined,
+ whenAppRequestPath: '/orgs/org_foo',
+ thenExpectActivationEntity: null,
+ },
+ {
+ name: 'Can activate an org by ID (basic)',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/orgs/:id'],
+ },
+ whenAppRequestPath: '/orgs/org_foo',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationId: 'org_foo',
+ },
+ },
+ {
+ name: 'mimatch activates nothing',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/orgs/:id'],
+ },
+ whenAppRequestPath: '/personal-account/my-resource',
+ thenExpectActivationEntity: null,
+ },
+ {
+ name: 'Can activate an org by ID (recommended matchers)',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/orgs/:id', '/orgs/:id/', '/orgs/:id/(.*)'],
+ },
+ whenAppRequestPath: '/orgs/org_foo',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationId: 'org_foo',
+ },
+ },
+ {
+ name: 'Can activate an org by ID with a trailing slash',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/orgs/:id', '/orgs/:id/', '/orgs/:id/(.*)'],
+ },
+ whenAppRequestPath: '/orgs/org_foo/',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationId: 'org_foo',
+ },
+ },
+ {
+ name: 'Can activate an org by ID with a trailing path component',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/orgs/:id', '/orgs/:id/', '/orgs/:id/(.*)'],
+ },
+ whenAppRequestPath: '/orgs/org_foo/nested-resource',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationId: 'org_foo',
+ },
+ },
+ {
+ name: 'Can activate an org by ID with many trailing path component',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/orgs/:id/(.*)'],
+ },
+ whenAppRequestPath: '/orgs/org_foo/nested-resource/and/more/deeply/nested/resources',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationId: 'org_foo',
+ },
+ },
+ {
+ name: 'Can activate an org by ID with an unrelated path token in the prefix',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/unknown-thing/:any/orgs/:id'],
+ },
+ whenAppRequestPath: '/unknown-thing/thing/orgs/org_foo',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationId: 'org_foo',
+ },
+ },
+ {
+ name: 'Can activate an org by slug',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/orgs/:slug'],
+ },
+ whenAppRequestPath: '/orgs/my-org',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationSlug: 'my-org',
+ },
+ },
+ {
+ name: 'Can activate the personal account',
+ whenOrgSyncOptions: {
+ personalAccountPatterns: ['/personal-account'],
+ },
+ whenAppRequestPath: '/personal-account',
+ thenExpectActivationEntity: {
+ type: 'personalAccount',
+ },
+ },
+ {
+ name: 'ID match precedes slug match',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/orgs/:id', '/orgs/:slug'], // bad practice
+ },
+ whenAppRequestPath: '/orgs/my-org',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationId: 'my-org',
+ },
+ },
+ {
+ name: 'personal account match precedes org match',
+ whenOrgSyncOptions: {
+ organizationPatterns: ['/personal-account'], // bad practice
+ personalAccountPatterns: ['/personal-account'],
+ },
+ whenAppRequestPath: '/personal-account',
+ thenExpectActivationEntity: {
+ type: 'personalAccount',
+ },
+ },
+ {
+ name: 'personal account may contain path tokens',
+ whenOrgSyncOptions: {
+ personalAccountPatterns: ['/user/:any', '/user/:any/(.*)'],
+ },
+ whenAppRequestPath: '/user/123/home',
+ thenExpectActivationEntity: {
+ type: 'personalAccount',
+ },
+ },
+ {
+ name: 'All of the config at once',
+ whenOrgSyncOptions: {
+ organizationPatterns: [
+ '/orgs-by-id/:id',
+ '/orgs-by-id/:id/(.*)',
+ '/orgs-by-slug/:slug',
+ '/orgs-by-slug/:slug/(.*)',
+ ],
+ personalAccountPatterns: ['/personal-account', '/personal-account/(.*)'],
+ },
+ whenAppRequestPath: '/orgs-by-slug/org_bar/sub-resource',
+ thenExpectActivationEntity: {
+ type: 'organization',
+ organizationSlug: 'org_bar',
+ },
+ },
+ ];
+
+ testCases.forEach(testCase => {
+ test(testCase.name, assert => {
+ const path = new URL(`http://localhost:3000${testCase.whenAppRequestPath}`);
+ const matchers = computeOrganizationSyncTargetMatchers(testCase.whenOrgSyncOptions);
+ const toActivate = getOrganizationSyncTarget(path, testCase.whenOrgSyncOptions, matchers);
+ assert.deepEqual(toActivate, testCase.thenExpectActivationEntity);
+ });
+ });
+ });
+
module('tokens.authenticateRequest(options)', hooks => {
let fakeClock;
let fakeFetch;
diff --git a/packages/backend/src/tokens/authStatus.ts b/packages/backend/src/tokens/authStatus.ts
index 658ca3af77c..6bb141fd2bd 100644
--- a/packages/backend/src/tokens/authStatus.ts
+++ b/packages/backend/src/tokens/authStatus.ts
@@ -69,6 +69,7 @@ export const AuthErrorReason = {
SessionTokenNBF: 'session-token-nbf',
SessionTokenIatInTheFuture: 'session-token-iat-in-the-future',
SessionTokenWithoutClientUAT: 'session-token-but-no-client-uat',
+ ActiveOrganizationMismatch: 'active-organization-mismatch',
UnexpectedError: 'unexpected-error',
} as const;
diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts
index 57d553f1572..2e4ed6b9fa3 100644
--- a/packages/backend/src/tokens/request.ts
+++ b/packages/backend/src/tokens/request.ts
@@ -1,3 +1,5 @@
+import type { Match, MatchFunction } from '@clerk/shared/pathToRegexp';
+import { match } from '@clerk/shared/pathToRegexp';
import type { JwtPayload } from '@clerk/types';
import { constants } from '../constants';
@@ -8,12 +10,13 @@ import { assertValidSecretKey } from '../util/optionsAssertions';
import { isDevelopmentFromSecretKey } from '../util/shared';
import type { AuthenticateContext } from './authenticateContext';
import { createAuthenticateContext } from './authenticateContext';
+import type { SignedInAuthObject } from './authObjects';
import type { HandshakeState, RequestState, SignedInState, SignedOutState } from './authStatus';
import { AuthErrorReason, handshake, signedIn, signedOut } from './authStatus';
import { createClerkRequest } from './clerkRequest';
import { getCookieName, getCookieValue } from './cookie';
import { verifyHandshakeToken } from './handshake';
-import type { AuthenticateRequestOptions } from './types';
+import type { AuthenticateRequestOptions, OrganizationSyncOptions } from './types';
import { verifyToken } from './verify';
export const RefreshTokenErrorReason = {
@@ -101,6 +104,9 @@ export async function authenticateRequest(
assertProxyUrlOrDomain(authenticateContext.proxyUrl || authenticateContext.domain);
}
+ // NOTE(izaak): compute regex matchers early for efficiency - they can be used multiple times.
+ const organizationSyncTargetMatchers = computeOrganizationSyncTargetMatchers(options.organizationSyncOptions);
+
function removeDevBrowserFromURL(url: URL) {
const updatedURL = new URL(url);
@@ -124,6 +130,19 @@ export async function authenticateRequest(
url.searchParams.append(constants.QueryParameters.DevBrowser, authenticateContext.devBrowserToken);
}
+ const toActivate = getOrganizationSyncTarget(
+ authenticateContext.clerkUrl,
+ options.organizationSyncOptions,
+ organizationSyncTargetMatchers,
+ );
+ if (toActivate) {
+ const params = getOrganizationSyncQueryParams(toActivate);
+
+ params.forEach((value, key) => {
+ url.searchParams.append(key, value);
+ });
+ }
+
return new Headers({ [constants.Headers.Location]: url.href });
}
@@ -340,6 +359,67 @@ ${error.getFullMessage()}`,
return signedOut(authenticateContext, reason, message);
}
+ /**
+ * Determines if a handshake must occur to resolve a mismatch between the organization as specified
+ * by the URL (according to the options) and the actual active organization on the session.
+ *
+ * @returns {HandshakeState | SignedOutState | null} - The function can return the following:
+ * - {HandshakeState}: If a handshake is needed to resolve the mismatched organization.
+ * - {SignedOutState}: If a handshake is required but cannot be performed.
+ * - {null}: If no action is required.
+ */
+ function handleMaybeOrganizationSyncHandshake(
+ authenticateContext: AuthenticateContext,
+ auth: SignedInAuthObject,
+ ): HandshakeState | SignedOutState | null {
+ const organizationSyncTarget = getOrganizationSyncTarget(
+ authenticateContext.clerkUrl,
+ options.organizationSyncOptions,
+ organizationSyncTargetMatchers,
+ );
+ if (!organizationSyncTarget) {
+ return null;
+ }
+ let mustActivate = false;
+ if (organizationSyncTarget.type === 'organization') {
+ // Activate an org by slug?
+ if (organizationSyncTarget.organizationSlug && organizationSyncTarget.organizationSlug !== auth.orgSlug) {
+ mustActivate = true;
+ }
+ // Activate an org by ID?
+ if (organizationSyncTarget.organizationId && organizationSyncTarget.organizationId !== auth.orgId) {
+ mustActivate = true;
+ }
+ }
+ // Activate the personal account?
+ if (organizationSyncTarget.type === 'personalAccount' && auth.orgId) {
+ mustActivate = true;
+ }
+ if (!mustActivate) {
+ return null;
+ }
+ if (authenticateContext.handshakeRedirectLoopCounter > 0) {
+ // We have an organization that needs to be activated, but this isn't our first time redirecting.
+ // This is because we attempted to activate the organization previously, but the organization
+ // must not have been valid (either not found, or not valid for this user), and gave us back
+ // a null organization. We won't re-try the handshake, and leave it to the server component to handle.
+ console.warn(
+ 'Clerk: Organization activation handshake loop detected. This is likely due to an invalid organization ID or slug. Skipping organization activation.',
+ );
+ return null;
+ }
+ const handshakeState = handleMaybeHandshakeStatus(
+ authenticateContext,
+ AuthErrorReason.ActiveOrganizationMismatch,
+ '',
+ );
+ if (handshakeState.status !== 'handshake') {
+ // Currently, this is only possible if we're in a redirect loop, but the above check should guard against that.
+ return null;
+ }
+ return handshakeState;
+ }
+
async function authenticateRequestWithTokenInHeader() {
const { sessionTokenInHeader } = authenticateContext;
@@ -509,7 +589,23 @@ ${error.getFullMessage()}`,
if (errors) {
throw errors[0];
}
- return signedIn(authenticateContext, data, undefined, authenticateContext.sessionTokenInCookie!);
+ const signedInRequestState = signedIn(
+ authenticateContext,
+ data,
+ undefined,
+ authenticateContext.sessionTokenInCookie!,
+ );
+
+ // Org sync if necessary
+ const handshakeRequestState = handleMaybeOrganizationSyncHandshake(
+ authenticateContext,
+ signedInRequestState.toAuth(),
+ );
+ if (handshakeRequestState) {
+ return handshakeRequestState;
+ }
+
+ return signedInRequestState;
} catch (err) {
return handleError(err, 'cookie');
}
@@ -585,6 +681,132 @@ export const debugRequestState = (params: RequestState) => {
return { isSignedIn, proxyUrl, reason, message, publishableKey, isSatellite, domain };
};
+type OrganizationSyncTargetMatchers = {
+ OrganizationMatcher: MatchFunction>> | null;
+ PersonalAccountMatcher: MatchFunction>> | null;
+};
+
+/**
+ * Computes regex-based matchers from the given organization sync options.
+ */
+export function computeOrganizationSyncTargetMatchers(
+ options: OrganizationSyncOptions | undefined,
+): OrganizationSyncTargetMatchers {
+ let personalAccountMatcher: MatchFunction>> | null = null;
+ if (options?.personalAccountPatterns) {
+ try {
+ personalAccountMatcher = match(options.personalAccountPatterns);
+ } catch (e) {
+ // Likely to be encountered during development, so throwing the error is more prudent than logging
+ throw new Error(`Invalid personal account pattern "${options.personalAccountPatterns}": "${e}"`);
+ }
+ }
+
+ let organizationMatcher: MatchFunction>> | null = null;
+ if (options?.organizationPatterns) {
+ try {
+ organizationMatcher = match(options.organizationPatterns);
+ } catch (e) {
+ // Likely to be encountered during development, so throwing the error is more prudent than logging
+ throw new Error(`Clerk: Invalid organization pattern "${options.organizationPatterns}": "${e}"`);
+ }
+ }
+
+ return {
+ OrganizationMatcher: organizationMatcher,
+ PersonalAccountMatcher: personalAccountMatcher,
+ };
+}
+
+/**
+ * Determines if the given URL and settings indicate a desire to activate a specific
+ * organization or personal account.
+ *
+ * @param url - The URL of the original request.
+ * @param options - The organization sync options.
+ * @param matchers - The matchers for the organization and personal account patterns, as generated by `computeOrganizationSyncTargetMatchers`.
+ */
+export function getOrganizationSyncTarget(
+ url: URL,
+ options: OrganizationSyncOptions | undefined,
+ matchers: OrganizationSyncTargetMatchers,
+): OrganizationSyncTarget | null {
+ if (!options) {
+ return null;
+ }
+
+ // Check for personal account activation
+ if (matchers.PersonalAccountMatcher) {
+ let personalResult: Match>>;
+ try {
+ personalResult = matchers.PersonalAccountMatcher(url.pathname);
+ } catch (e) {
+ // Intentionally not logging the path to avoid potentially leaking anything sensitive
+ console.error(`Failed to apply personal account pattern "${options.personalAccountPatterns}" to a path`, e);
+ return null;
+ }
+
+ if (personalResult) {
+ return { type: 'personalAccount' };
+ }
+ }
+
+ // Check for organization activation
+ if (matchers.OrganizationMatcher) {
+ let orgResult: Match>>;
+ try {
+ orgResult = matchers.OrganizationMatcher(url.pathname);
+ } catch (e) {
+ // Intentionally not logging the path to avoid potentially leaking anything sensitive
+ console.error(`Clerk: Failed to apply organization pattern "${options.organizationPatterns}" to a path`, e);
+ return null;
+ }
+
+ if (orgResult && 'params' in orgResult) {
+ const params = orgResult.params;
+
+ if ('id' in params && typeof params.id === 'string') {
+ return { type: 'organization', organizationId: params.id };
+ }
+ if ('slug' in params && typeof params.slug === 'string') {
+ return { type: 'organization', organizationSlug: params.slug };
+ }
+ console.warn(
+ 'Clerk: Detected an organization pattern match, but no organization ID or slug was found in the URL. Does the pattern include `:id` or `:slug`?',
+ );
+ }
+ }
+ return null;
+}
+
+/**
+ * Represents an organization or a personal account - e.g. an
+ * entity that can be activated by the handshake API.
+ */
+export type OrganizationSyncTarget =
+ | { type: 'personalAccount' }
+ | { type: 'organization'; organizationId?: string; organizationSlug?: string };
+
+/**
+ * Generates the query parameters to activate an organization or personal account
+ * via the FAPI handshake api.
+ */
+function getOrganizationSyncQueryParams(toActivate: OrganizationSyncTarget): Map {
+ const ret = new Map();
+ if (toActivate.type === 'personalAccount') {
+ ret.set('organization_id', '');
+ }
+ if (toActivate.type === 'organization') {
+ if (toActivate.organizationId) {
+ ret.set('organization_id', toActivate.organizationId);
+ }
+ if (toActivate.organizationSlug) {
+ ret.set('organization_id', toActivate.organizationSlug);
+ }
+ }
+ return ret;
+}
+
const convertTokenVerificationErrorReasonToAuthErrorReason = ({
tokenError,
refreshError,
diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts
index 3136f98338b..0e97c0026c4 100644
--- a/packages/backend/src/tokens/types.ts
+++ b/packages/backend/src/tokens/types.ts
@@ -10,5 +10,60 @@ export type AuthenticateRequestOptions = {
signUpUrl?: string;
afterSignInUrl?: string;
afterSignUpUrl?: string;
+ organizationSyncOptions?: OrganizationSyncOptions;
apiClient?: ApiClient;
} & VerifyTokenOptions;
+
+/**
+ * Defines the options for syncing an organization or personal account state from the URL to the clerk session.
+ * Useful if the application requires the inclusion of a URL that indicates that a given clerk organization
+ * (or personal account) must be active on the clerk session.
+ *
+ * If a mismatch between the active organization on the session and the organization as indicated by the URL is
+ * detected, an attempt to activate the given organization will be made.
+ *
+ * WARNING: If the activation cannot be performed, either because an organization does not exist or the user lacks
+ * access, then the active organization on the session will not be changed (and a warning will be logged). It is
+ * ultimately the responsibility of the page to verify that the resources are appropriate to render given the URL,
+ * and handle mismatches appropriately (e.g. by returning a 404).
+ */
+export type OrganizationSyncOptions = {
+ /**
+ * URL patterns that are organization-specific and contain an organization ID or slug as a path token.
+ * If a request matches this path, the organization identifier will be extracted and activated before rendering.
+ *
+ * WARNING: If the organization cannot be activated either because it does not exist or the user lacks access,
+ * organization-related fields will be set to null. The server component must detect this and respond
+ * with an appropriate error (e.g., notFound()).
+ *
+ * If the route also matches the personalAccountPatterns, the personalAccountPattern takes precedence.
+ *
+ * Must have a path token named either ":id" (matches a clerk organization ID) or ":slug" (matches a clerk
+ * organization slug).
+ *
+ * Common examples:
+ * - ["/orgs/:slug", "/orgs/:slug/(.*)"]
+ * - ["/orgs/:id", "/orgs/:id/(.*)"]
+ * - ["/app/:any/orgs/:slug", "/app/:any/orgs/:slug/(.*)"]
+ */
+ organizationPatterns?: Pattern[];
+
+ /**
+ * URL patterns for resources in the context of a clerk personal account (user-specific, outside any organization).
+ * If the route also matches the organizationPattern, this takes precedence.
+ *
+ * Common examples:
+ * - ["/user", "/user/(.*)"]
+ * - ["/user/:any", "/user/:any/(.*)"]
+ */
+ personalAccountPatterns?: Pattern[];
+};
+
+/**
+ * A pattern representing the structure of a URL path.
+ * In addition to a valid URL, may include:
+ * - Named path tokens prefixed with a colon (e.g., ":id", ":slug", ":any")
+ * - Wildcard token (e.g., ".(*)"), which will match the remainder of the path
+ * Examples: "/orgs/:slug", "/app/:any/orgs/:id", "/personal-account/(.*)"
+ */
+type Pattern = string;