Skip to content

fix(backend,nextjs): Allow satellite redirect to public route #6225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
7 changes: 7 additions & 0 deletions .changeset/fast-spoons-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/backend': patch
'@clerk/nextjs': patch
'@clerk/astro': patch
---

Updates the redirect logic in satellite applications to permit navigation to public routes after sign-in or sign-up. Previously, users were always redirected to the primary domain or a protected route, even when the original destination was public. This change improves support for more flexible redirect flows in multi-domain setups.
4 changes: 3 additions & 1 deletion packages/astro/src/server/clerk-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => {

const authObject = requestState.toAuth();

const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest);
const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest, requestState);
const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { redirectToSignIn });

decorateAstroLocal(clerkRequest, context, requestState);
Expand Down Expand Up @@ -359,6 +359,8 @@ const redirectAdapter = (url: string | URL) => {

const createMiddlewareRedirectToSignIn = (
clerkRequest: ClerkRequest,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
requestState: RequestState,
): ClerkMiddlewareAuthObject['redirectToSignIn'] => {
return (opts = {}) => {
const err = new Error(CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN) as any;
Expand Down
12 changes: 7 additions & 5 deletions packages/backend/src/tokens/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,20 +452,22 @@ export const authenticateRequest: AuthenticateRequest = (async (
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.DevBrowserSync, '');
}

const isRequestEligibleForMultiDomainSync =
authenticateContext.isSatellite && authenticateContext.secFetchDest === 'document';

/**
* Begin multi-domain sync flows
*/
if (authenticateContext.instanceType === 'production' && isRequestEligibleForMultiDomainSync) {
if (
authenticateContext.instanceType === 'production' &&
authenticateContext.isSatellite &&
authenticateContext.secFetchDest === 'document'
) {
return handleMaybeHandshakeStatus(authenticateContext, AuthErrorReason.SatelliteCookieNeedsSyncing, '');
}

// Multi-domain development sync flow
if (
authenticateContext.instanceType === 'development' &&
isRequestEligibleForMultiDomainSync &&
authenticateContext.isSatellite &&
authenticateContext.secFetchDest === 'document' &&
!authenticateContext.clerkUrl.searchParams.has(constants.QueryParameters.ClerkSynced)
) {
// initiate MD sync
Expand Down
158 changes: 152 additions & 6 deletions packages/nextjs/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,18 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
logger.debug('options', options);
logger.debug('url', () => clerkRequest.toJSON());

// Initial request debugging
logger.debug('🚀 Initial middleware request', () => ({
url: clerkRequest.clerkUrl.toString(),
method: request.method,
isSatellite: options.isSatellite,
domain: options.domain,
signInUrl: options.signInUrl,
hasSessionCookie: !!clerkRequest.cookies.get(constants.Cookies.Session),
hasClientUatCookie: !!clerkRequest.cookies.get(constants.Cookies.ClientUat),
queryParams: Object.fromEntries(clerkRequest.clerkUrl.searchParams.entries()),
}));

const authHeader = request.headers.get(constants.Headers.Authorization);
if (authHeader && authHeader.startsWith('Basic ')) {
logger.debug('Basic Auth detected');
Expand All @@ -180,7 +192,7 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
}));
}

const requestState = await resolvedClerkClient.authenticateRequest(
let requestState = await resolvedClerkClient.authenticateRequest(
clerkRequest,
createAuthenticateRequestOptions(clerkRequest, options),
);
Expand All @@ -199,11 +211,113 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl
throw new Error('Clerk: handshake status without redirect');
}

// Check if we've just completed a satellite domain handshake and should re-authenticate
// This fixes the issue where auth state appears signed out after satellite handshake completion
const debugInfo = {
isSatellite: requestState.isSatellite,
currentStatus: requestState.status,
url: clerkRequest.clerkUrl.toString(),
hasClerkSynced: clerkRequest.clerkUrl.searchParams.has(constants.QueryParameters.ClerkSynced),
hasDevBrowser: clerkRequest.clerkUrl.searchParams.has(constants.QueryParameters.DevBrowser),
hasSessionCookie: !!clerkRequest.cookies.get(constants.Cookies.Session),
hasClientUatCookie: !!clerkRequest.cookies.get(constants.Cookies.ClientUat),
sessionCookieValue: clerkRequest.cookies.get(constants.Cookies.Session) ? '[PRESENT]' : '[MISSING]',
allQueryParams: Object.fromEntries(clerkRequest.clerkUrl.searchParams.entries()),
allCookieNames: Array.from(clerkRequest.cookies.keys()),
};

logger.debug('🔍 Satellite handshake detection debug info', () => debugInfo);

const hasClerkSynced = clerkRequest.clerkUrl.searchParams.has(constants.QueryParameters.ClerkSynced);
const hasDevBrowser = clerkRequest.clerkUrl.searchParams.has(constants.QueryParameters.DevBrowser);
const hasSessionCookie = !!clerkRequest.cookies.get(constants.Cookies.Session);
const isSignedOutWithSession = requestState.status === AuthStatus.SignedOut && hasSessionCookie;

const hasHandshakeCompletionIndicators =
requestState.isSatellite && (hasClerkSynced || hasDevBrowser || isSignedOutWithSession);

logger.debug('🔍 Handshake completion check', () => ({
isSatellite: requestState.isSatellite,
hasClerkSynced,
hasDevBrowser,
isSignedOutWithSession,
hasHandshakeCompletionIndicators,
}));

if (hasHandshakeCompletionIndicators) {
logger.debug('🔄 Satellite handshake completion detected, re-authenticating for fresh auth state');

const beforeReAuth = {
status: requestState.status,
reason: requestState.reason,
isSignedIn: requestState.isSignedIn,
token: requestState.token ? '[PRESENT]' : '[MISSING]',
};

logger.debug('📊 Auth state before re-authentication', () => beforeReAuth);

// Re-authenticate to get the updated auth state after handshake completion
requestState = await resolvedClerkClient.authenticateRequest(
clerkRequest,
createAuthenticateRequestOptions(clerkRequest, options),
);

const afterReAuth = {
status: requestState.status,
reason: requestState.reason,
isSignedIn: requestState.isSignedIn,
token: requestState.token ? '[PRESENT]' : '[MISSING]',
};

logger.debug('✅ Auth state after re-authentication', () => afterReAuth);
logger.debug('🔄 Re-auth comparison', () => ({
statusChanged: beforeReAuth.status !== afterReAuth.status,
signedInChanged: beforeReAuth.isSignedIn !== afterReAuth.isSignedIn,
tokenChanged: beforeReAuth.token !== afterReAuth.token,
from: `${beforeReAuth.status}/${beforeReAuth.isSignedIn}`,
to: `${afterReAuth.status}/${afterReAuth.isSignedIn}`,
}));
}

const authObject = requestState.toAuth();
logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() }));

