diff --git a/.changeset/satellite-auto-sync.md b/.changeset/satellite-auto-sync.md new file mode 100644 index 00000000000..84317c1bee4 --- /dev/null +++ b/.changeset/satellite-auto-sync.md @@ -0,0 +1,78 @@ +--- +"@clerk/backend": minor +"@clerk/shared": minor +"@clerk/clerk-js": minor +"@clerk/tanstack-react-start": minor +"@clerk/nextjs": patch +"@clerk/astro": patch +--- + +Add `satelliteAutoSync` option to optimize satellite app handshake behavior + +Satellite apps currently trigger a handshake redirect on every first page load, even when no cookies exist. This creates unnecessary redirects to the primary domain for apps where most users aren't authenticated. + +**New option: `satelliteAutoSync`** (default: `true`) +- When `true` (default): Satellite apps automatically trigger handshake on first load (existing behavior) +- When `false`: Skip automatic handshake if no session cookies exist, only trigger after explicit sign-in action + +**New query parameter: `__clerk_sync`** +- `__clerk_sync=1` (NeedsSync): Triggers handshake after returning from primary sign-in +- `__clerk_sync=2` (Completed): Prevents re-sync loop after handshake completes + +Backwards compatible: Still reads legacy `__clerk_synced=true` parameter. + +**SSR redirect fix**: Server-side redirects (e.g., `redirectToSignIn()` from middleware) now correctly add `__clerk_sync=1` to the return URL for satellite apps. This ensures the handshake is triggered when the user returns from sign-in on the primary domain. + +**CSR redirect fix**: Client-side redirects now add `__clerk_sync=1` to all redirect URL variants (`forceRedirectUrl`, `fallbackRedirectUrl`) for satellite apps, not just the default `redirectUrl`. + +## Usage + +### SSR (Next.js Middleware) +```typescript +import { clerkMiddleware } from '@clerk/nextjs/server'; + +export default clerkMiddleware({ + isSatellite: true, + domain: 'satellite.example.com', + signInUrl: 'https://primary.example.com/sign-in', + satelliteAutoSync: false, +}); +``` + +### SSR (TanStack Start) +```typescript +import { clerkMiddleware } from '@clerk/tanstack-react-start/server'; + +export default clerkMiddleware({ + isSatellite: true, + domain: 'satellite.example.com', + signInUrl: 'https://primary.example.com/sign-in', + satelliteAutoSync: false, +}); +``` + +### CSR (ClerkProvider) +```tsx + + {children} + +``` + +### SSR (TanStack Start with callback) +```typescript +import { clerkMiddleware } from '@clerk/tanstack-react-start/server'; + +// Options callback - receives context object, returns options +export default clerkMiddleware(({ url }) => ({ + isSatellite: true, + domain: 'satellite.example.com', + signInUrl: 'https://primary.example.com/sign-in', + satelliteAutoSync: url.pathname.startsWith('/dashboard'), +})); +``` diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts index ae9ee00e007..dc6975fc524 100644 --- a/integration/tests/handshake.test.ts +++ b/integration/tests/handshake.test.ts @@ -42,6 +42,7 @@ test.describe('Client handshake @generic', () => { () => `import { clerkMiddleware } from '@clerk/nextjs/server'; export const middleware = (req, evt) => { + const satelliteAutoSyncHeader = req.headers.get('x-satellite-auto-sync'); return clerkMiddleware({ publishableKey: req.headers.get("x-publishable-key"), secretKey: req.headers.get("x-secret-key"), @@ -49,6 +50,7 @@ test.describe('Client handshake @generic', () => { domain: req.headers.get("x-domain"), isSatellite: req.headers.get('x-satellite') === 'true', signInUrl: req.headers.get("x-sign-in-url"), + satelliteAutoSync: satelliteAutoSyncHeader === null ? undefined : satelliteAutoSyncHeader === 'true', })(req, evt) }; @@ -567,6 +569,86 @@ test.describe('Client handshake @generic', () => { expect(res.status).toBe(200); }); + test('signed out satellite with satelliteAutoSync=false skips handshake - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'X-Satellite-Auto-Sync': 'false', + 'Sec-Fetch-Dest': 'document', + }), + redirect: 'manual', + }); + // Should NOT redirect to handshake when satelliteAutoSync=false and no cookies + expect(res.status).toBe(200); + }); + + test('signed out satellite with satelliteAutoSync=false triggers handshake when __clerk_synced=false - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/?__clerk_synced=false', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'X-Satellite-Auto-Sync': 'false', + 'Sec-Fetch-Dest': 'document', + }), + redirect: 'manual', + }); + // Should redirect to handshake when __clerk_synced=false is present + expect(res.status).toBe(307); + const locationUrl = new URL(res.headers.get('location')); + expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake'); + expect(locationUrl.searchParams.get('__clerk_hs_reason')).toBe('satellite-needs-syncing'); + }); + + test('signed out satellite skips handshake when __clerk_synced=true (completed) - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/?__clerk_synced=true', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'Sec-Fetch-Dest': 'document', + }), + redirect: 'manual', + }); + // Should NOT redirect when __clerk_synced=true indicates sync already completed + expect(res.status).toBe(200); + }); + + test('signed out satellite with satelliteAutoSync=true (default) triggers handshake - prod', async () => { + const config = generateConfig({ + mode: 'live', + }); + const res = await fetch(app.serverUrl + '/', { + headers: new Headers({ + 'X-Publishable-Key': config.pk, + 'X-Secret-Key': config.sk, + 'X-Satellite': 'true', + 'X-Domain': 'example.com', + 'X-Satellite-Auto-Sync': 'true', + 'Sec-Fetch-Dest': 'document', + }), + redirect: 'manual', + }); + // Should redirect to handshake with default/true satelliteAutoSync + expect(res.status).toBe(307); + const locationUrl = new URL(res.headers.get('location')); + expect(locationUrl.origin + locationUrl.pathname).toBe('https://clerk.example.com/v1/client/handshake'); + }); + test('missing session token, missing uat (indicating signed out), missing devbrowser - dev', async () => { const config = generateConfig({ mode: 'test', diff --git a/packages/astro/src/server/clerk-middleware.ts b/packages/astro/src/server/clerk-middleware.ts index b40c656de99..fa0500f2197 100644 --- a/packages/astro/src/server/clerk-middleware.ts +++ b/packages/astro/src/server/clerk-middleware.ts @@ -300,6 +300,7 @@ function decorateAstroLocal( signInUrl: requestState.signInUrl, signUpUrl: requestState.signUpUrl, sessionStatus: requestState.toAuth()?.sessionStatus, + isSatellite: requestState.isSatellite, }).redirectToSignIn({ returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl.toString(), }); @@ -422,6 +423,7 @@ const handleControlFlowErrors = ( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion publishableKey: getSafeEnv(context).pk!, sessionStatus: requestState.toAuth()?.sessionStatus, + isSatellite: requestState.isSatellite, }).redirectToSignIn({ returnBackUrl: e.returnBackUrl }); default: throw e; diff --git a/packages/backend/src/__tests__/createRedirect.test.ts b/packages/backend/src/__tests__/createRedirect.test.ts index ee4fb558d16..0877146bb89 100644 --- a/packages/backend/src/__tests__/createRedirect.test.ts +++ b/packages/backend/src/__tests__/createRedirect.test.ts @@ -99,26 +99,44 @@ describe('redirect(redirectAdapter)', () => { ); }); - it('removes __clerk_synced when cross-origin redirect', () => { - const returnBackUrl = 'https://current.url:3000/path?__clerk_synced=true&q=1#hash'; - const encodedUrl = 'https%3A%2F%2Fcurrent.url%3A3000%2Fpath%3Fq%3D1%23hash'; - const signUpUrl = 'https://lcl.dev/sign-up'; + it('adds __clerk_synced=false to returnBackUrl for satellite apps on cross-origin redirect', () => { + const returnBackUrl = 'https://satellite.example.com/dashboard'; + const encodedUrl = 'https%3A%2F%2Fsatellite.example.com%2Fdashboard%3F__clerk_synced%3Dfalse'; + const signInUrl = 'https://primary.example.com/sign-in'; const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue'); - const { redirectToSignUp } = createRedirect({ - baseUrl: 'http://www.clerk.com', - devBrowserToken: 'deadbeef', + const { redirectToSignIn } = createRedirect({ + baseUrl: 'https://satellite.example.com', redirectAdapter: redirectAdapterSpy, - publishableKey: 'pk_test_Y2xlcmsubGNsLmRldiQ', + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', sessionStatus: 'active', - signUpUrl, + signInUrl, + isSatellite: true, }); - const result = redirectToSignUp({ returnBackUrl }); + const result = redirectToSignIn({ returnBackUrl }); expect(result).toBe('redirectAdapterValue'); - expect(redirectAdapterSpy).toHaveBeenCalledWith( - `${signUpUrl}?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`, - ); + expect(redirectAdapterSpy).toHaveBeenCalledWith(`${signInUrl}?redirect_url=${encodedUrl}`); + }); + + it('does not add __clerk_synced=false for non-satellite apps', () => { + const returnBackUrl = 'https://app.example.com/dashboard'; + const encodedUrl = 'https%3A%2F%2Fapp.example.com%2Fdashboard'; + const signInUrl = 'https://accounts.example.com/sign-in'; + + const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue'); + const { redirectToSignIn } = createRedirect({ + baseUrl: 'https://app.example.com', + redirectAdapter: redirectAdapterSpy, + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + sessionStatus: 'active', + signInUrl, + isSatellite: false, + }); + + const result = redirectToSignIn({ returnBackUrl }); + expect(result).toBe('redirectAdapterValue'); + expect(redirectAdapterSpy).toHaveBeenCalledWith(`${signInUrl}?redirect_url=${encodedUrl}`); }); it('returns path based url with development (kima) publishableKey (with staging Clerk) but without signUpUrl to redirectToSignUp', () => { @@ -342,25 +360,5 @@ describe('redirect(redirectAdapter)', () => { `https://included.katydid-92.accounts.dev/sign-up/tasks?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`, ); }); - - it('removes __clerk_synced when cross-origin redirect', () => { - const returnBackUrl = 'https://current.url:3000/path?__clerk_synced=true&q=1#hash'; - const encodedUrl = 'https%3A%2F%2Fcurrent.url%3A3000%2Fpath%3Fq%3D1%23hash'; - - const redirectAdapterSpy = vi.fn().mockImplementation(_url => 'redirectAdapterValue'); - const { redirectToSignUp } = createRedirect({ - baseUrl: 'http://www.clerk.com', - devBrowserToken: 'deadbeef', - redirectAdapter: redirectAdapterSpy, - publishableKey: 'pk_test_Y2xlcmsubGNsLmRldiQ', - sessionStatus: 'pending', - }); - - const result = redirectToSignUp({ returnBackUrl }); - expect(result).toBe('redirectAdapterValue'); - expect(redirectAdapterSpy).toHaveBeenCalledWith( - `https://accounts.lcl.dev/sign-up/tasks?redirect_url=${encodedUrl}&__clerk_db_jwt=deadbeef`, - ); - }); }); }); diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index ceaa1721d37..2d5f49625f8 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -74,6 +74,17 @@ const ContentTypes = { Json: 'application/json', } as const; +/** + * Sync status values for the __clerk_synced query parameter. + * Used to coordinate satellite domain authentication flows. + */ +export const ClerkSyncStatus = { + /** Not synced - satellite needs handshake after returning from primary sign-in */ + NeedsSync: 'false', + /** Sync completed - prevents re-sync loop after handshake completes */ + Completed: 'true', +} as const; + /** * @internal */ @@ -83,6 +94,7 @@ export const constants = { Headers, ContentTypes, QueryParameters, + ClerkSyncStatus, } as const; export type Constants = typeof constants; diff --git a/packages/backend/src/createRedirect.ts b/packages/backend/src/createRedirect.ts index 41eb4fd7626..1c06b8302c1 100644 --- a/packages/backend/src/createRedirect.ts +++ b/packages/backend/src/createRedirect.ts @@ -1,7 +1,7 @@ import { buildAccountsBaseUrl } from '@clerk/shared/buildAccountsBaseUrl'; import type { SessionStatusClaim } from '@clerk/shared/types'; -import { constants } from './constants'; +import { ClerkSyncStatus, constants } from './constants'; import { errorThrower, parsePublishableKey } from './util/shared'; const buildUrl = ( @@ -9,6 +9,7 @@ const buildUrl = ( _targetUrl: string | URL, _returnBackUrl?: string | URL | null, _devBrowserToken?: string | null, + _isSatellite?: boolean, ) => { if (_baseUrl === '') { return legacyBuildUrl(_targetUrl.toString(), _returnBackUrl?.toString()); @@ -20,8 +21,10 @@ const buildUrl = ( const isCrossOriginRedirect = `${baseUrl.hostname}:${baseUrl.port}` !== `${res.hostname}:${res.port}`; if (returnBackUrl) { - if (isCrossOriginRedirect) { - returnBackUrl.searchParams.delete(constants.QueryParameters.ClerkSynced); + // For satellite apps redirecting to primary sign-in, add sync trigger to returnBackUrl + // This ensures handshake is triggered when the user returns after signing in + if (isCrossOriginRedirect && _isSatellite) { + returnBackUrl.searchParams.set(constants.QueryParameters.ClerkSynced, ClerkSyncStatus.NeedsSync); } res.searchParams.set('redirect_url', returnBackUrl.toString()); @@ -77,13 +80,14 @@ type CreateRedirect = (params: { signInUrl?: URL | string; signUpUrl?: URL | string; sessionStatus?: SessionStatusClaim | null; + isSatellite?: boolean; }) => { redirectToSignIn: RedirectFun; redirectToSignUp: RedirectFun; }; export const createRedirect: CreateRedirect = params => { - const { publishableKey, redirectAdapter, signInUrl, signUpUrl, baseUrl, sessionStatus } = params; + const { publishableKey, redirectAdapter, signInUrl, signUpUrl, baseUrl, sessionStatus, isSatellite } = params; const parsedPublishableKey = parsePublishableKey(publishableKey); const frontendApi = parsedPublishableKey?.frontendApi; const isDevelopment = parsedPublishableKey?.instanceType === 'development'; @@ -92,7 +96,7 @@ export const createRedirect: CreateRedirect = params => { const redirectToTasks = (url: string | URL, { returnBackUrl }: RedirectToParams) => { return redirectAdapter( - buildUrl(baseUrl, `${url}/tasks`, returnBackUrl, isDevelopment ? params.devBrowserToken : null), + buildUrl(baseUrl, `${url}/tasks`, returnBackUrl, isDevelopment ? params.devBrowserToken : null, isSatellite), ); }; @@ -119,7 +123,9 @@ export const createRedirect: CreateRedirect = params => { return redirectToTasks(targetUrl, { returnBackUrl }); } - return redirectAdapter(buildUrl(baseUrl, targetUrl, returnBackUrl, isDevelopment ? params.devBrowserToken : null)); + return redirectAdapter( + buildUrl(baseUrl, targetUrl, returnBackUrl, isDevelopment ? params.devBrowserToken : null, isSatellite), + ); }; const redirectToSignIn = ({ returnBackUrl }: RedirectToParams = {}) => { @@ -134,7 +140,9 @@ export const createRedirect: CreateRedirect = params => { return redirectToTasks(targetUrl, { returnBackUrl }); } - return redirectAdapter(buildUrl(baseUrl, targetUrl, returnBackUrl, isDevelopment ? params.devBrowserToken : null)); + return redirectAdapter( + buildUrl(baseUrl, targetUrl, returnBackUrl, isDevelopment ? params.devBrowserToken : null, isSatellite), + ); }; return { redirectToSignUp, redirectToSignIn }; diff --git a/packages/backend/src/tokens/__tests__/request.test.ts b/packages/backend/src/tokens/__tests__/request.test.ts index 450da616de5..115dd24be2b 100644 --- a/packages/backend/src/tokens/__tests__/request.test.ts +++ b/packages/backend/src/tokens/__tests__/request.test.ts @@ -688,15 +688,15 @@ describe('tokens.authenticateRequest(options)', () => { const requestState = await authenticateRequest( mockRequestWithCookies( - {}, + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, { __client_uat: '0', + __clerk_db_jwt: mockJwt, }, ), mockOptions({ - secretKey: 'deadbeef', + secretKey: 'sk_test_deadbeef', publishableKey: PK_TEST, - clientUat: '0', isSatellite: true, signInUrl: 'https://primary.dev/sign-in', domain: 'satellite.dev', @@ -790,6 +790,84 @@ describe('tokens.authenticateRequest(options)', () => { expect(requestState.toAuth()).toBeSignedOutToAuth(); }); + test('cookieToken: returns signed out without handshake when satelliteAutoSync is false and no cookies', async () => { + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { __client_uat: '0' }, + `http://satellite.example/path`, + ), + mockOptions({ + secretKey: 'deadbeef', + publishableKey: PK_LIVE, + signInUrl: 'https://primary.example/sign-in', + isSatellite: true, + domain: 'satellite.example', + satelliteAutoSync: false, + }), + ); + + expect(requestState).toBeSignedOut({ + reason: AuthErrorReason.SessionTokenAndUATMissing, + isSatellite: true, + domain: 'satellite.example', + signInUrl: 'https://primary.example/sign-in', + }); + expect(requestState.toAuth()).toBeSignedOutToAuth(); + // Should NOT have a location header (no handshake redirect) + expect(requestState.headers.get('location')).toBeNull(); + }); + + test('cookieToken: triggers handshake when satelliteAutoSync is false but __clerk_synced=false is present', async () => { + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { __client_uat: '0' }, + `http://satellite.example/path?__clerk_synced=false`, + ), + mockOptions({ + secretKey: 'deadbeef', + publishableKey: PK_LIVE, + signInUrl: 'https://primary.example/sign-in', + isSatellite: true, + domain: 'satellite.example', + satelliteAutoSync: false, + }), + ); + + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.SatelliteCookieNeedsSyncing, + isSatellite: true, + domain: 'satellite.example', + signInUrl: 'https://primary.example/sign-in', + }); + }); + + test('cookieToken: returns signed out when __clerk_synced=true (completed) is present', async () => { + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { __client_uat: '0' }, + `http://satellite.example/path?__clerk_synced=true`, + ), + mockOptions({ + secretKey: 'deadbeef', + publishableKey: PK_LIVE, + signInUrl: 'https://primary.example/sign-in', + isSatellite: true, + domain: 'satellite.example', + }), + ); + + expect(requestState).toBeSignedOut({ + reason: AuthErrorReason.SessionTokenAndUATMissing, + isSatellite: true, + domain: 'satellite.example', + signInUrl: 'https://primary.example/sign-in', + }); + expect(requestState.toAuth()).toBeSignedOutToAuth(); + }); + test('cookieToken: returns handshake when app is not satellite and responds to syncing on dev instances[12y]', async () => { const sp = new URLSearchParams(); sp.set('__clerk_redirect_url', 'http://localhost:3000'); @@ -833,7 +911,35 @@ describe('tokens.authenticateRequest(options)', () => { const location = requestState.headers.get('location'); expect(location).toBeTruthy(); expect(location).toContain('localhost:3001/dashboard'); + // Should contain the sync param (with Completed status) + expect(location).toContain('__clerk_synced=true'); + }); + + test('cookieToken: primary responds to syncing overwrites __clerk_synced=false with __clerk_synced=true (no duplicates)', async () => { + const sp = new URLSearchParams(); + // Redirect URL already contains __clerk_synced=false (NeedsSync) + sp.set('__clerk_redirect_url', 'http://localhost:3001/dashboard?__clerk_synced=false'); + sp.set('__clerk_db_jwt', mockJwt); + const requestUrl = `http://localhost:3000/sign-in?${sp.toString()}`; + const requestState = await authenticateRequest( + mockRequestWithCookies( + { ...defaultHeaders, 'sec-fetch-dest': 'document' }, + { __client_uat: '12345', __session: mockJwt, __clerk_db_jwt: mockJwt }, + requestUrl, + ), + mockOptions({ secretKey: 'sk_test_deadbeef', isSatellite: false }), + ); + + expect(requestState).toMatchHandshake({ + reason: AuthErrorReason.PrimaryRespondsToSyncing, + }); + + const location = requestState.headers.get('location'); + expect(location).toBeTruthy(); + // Should overwrite __clerk_synced=false with __clerk_synced=true, not append expect(location).toContain('__clerk_synced=true'); + // Should NOT contain __clerk_synced=false anymore + expect(location).not.toContain('__clerk_synced=false'); }); test('cookieToken: returns signed out when no cookieToken and no clientUat in production [4y]', async () => { diff --git a/packages/backend/src/tokens/request.ts b/packages/backend/src/tokens/request.ts index 9bf4c2a422d..c9b444e3623 100644 --- a/packages/backend/src/tokens/request.ts +++ b/packages/backend/src/tokens/request.ts @@ -466,28 +466,77 @@ export const authenticateRequest: AuthenticateRequest = (async ( /** * Begin multi-domain sync flows + * + * Sync status values (__clerk_synced query param): + * - 'false' (NeedsSync): Trigger sync - satellite returning from primary sign-in + * - 'true' (Completed): Sync done - prevents re-sync loop + * + * With satelliteAutoSync=false: + * - Skip handshake on first visit if no cookies exist (return signedOut immediately) + * - Trigger handshake when __clerk_synced=false is present (post sign-in redirect) + * - Allow normal token verification flow when cookies exist (enables refresh) */ - if (authenticateContext.instanceType === 'production' && isRequestEligibleForMultiDomainSync) { - return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, ''); + + // Check sync status param (__clerk_synced=false triggers sync, __clerk_synced=true means completed) + const syncedParam = authenticateContext.clerkUrl.searchParams.get(constants.QueryParameters.ClerkSynced); + const needsSync = syncedParam === constants.ClerkSyncStatus.NeedsSync; + const syncCompleted = syncedParam === constants.ClerkSyncStatus.Completed; + + // Check if cookies exist (session token or active client UAT) + const hasCookies = hasSessionToken || hasActiveClient; + + // Determine if we should skip handshake for satellites with no cookies + // satelliteAutoSync defaults to true, so we only skip when explicitly set to false + const shouldSkipSatelliteHandshake = authenticateContext.satelliteAutoSync === false && !hasCookies && !needsSync; + + if (authenticateContext.instanceType === 'production' && isRequestEligibleForMultiDomainSync && !syncCompleted) { + // With satelliteAutoSync=false: skip handshake if no cookies and no sync trigger + if (shouldSkipSatelliteHandshake) { + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.SessionTokenAndUATMissing, + }); + } + + // If cookies exist, fall through to normal token verification flow (enables refresh) + // Only trigger handshake if no cookies exist (or sync was explicitly requested) + if (!hasCookies || needsSync) { + return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, ''); + } + // Fall through to normal token verification flow when cookies exist } // Multi-domain development sync flow - if ( - authenticateContext.instanceType === 'development' && - isRequestEligibleForMultiDomainSync && - !authenticateContext.clerkUrl.searchParams.has(constants.QueryParameters.ClerkSynced) - ) { - // initiate MD sync + if (authenticateContext.instanceType === 'development' && isRequestEligibleForMultiDomainSync && !syncCompleted) { + // With satelliteAutoSync=false: skip sync if no cookies and no sync trigger + if (shouldSkipSatelliteHandshake) { + return signedOut({ + tokenType: TokenType.SessionToken, + authenticateContext, + reason: AuthErrorReason.SessionTokenAndUATMissing, + }); + } - // signInUrl exists, checked at the top of `authenticateRequest` - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const redirectURL = new URL(authenticateContext.signInUrl!); - redirectURL.searchParams.append( - constants.QueryParameters.ClerkRedirectUrl, - authenticateContext.clerkUrl.toString(), - ); - const headers = new Headers({ [constants.Headers.Location]: redirectURL.toString() }); - return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '', headers); + // If cookies exist, fall through to normal flow (enables refresh) + if (!hasCookies || needsSync) { + // initiate MD sync + // signInUrl exists, checked at the top of `authenticateRequest` + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const redirectURL = new URL(authenticateContext.signInUrl!); + redirectURL.searchParams.append( + constants.QueryParameters.ClerkRedirectUrl, + authenticateContext.clerkUrl.toString(), + ); + const headers = new Headers({ [constants.Headers.Location]: redirectURL.toString() }); + return handleMaybeHandshakeStatus( + authenticateContext, + AuthErrorReason.SatelliteCookieNeedsSyncing, + '', + headers, + ); + } + // Fall through to normal token verification flow when cookies exist } // Multi-domain development sync flow - primary responds to syncing @@ -507,7 +556,12 @@ export const authenticateRequest: AuthenticateRequest = (async ( authenticateContext.devBrowserToken, ); } - redirectBackToSatelliteUrl.searchParams.append(constants.QueryParameters.ClerkSynced, 'true'); + // Use set (not append) to ensure completion status overwrites any existing NeedsSync value + // This prevents sync loops when the redirect URL already contains __clerk_synced=false + redirectBackToSatelliteUrl.searchParams.set( + constants.QueryParameters.ClerkSynced, + constants.ClerkSyncStatus.Completed, + ); const headers = new Headers({ [constants.Headers.Location]: redirectBackToSatelliteUrl.toString() }); return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.PrimaryRespondsToSyncing, '', headers); diff --git a/packages/backend/src/tokens/types.ts b/packages/backend/src/tokens/types.ts index 17def630280..657f4c9829d 100644 --- a/packages/backend/src/tokens/types.ts +++ b/packages/backend/src/tokens/types.ts @@ -72,6 +72,20 @@ export type AuthenticateRequestOptions = { * This will override the Clerk secret key. */ machineSecretKey?: string; + /** + * Controls whether satellite apps automatically sync with the primary domain on initial page load. + * + * When `true` (default), satellite apps will automatically trigger a handshake redirect + * to sync authentication state with the primary domain, even if no session cookies exist. + * + * When `false`, satellite apps will skip the automatic handshake if no session cookies exist, + * and only trigger the handshake after an explicit sign-in action (when the `__clerk_synced=false` + * query parameter is present). This optimizes performance for satellite apps where users + * may not be authenticated, avoiding unnecessary redirects to the primary domain. + * + * @default true + */ + satelliteAutoSync?: boolean; } & VerifyTokenOptions; /** diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 48d108389ce..9d3629909dc 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -22,6 +22,7 @@ import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED, + CLERK_SYNCED_STATUS, ERROR_CODES, } from '@clerk/shared/internal/clerk-js/constants'; import { RedirectUrls } from '@clerk/shared/internal/clerk-js/redirectUrls'; @@ -1680,21 +1681,75 @@ export class Clerk implements ClerkInterface { } public buildSignInUrl(options?: SignInRedirectOptions): string { - return this.#buildUrl( - 'signInUrl', - { ...options, redirectUrl: options?.redirectUrl || window.location.href }, - options?.initialValues, - ); + let redirectUrl = options?.redirectUrl || window.location.href; + + // For satellites, add sync trigger param to all redirect URLs + // This ensures handshake is triggered when returning from primary sign-in + if (this.isSatellite) { + redirectUrl = this.#addSyncTriggerToUrl(redirectUrl); + } + + const modifiedOptions = this.isSatellite ? this.#addSyncTriggerToRedirectOptions(options) : options; + + return this.#buildUrl('signInUrl', { ...modifiedOptions, redirectUrl }, options?.initialValues); } public buildSignUpUrl(options?: SignUpRedirectOptions): string { - return this.#buildUrl( - 'signUpUrl', - { ...options, redirectUrl: options?.redirectUrl || window.location.href }, - options?.initialValues, - ); + let redirectUrl = options?.redirectUrl || window.location.href; + + // For satellites, add sync trigger param to all redirect URLs + // This ensures handshake is triggered when returning from primary sign-up + if (this.isSatellite) { + redirectUrl = this.#addSyncTriggerToUrl(redirectUrl); + } + + const modifiedOptions = this.isSatellite ? this.#addSyncTriggerToRedirectOptions(options) : options; + + return this.#buildUrl('signUpUrl', { ...modifiedOptions, redirectUrl }, options?.initialValues); } + /** + * Adds the __clerk_synced=false param to a URL to trigger satellite handshake + * when the user returns from primary domain after sign-in/sign-up. + */ + #addSyncTriggerToUrl = (urlString: string): string => { + try { + const url = new URL(urlString, window.location.origin); + url.searchParams.set(CLERK_SYNCED, CLERK_SYNCED_STATUS.NeedsSync); + return url.toString(); + } catch { + // If URL parsing fails, return original + return urlString; + } + }; + + /** + * Adds the __clerk_synced=false param to all redirect URL options for satellites. + * This handles forceRedirectUrl and fallbackRedirectUrl variants. + */ + #addSyncTriggerToRedirectOptions = (options: T): T => { + if (!options) { + return options; + } + + const result = { ...options } as RedirectOptions; + + if (result.signInForceRedirectUrl) { + result.signInForceRedirectUrl = this.#addSyncTriggerToUrl(result.signInForceRedirectUrl); + } + if (result.signInFallbackRedirectUrl) { + result.signInFallbackRedirectUrl = this.#addSyncTriggerToUrl(result.signInFallbackRedirectUrl); + } + if (result.signUpForceRedirectUrl) { + result.signUpForceRedirectUrl = this.#addSyncTriggerToUrl(result.signUpForceRedirectUrl); + } + if (result.signUpFallbackRedirectUrl) { + result.signUpFallbackRedirectUrl = this.#addSyncTriggerToUrl(result.signUpFallbackRedirectUrl); + } + + return result as T; + }; + public buildUserProfileUrl(): string { if (!this.environment || !this.environment.displayConfig) { return ''; @@ -1806,8 +1861,9 @@ export class Clerk implements ClerkInterface { if (!inBrowser()) { return; } + // Use __clerk_synced=true to signal sync completion const searchParams = new URLSearchParams({ - [CLERK_SYNCED]: 'true', + [CLERK_SYNCED]: CLERK_SYNCED_STATUS.Completed, }); const satelliteUrl = getClerkQueryParam(CLERK_SATELLITE_URL); @@ -2609,7 +2665,9 @@ export class Clerk implements ClerkInterface { }; #shouldSyncWithPrimary = (): boolean => { - if (getClerkQueryParam(CLERK_SYNCED) === 'true') { + // Check sync status: __clerk_synced=true means completed, __clerk_synced=false means needs sync + const syncStatus = getClerkQueryParam(CLERK_SYNCED); + if (syncStatus === CLERK_SYNCED_STATUS.Completed) { return false; } @@ -2617,6 +2675,19 @@ export class Clerk implements ClerkInterface { return false; } + // Check if sync was triggered (e.g., after returning from primary sign-in) + const needsSync = syncStatus === CLERK_SYNCED_STATUS.NeedsSync; + if (needsSync) { + return true; + } + + // Check if satelliteAutoSync is disabled - if so, skip automatic sync + // unless explicitly triggered via __clerk_synced=false + if (this.#options.satelliteAutoSync === false) { + // Skip automatic sync when satelliteAutoSync is false + return false; + } + return !!this.#authService?.isSignedOut(); }; diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index c8e0549e82d..f5ce9a3190d 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -109,6 +109,7 @@ export const auth: AuthFn = (async (options?: AuthOptions) => { signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL, signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL, sessionStatus: authObject.tokenType === TokenType.SessionToken ? authObject.sessionStatus : null, + isSatellite: decryptedRequestData.isSatellite, }), returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(), ] as const; diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 671c034af63..9c9763128c1 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -506,6 +506,7 @@ const handleControlFlowErrors = ( signUpUrl: requestState.signUpUrl, publishableKey: requestState.publishableKey, sessionStatus: requestState.toAuth()?.sessionStatus, + isSatellite: requestState.isSatellite, }); const { returnBackUrl } = e; diff --git a/packages/shared/src/internal/clerk-js/constants.ts b/packages/shared/src/internal/clerk-js/constants.ts index bcb2a632529..fae8b43b265 100644 --- a/packages/shared/src/internal/clerk-js/constants.ts +++ b/packages/shared/src/internal/clerk-js/constants.ts @@ -13,6 +13,12 @@ export const PRESERVED_QUERYSTRING_PARAMS = [ export const CLERK_MODAL_STATE = '__clerk_modal_state'; export const CLERK_SYNCED = '__clerk_synced'; +export const CLERK_SYNCED_STATUS = { + /** Not synced - satellite needs handshake after returning from primary sign-in */ + NeedsSync: 'false', + /** Sync completed - prevents re-sync loop after handshake completes */ + Completed: 'true', +} as const; export const CLERK_SUFFIXED_COOKIES = 'suffixed_cookies'; export const CLERK_SATELLITE_URL = '__clerk_satellite_url'; export const ERROR_CODES = { diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 33b15d3bfe8..7b88855fdc3 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1132,6 +1132,20 @@ export type ClerkOptions = ClerkOptionsNavigation & * This option defines that the application is a satellite application. */ isSatellite?: boolean | ((url: URL) => boolean); + /** + * Controls whether satellite apps automatically sync with the primary domain on initial page load. + * + * When `true` (default), satellite apps will automatically trigger a handshake redirect + * to sync authentication state with the primary domain, even if no session cookies exist. + * + * When `false`, satellite apps will skip the automatic handshake if no session cookies exist, + * and only trigger the handshake after an explicit sign-in action (when the `__clerk_synced=false` + * query parameter is present). This optimizes performance for satellite apps where users + * may not be authenticated, avoiding unnecessary redirects to the primary domain. + * + * @default true + */ + satelliteAutoSync?: boolean; /** * Controls whether or not Clerk will collect [telemetry data](https://clerk.com/docs/guides/how-clerk-works/security/clerk-telemetry). If set to `debug`, telemetry events are only logged to the console and not sent to Clerk. */ diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 661d9705ac2..89d0631a988 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -7,13 +7,20 @@ import { createMiddleware, json } from '@tanstack/react-start'; import { clerkClient } from './clerkClient'; import { loadOptions } from './loadOptions'; -import type { ClerkMiddlewareOptions } from './types'; +import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; import { getResponseClerkState, patchRequest } from './utils'; -export const clerkMiddleware = (options?: ClerkMiddlewareOptions): AnyRequestMiddleware => { +export const clerkMiddleware = ( + options?: ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback, +): AnyRequestMiddleware => { return createMiddleware().server(async args => { const clerkRequest = createClerkRequest(patchRequest(args.request)); - const loadedOptions = loadOptions(clerkRequest, options); + + // Resolve options: if function, call it with context object; otherwise use as-is + const resolvedOptions = typeof options === 'function' ? await options({ url: clerkRequest.clerkUrl }) : options; + + const loadedOptions = loadOptions(clerkRequest, resolvedOptions); + const requestState = await clerkClient().authenticateRequest(clerkRequest, { ...loadedOptions, acceptsToken: 'any', diff --git a/packages/tanstack-react-start/src/server/index.ts b/packages/tanstack-react-start/src/server/index.ts index 5bf8d415361..1c5def3396e 100644 --- a/packages/tanstack-react-start/src/server/index.ts +++ b/packages/tanstack-react-start/src/server/index.ts @@ -1,6 +1,7 @@ export { auth } from './auth'; export { clerkClient } from './clerkClient'; export { clerkMiddleware } from './clerkMiddleware'; +export type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; /** * Re-export resource types from @clerk/backend diff --git a/packages/tanstack-react-start/src/server/loadOptions.ts b/packages/tanstack-react-start/src/server/loadOptions.ts index 5fc6e348618..934c95f67df 100644 --- a/packages/tanstack-react-start/src/server/loadOptions.ts +++ b/packages/tanstack-react-start/src/server/loadOptions.ts @@ -3,7 +3,6 @@ import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey import { getEnvVariable } from '@clerk/shared/getEnvVariable'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy'; -import { handleValueOrFn } from '@clerk/shared/utils'; import { errorThrower } from '../utils'; import { commonEnvs } from './constants'; @@ -16,11 +15,12 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {} const publishableKey = overrides.publishableKey || commonEnv.PUBLISHABLE_KEY; const jwtKey = overrides.jwtKey || commonEnv.CLERK_JWT_KEY; const apiUrl = getEnvVariable('CLERK_API_URL') || apiUrlFromPublishableKey(publishableKey); - const domain = handleValueOrFn(overrides.domain, new URL(request.url)) || commonEnv.DOMAIN; - const isSatellite = handleValueOrFn(overrides.isSatellite, new URL(request.url)) || commonEnv.IS_SATELLITE; - const relativeOrAbsoluteProxyUrl = handleValueOrFn(overrides?.proxyUrl, request.clerkUrl, commonEnv.PROXY_URL); + const domain = overrides.domain || commonEnv.DOMAIN; + const isSatellite = overrides.isSatellite || commonEnv.IS_SATELLITE; + const relativeOrAbsoluteProxyUrl = overrides.proxyUrl || commonEnv.PROXY_URL; const signInUrl = overrides.signInUrl || commonEnv.SIGN_IN_URL; const signUpUrl = overrides.signUpUrl || commonEnv.SIGN_UP_URL; + const satelliteAutoSync = overrides.satelliteAutoSync; let proxyUrl; if (!!relativeOrAbsoluteProxyUrl && isProxyUrlRelative(relativeOrAbsoluteProxyUrl)) { @@ -57,5 +57,6 @@ export const loadOptions = (request: ClerkRequest, overrides: LoaderOptions = {} proxyUrl, signInUrl, signUpUrl, + satelliteAutoSync, }; }; diff --git a/packages/tanstack-react-start/src/server/types.ts b/packages/tanstack-react-start/src/server/types.ts index 55963186ade..df35ae5d1e8 100644 --- a/packages/tanstack-react-start/src/server/types.ts +++ b/packages/tanstack-react-start/src/server/types.ts @@ -1,7 +1,7 @@ import type { VerifyTokenOptions } from '@clerk/backend'; import type { OrganizationSyncOptions } from '@clerk/backend/internal'; import type { - MultiDomainAndOrProxy, + MultiDomainAndOrProxyPrimitives, SignInFallbackRedirectUrl, SignInForceRedirectUrl, SignUpFallbackRedirectUrl, @@ -16,8 +16,22 @@ export type ClerkMiddlewareOptions = { signInUrl?: string; signUpUrl?: string; organizationSyncOptions?: OrganizationSyncOptions; + /** + * Controls whether satellite apps automatically sync with the primary domain on initial page load. + * + * When `true` (default), satellite apps will automatically trigger a handshake redirect + * to sync authentication state with the primary domain, even if no session cookies exist. + * + * When `false`, satellite apps will skip the automatic handshake if no session cookies exist, + * and only trigger the handshake after an explicit sign-in action (when the `__clerk_synced=false` + * query parameter is present). This optimizes performance for satellite apps where users + * may not be authenticated, avoiding unnecessary redirects to the primary domain. + * + * @default true + */ + satelliteAutoSync?: boolean; } & Pick & - MultiDomainAndOrProxy & + MultiDomainAndOrProxyPrimitives & SignInForceRedirectUrl & SignInFallbackRedirectUrl & SignUpForceRedirectUrl & @@ -25,6 +39,14 @@ export type ClerkMiddlewareOptions = { export type LoaderOptions = ClerkMiddlewareOptions; +/** + * Callback function that receives request context and returns middleware options. + * Allows dynamic configuration based on the current request. + */ +export type ClerkMiddlewareOptionsCallback = (args: { + url: URL; +}) => ClerkMiddlewareOptions | Promise; + export type AdditionalStateOptions = SignInFallbackRedirectUrl & SignUpFallbackRedirectUrl & SignInForceRedirectUrl &