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;