const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest);
const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest);
if (!authObject) {
throw new Error('Clerk: Auth object is null after request state resolution');
}

logger.debug('🔐 Final auth object details', () => ({
userId: authObject.userId || '[NONE]',
sessionId: authObject.sessionId || '[NONE]',
sessionStatus: authObject.sessionStatus || '[NONE]',
status: requestState.status,
isSignedIn: requestState.isSignedIn,
isSatellite: requestState.isSatellite,
hasToken: !!requestState.token,
debug: authObject.debug(),
}));

// Enhanced satellite domain handling: Check for unauthenticated requests on satellite domains
// and ensure they can properly redirect back after authentication
const isSatellite = requestState.isSatellite;
const isUnauthenticated = !authObject.userId;

// For satellite domains with unauthenticated users, we want to ensure proper redirect handling
// This fixes the issue where public routes on satellite domains don't redirect back after auth
if (isSatellite && isUnauthenticated) {
logger.debug('Satellite domain unauthenticated request detected');

// If this is a satellite domain and the user is not authenticated,
// we want to ensure any authentication attempts preserve the return URL
const currentUrl = clerkRequest.clerkUrl.toString();

// Only apply enhanced redirect logic if we're not already on an auth page
if (!currentUrl.includes('/sign-in') && !currentUrl.includes('/sign-up')) {
logger.debug('Enhancing redirect functions for satellite domain', { currentUrl });
}
}

const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest, requestState);
const redirectToSignUp = createMiddlewareRedirectToSignUp(clerkRequest, requestState);
Comment on lines +319 to +320
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify function signature changes across the codebase.

The function signatures for createMiddlewareRedirectToSignIn and createMiddlewareRedirectToSignUp have been updated to accept a requestState parameter. Ensure all callers of these functions have been updated accordingly.


🏁 Script executed:

#!/bin/bash
# Description: Search for any remaining calls to these functions that might not have been updated with the new signature.

# Search for function calls that might still use the old signature
rg -A 3 "createMiddlewareRedirectToSignIn|createMiddlewareRedirectToSignUp" --type ts --type tsx

Length of output: 126


🏁 Script executed:

#!/bin/bash
# Search for all calls to the redirect functions in .ts and .tsx files
rg -n "createMiddlewareRedirectToSignIn|createMiddlewareRedirectToSignUp" -g '*.ts' -g '*.tsx'

Length of output: 787


Update Astro middleware to use the new redirect signatures

The Astro middleware still invokes createMiddlewareRedirectToSignIn with only the old single‐argument signature. It must be updated to pass the new requestState parameter (and likewise for createMiddlewareRedirectToSignUp if used).

• packages/astro/src/server/clerk-middleware.ts:

  • Line 99: change
    const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest);
    to
    const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest, requestState);
  • (If present) update any createMiddlewareRedirectToSignUp(clerkRequest) calls in this file to also include requestState.
🤖 Prompt for AI Agents
In packages/astro/src/server/clerk-middleware.ts around line 99, update the call
to createMiddlewareRedirectToSignIn to include the second argument requestState,
changing it from createMiddlewareRedirectToSignIn(clerkRequest) to
createMiddlewareRedirectToSignIn(clerkRequest, requestState). Also, if there are
any calls to createMiddlewareRedirectToSignUp with only clerkRequest as an
argument, update those to include requestState as the second argument as well.

const protect = await createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn);

const authHandler = createMiddlewareAuthHandler(authObject, redirectToSignIn, redirectToSignUp);
Expand Down Expand Up @@ -267,6 +381,16 @@ export const clerkMiddleware = ((...args: unknown[]): NextMiddleware | NextMiddl

decorateRequest(clerkRequest, handlerResult, requestState, resolvedParams, keylessKeysForRequestData);

// Final middleware summary
logger.debug('🏁 Middleware complete', () => ({
finalAuthStatus: requestState.status,
finalIsSignedIn: requestState.isSignedIn,
userId: authObject.userId || '[NONE]',
responseStatus: handlerResult.status,
wasReAuthTriggered: hasHandshakeCompletionIndicators,
isSatelliteRequest: requestState.isSatellite,
}));

return handlerResult;
});

Expand Down Expand Up @@ -363,19 +487,41 @@ export const createAuthenticateRequestOptions = (

const createMiddlewareRedirectToSignIn = (
clerkRequest: ClerkRequest,
requestState: RequestState,
): ClerkMiddlewareSessionAuthObject['redirectToSignIn'] => {
return (opts = {}) => {
const url = clerkRequest.clerkUrl.toString();
redirectToSignInError(url, opts.returnBackUrl);
const returnBackUrl = opts.returnBackUrl === null ? null : (opts.returnBackUrl || url).toString();

// For satellite domains, use the satellite domain sync flow instead of standard redirects
// This ensures proper cross-domain redirect handling similar to automatic satellite sync
if (requestState.isSatellite && requestState.signInUrl && returnBackUrl !== null) {
const redirectURL = new URL(requestState.signInUrl);
redirectURL.searchParams.set('redirect_url', returnBackUrl);
redirectToSignInError(redirectURL.toString(), returnBackUrl);
} else {
redirectToSignInError(url, returnBackUrl);
}
};
};

const createMiddlewareRedirectToSignUp = (
clerkRequest: ClerkRequest,
requestState: RequestState,
): ClerkMiddlewareSessionAuthObject['redirectToSignUp'] => {
return (opts = {}) => {
const url = clerkRequest.clerkUrl.toString();
redirectToSignUpError(url, opts.returnBackUrl);
const returnBackUrl = opts.returnBackUrl === null ? null : (opts.returnBackUrl || url).toString();

// For satellite domains, use the satellite domain sync flow instead of standard redirects
// This ensures proper cross-domain redirect handling similar to automatic satellite sync
if (requestState.isSatellite && requestState.signUpUrl && returnBackUrl !== null) {
const redirectURL = new URL(requestState.signUpUrl);
redirectURL.searchParams.set('redirect_url', returnBackUrl);
redirectToSignUpError(redirectURL.toString(), returnBackUrl);
} else {
redirectToSignUpError(url, returnBackUrl);
}
};
};
Comment on lines 488 to 526
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Satellite domain redirect logic enhances cross-domain authentication flow.

The updated redirect functions correctly implement satellite domain sync flow by adding a redirect_url query parameter when redirecting from satellite domains. This ensures users return to their intended destination after authentication.

However, consider adding validation for the redirect_url parameter to prevent potential open redirect vulnerabilities.


🌐 Web query:

What are the security best practices for validating redirect URLs to prevent open redirect attacks in authentication flows?

💡 Result:

To prevent open redirect attacks in authentication flows, validating redirect URLs properly is crucial. Here are the best security practices for validating these URLs:

  1. Use a Whitelist of Allowed URLs or Domains
    Maintain a strict list of allowed redirect URLs or base domains. Only redirect to URLs that exactly match or reside within these trusted domains.

  2. Avoid Open Redirect Parameters Without Validation
    Never directly use user-supplied URLs for redirection without validation. Validate that the URL parameter strictly conforms to an allowed list or set of rules.

  3. Canonicalize and Normalize URLs
    Before validation, canonicalize URLs to resolve relative paths, remove encoding tricks (like escaped characters), and normalize the URL to prevent bypass using variations.

  4. Check URL Scheme and Host
    Ensure the URLs use safe schemes (e.g., only https or your valid schemes). Reject or sanitize URLs with suspicious schemes such as javascript:, data:, or file:.

  5. Use Relative Paths Instead of Full URLs Whenever Possible
    Prefer using relative paths within your domain for redirects. This avoids cross-domain redirection risks.

  6. Limit Redirects to Internal Paths
    Redirect only to internal paths or safe subdomains owned by your service. For external redirects, consider additional verification mechanisms or user approval.

  7. Use URL Parsing Libraries
    Use well-tested URL parsing libraries rather than regex or string operations to analyze URLs.

  8. Log and Monitor Redirects
    Log every redirect request and monitor for abnormal or suspicious redirect attempts.

  9. Fail Securely
    When validation fails, redirect users to a safe default location (such as your homepage or error page) instead of echoing back the original input.

  10. Implement Security Headers
    Use security headers like Content-Security-Policy and X-Content-Type-Options to further reduce risks from malicious redirects.

By implementing these best practices, you can significantly reduce the risk of open redirect vulnerabilities in your authentication flows.


Add strict validation for redirect_url to prevent open redirects

The current satellite-domain logic appends the user-supplied returnBackUrl directly into the redirect_url query parameter, which could be exploited for open-redirect attacks. Introduce URL validation before using it in both redirect functions.

• File: packages/nextjs/src/server/clerkMiddleware.ts (createMiddlewareRedirectToSignIn & createMiddlewareRedirectToSignUp, lines 384–422)

  • Canonicalize and parse returnBackUrl with a URL-parsing library.
  • Enforce allowed schemes (e.g., only https:).
  • Check host against a whitelist of trusted domains or restrict to internal paths.
  • Fall back to a safe default URL (e.g., home or sign-in page) if validation fails.

Implement these steps to ensure only legitimate URLs are used for redirection.

🤖 Prompt for AI Agents
In packages/nextjs/src/server/clerkMiddleware.ts between lines 384 and 422, the
redirect functions createMiddlewareRedirectToSignIn and
createMiddlewareRedirectToSignUp currently use the user-supplied returnBackUrl
directly in the redirect_url query parameter, risking open redirect
vulnerabilities. To fix this, parse and canonicalize returnBackUrl using a URL
parser, validate that its scheme is https, and verify its host against a
whitelist of trusted domains or restrict it to internal paths. If validation
fails, replace returnBackUrl with a safe default URL such as the home or sign-in
page URL before appending it to the redirect URL. This ensures only legitimate
URLs are used for redirection and prevents open redirect attacks.


Expand Down
Loading