From 5d9f80ad5e32756e759bfefe80f1b6ac978405a6 Mon Sep 17 00:00:00 2001
From: DevCalebR
Date: Mon, 25 May 2026 19:08:22 -0400
Subject: [PATCH] Fix Clerk auth UI CSP and path handling
---
app/(auth)/sign-in/[[...sign-in]]/page.tsx | 38 ++++++++++++--
app/(auth)/sign-up/[[...sign-up]]/page.tsx | 41 +++++++++++++--
lib/clerk-config.ts | 30 ++++++++++-
lib/middleware-access.ts | 9 ++++
lib/security-headers.ts | 58 +++++++++++++++++++---
middleware.ts | 9 +++-
tests/clerk-config.test.ts | 38 ++++++++++++++
tests/middleware-auth-routing.test.ts | 5 +-
tests/public-auth-routing.test.ts | 12 +++++
tests/security-headers.test.ts | 11 ++--
10 files changed, 229 insertions(+), 22 deletions(-)
create mode 100644 tests/clerk-config.test.ts
diff --git a/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/app/(auth)/sign-in/[[...sign-in]]/page.tsx
index 24f988b..557e85a 100644
--- a/app/(auth)/sign-in/[[...sign-in]]/page.tsx
+++ b/app/(auth)/sign-in/[[...sign-in]]/page.tsx
@@ -4,10 +4,37 @@ import { redirect } from 'next/navigation';
import { getAdminSession } from '@/lib/admin';
import { getBusinessForOwnerClerkId } from '@/lib/business-access';
-import { DEFAULT_CLERK_AFTER_AUTH_URL, getClerkAuthUrls } from '@/lib/clerk-config';
+import {
+ DEFAULT_CLERK_AFTER_AUTH_URL,
+ DEFAULT_CLERK_SIGN_IN_URL,
+ DEFAULT_CLERK_SIGN_UP_URL,
+ hasRequiredValidClerkEnv,
+} from '@/lib/clerk-config';
import { resolveSignedInAppDestination } from '@/lib/public-auth-routing';
export default async function SignInPage() {
+ if (!hasRequiredValidClerkEnv()) {
+ return (
+
+
+ Existing users
+ Sign in to your CallbackCloser workspace
+
+ Sign in is for existing owners and operators. If you need a new business workspace, create an account or start a free pilot from the public site instead.
+
+
+
+
+
Authentication is temporarily unavailable.
+
+ CallbackCloser sign-in is unavailable until Clerk production configuration is restored. Please try again shortly or contact support.
+
+
+
+
+ );
+ }
+
const { userId } = await auth();
if (userId) {
const [adminSession, business] = await Promise.all([
@@ -23,8 +50,6 @@ export default async function SignInPage() {
);
}
- const { signInUrl, signUpUrl } = getClerkAuthUrls();
-
return (
@@ -35,7 +60,12 @@ export default async function SignInPage() {
-
+
);
diff --git a/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/app/(auth)/sign-up/[[...sign-up]]/page.tsx
index 08fd0af..05d6adb 100644
--- a/app/(auth)/sign-up/[[...sign-up]]/page.tsx
+++ b/app/(auth)/sign-up/[[...sign-up]]/page.tsx
@@ -4,7 +4,12 @@ import { redirect } from 'next/navigation';
import { getAdminSession } from '@/lib/admin';
import { getBusinessForOwnerClerkId } from '@/lib/business-access';
-import { DEFAULT_CLERK_AFTER_AUTH_URL, getClerkAuthUrls } from '@/lib/clerk-config';
+import {
+ DEFAULT_CLERK_AFTER_AUTH_URL,
+ DEFAULT_CLERK_SIGN_IN_URL,
+ DEFAULT_CLERK_SIGN_UP_URL,
+ hasRequiredValidClerkEnv,
+} from '@/lib/clerk-config';
import { resolveSignedInAppDestination } from '@/lib/public-auth-routing';
function getIntentCopy(intent: string | undefined) {
@@ -30,6 +35,32 @@ export default async function SignUpPage({
}: {
searchParams?: Record;
}) {
+ if (!hasRequiredValidClerkEnv()) {
+ const intent = typeof searchParams?.intent === 'string' ? searchParams.intent : undefined;
+ const copy = getIntentCopy(intent);
+
+ return (
+
+
+ {copy.label}
+ {copy.title}
+ {copy.detail}
+
+ Existing users should sign in. CallbackCloser operators setting up a customer pilot should use the admin new-business flow, not public signup.
+
+
+
+
+
Authentication is temporarily unavailable.
+
+ CallbackCloser sign-up is unavailable until Clerk production configuration is restored. Please try again shortly or contact support.
+
+
+
+
+ );
+ }
+
const { userId } = await auth();
if (userId) {
const [adminSession, business] = await Promise.all([
@@ -45,7 +76,6 @@ export default async function SignUpPage({
);
}
- const { signInUrl, signUpUrl } = getClerkAuthUrls();
const intent = typeof searchParams?.intent === 'string' ? searchParams.intent : undefined;
const copy = getIntentCopy(intent);
@@ -60,7 +90,12 @@ export default async function SignUpPage({
-
+
);
diff --git a/lib/clerk-config.ts b/lib/clerk-config.ts
index bb09e8a..31905cb 100644
--- a/lib/clerk-config.ts
+++ b/lib/clerk-config.ts
@@ -18,11 +18,13 @@ export function isLikelyValidClerkSecretKey(value: string) {
function normalizeClerkRoute(rawValue: string | undefined, fallbackPath: string) {
const value = rawValue?.trim();
if (!value) return fallbackPath;
- if (value.startsWith('/') && !value.startsWith('//')) return value;
+ if (value.startsWith('/') && !value.startsWith('//')) {
+ return value.split('?')[0]?.split('#')[0] || fallbackPath;
+ }
try {
const parsed = new URL(value);
- const normalized = `${parsed.pathname}${parsed.search}` || fallbackPath;
+ const normalized = parsed.pathname || fallbackPath;
return normalized.startsWith('/') ? normalized : fallbackPath;
} catch {
return fallbackPath;
@@ -74,3 +76,27 @@ export function validateOptionalClerkRouteEnv(name: string, env: EnvMap = proces
return `${name} must be a relative path like /sign-in or a valid absolute URL`;
}
}
+
+function decodeBase64Url(value: string) {
+ const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
+ const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4));
+ return Buffer.from(`${normalized}${padding}`, 'base64').toString('utf8');
+}
+
+export function getClerkFrontendApiOrigin(env: EnvMap = process.env) {
+ const publishableKey = env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY?.trim() ?? '';
+ const match = publishableKey.match(/^pk_(?:test|live)_(.+)$/);
+ if (!match) return null;
+
+ try {
+ const decoded = decodeBase64Url(match[1]).replace(/\$$/, '').trim();
+ if (!decoded) return null;
+
+ const withProtocol = decoded.startsWith('http://') || decoded.startsWith('https://')
+ ? decoded
+ : `https://${decoded}`;
+ return new URL(withProtocol).origin;
+ } catch {
+ return null;
+ }
+}
diff --git a/lib/middleware-access.ts b/lib/middleware-access.ts
index b1a17a5..7d02187 100644
--- a/lib/middleware-access.ts
+++ b/lib/middleware-access.ts
@@ -19,6 +19,11 @@ const CLERK_CONTEXT_PUBLIC_ROUTE_PREFIXES = [
'/buy',
] as const;
+const CLERK_FALLBACK_RENDER_ROUTE_PREFIXES = [
+ '/sign-in',
+ '/sign-up',
+] as const;
+
function matchesRoutePrefix(pathname: string, prefix: string) {
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
@@ -42,3 +47,7 @@ export function routeNeedsClerkContext(pathname: string) {
export function routeCanRenderWithoutClerk(pathname: string) {
return !routeNeedsClerkContext(pathname);
}
+
+export function routeCanRenderClerkFallback(pathname: string) {
+ return matchesAnyPrefix(pathname, CLERK_FALLBACK_RENDER_ROUTE_PREFIXES);
+}
diff --git a/lib/security-headers.ts b/lib/security-headers.ts
index 078026a..01323bf 100644
--- a/lib/security-headers.ts
+++ b/lib/security-headers.ts
@@ -1,24 +1,68 @@
+import { getClerkFrontendApiOrigin } from '@/lib/clerk-config';
+
type EnvMap = Readonly>;
function isProductionEnv(env: EnvMap) {
return env.NODE_ENV === 'production';
}
-function buildContentSecurityPolicy() {
+function buildDirective(name: string, values: string[]) {
+ return `${name} ${Array.from(new Set(values)).join(' ')}`;
+}
+
+function buildContentSecurityPolicy(env: EnvMap = process.env) {
+ const clerkFrontendApiOrigin = getClerkFrontendApiOrigin(env);
+ const clerkScriptOrigins = [
+ "'self'",
+ "'unsafe-inline'",
+ 'https://js.stripe.com',
+ 'https://*.clerk.com',
+ 'https://*.clerk.accounts.dev',
+ 'https://challenges.cloudflare.com',
+ ...(clerkFrontendApiOrigin ? [clerkFrontendApiOrigin] : []),
+ ];
+ const clerkConnectOrigins = [
+ "'self'",
+ 'https://api.stripe.com',
+ 'https://checkout.stripe.com',
+ 'https://billing.stripe.com',
+ 'https://*.clerk.com',
+ 'https://*.clerk.accounts.dev',
+ ...(clerkFrontendApiOrigin ? [clerkFrontendApiOrigin] : []),
+ ];
+ const clerkFrameOrigins = [
+ "'self'",
+ 'https://js.stripe.com',
+ 'https://hooks.stripe.com',
+ 'https://*.clerk.com',
+ 'https://*.clerk.accounts.dev',
+ 'https://challenges.cloudflare.com',
+ ...(clerkFrontendApiOrigin ? [clerkFrontendApiOrigin] : []),
+ ];
+ const clerkFormOrigins = [
+ "'self'",
+ 'https://*.clerk.com',
+ 'https://*.clerk.accounts.dev',
+ 'https://checkout.stripe.com',
+ 'https://billing.stripe.com',
+ ...(clerkFrontendApiOrigin ? [clerkFrontendApiOrigin] : []),
+ ];
+
return [
"default-src 'self'",
"base-uri 'self'",
"frame-ancestors 'none'",
"object-src 'none'",
- "form-action 'self' https://*.clerk.com https://*.clerk.accounts.dev https://checkout.stripe.com https://billing.stripe.com",
- "script-src 'self' 'unsafe-inline' https://js.stripe.com https://*.clerk.com https://*.clerk.accounts.dev",
+ buildDirective('form-action', clerkFormOrigins),
+ buildDirective('script-src', clerkScriptOrigins),
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
- "img-src 'self' data: blob: https:",
+ "img-src 'self' data: blob: https: https://img.clerk.com",
"font-src 'self' data: https://fonts.gstatic.com",
- "connect-src 'self' https://api.stripe.com https://checkout.stripe.com https://billing.stripe.com https://*.clerk.com https://*.clerk.accounts.dev",
- "frame-src 'self' https://js.stripe.com https://hooks.stripe.com https://*.clerk.com https://*.clerk.accounts.dev",
+ buildDirective('connect-src', clerkConnectOrigins),
+ buildDirective('frame-src', clerkFrameOrigins),
"media-src 'self' blob:",
"manifest-src 'self'",
+ "worker-src 'self' blob:",
'upgrade-insecure-requests',
].join('; ');
}
@@ -33,7 +77,7 @@ export function getSecurityHeaders(env: EnvMap = process.env): Record {
+ const urls = getClerkAuthUrls({
+ NEXT_PUBLIC_CLERK_SIGN_IN_URL: 'https://callbackcloser.com/sign-in?redirect_url=%2Fapp',
+ NEXT_PUBLIC_CLERK_SIGN_UP_URL: '/sign-up?intent=pilot',
+ });
+
+ assert.deepEqual(urls, {
+ signInUrl: DEFAULT_CLERK_SIGN_IN_URL,
+ signUpUrl: DEFAULT_CLERK_SIGN_UP_URL,
+ });
+});
+
+test('getClerkFrontendApiOrigin decodes the frontend API host from the publishable key', () => {
+ const origin = getClerkFrontendApiOrigin({
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: 'pk_live_Y2xlcmsuY2FsbGJhY2tjbG9zZXIuY29tJA',
+ });
+
+ assert.equal(origin, 'https://clerk.callbackcloser.com');
+});
+
+test('getClerkFrontendApiOrigin returns null for invalid publishable keys', () => {
+ assert.equal(
+ getClerkFrontendApiOrigin({
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: 'not-a-real-key',
+ }),
+ null
+ );
+});
diff --git a/tests/middleware-auth-routing.test.ts b/tests/middleware-auth-routing.test.ts
index 3384da8..5cf0322 100644
--- a/tests/middleware-auth-routing.test.ts
+++ b/tests/middleware-auth-routing.test.ts
@@ -4,6 +4,7 @@ import path from 'node:path';
import test from 'node:test';
import {
+ routeCanRenderClerkFallback,
routeCanRenderWithoutClerk,
routeNeedsClerkContext,
routeNeedsProtectedMutationRateLimit,
@@ -31,6 +32,8 @@ test('marketing pages remain public when Clerk is unavailable', () => {
assert.equal(routeCanRenderWithoutClerk('/pricing'), true);
assert.equal(routeCanRenderWithoutClerk('/demo'), true);
assert.equal(routeCanRenderWithoutClerk('/contact'), true);
+ assert.equal(routeCanRenderClerkFallback('/sign-up'), true);
+ assert.equal(routeCanRenderClerkFallback('/sign-in'), true);
});
test('protected owner and admin routes still require auth', () => {
@@ -60,7 +63,7 @@ test('middleware uses one clerkMiddleware path and only protects the protected s
assert.match(middleware, /if \(routeNeedsProtection\(pathname\)\) {\s*await auth\.protect\(\);/s);
assert.match(middleware, /if \(req\.method === 'POST' && routeNeedsProtectedMutationRateLimit\(pathname\)\)/);
assert.match(middleware, /const needsClerkContext = routeNeedsClerkContext\(pathname\)/);
- assert.match(middleware, /needsClerkContext \? buildAuthUnavailableResponse\(req\) : NextResponse\.next\(\)/);
+ assert.match(middleware, /routeCanRenderClerkFallback\(pathname\)/);
assert.doesNotMatch(middleware, /if \(!isProtectedRoute\(req\)\)/);
assert.doesNotMatch(middleware, /const protectedMiddleware = clerkMiddleware/);
assert.match(signInPage, /const \{ userId \} = await auth\(\);/);
diff --git a/tests/public-auth-routing.test.ts b/tests/public-auth-routing.test.ts
index 1f49926..f62bf8c 100644
--- a/tests/public-auth-routing.test.ts
+++ b/tests/public-auth-routing.test.ts
@@ -45,9 +45,21 @@ test('clerk auth surfaces use explicit path routing and fallback redirects', ()
assert.match(layout, /signUpFallbackRedirectUrl=\{DEFAULT_CLERK_AFTER_AUTH_URL\}/);
assert.match(signInPage, /routing="path"/);
assert.match(signInPage, /fallbackRedirectUrl=\{DEFAULT_CLERK_AFTER_AUTH_URL\}/);
+ assert.match(signInPage, / {
});
test('getSecurityHeaders includes HSTS in production', () => {
- const headers = getSecurityHeaders({ NODE_ENV: 'production' });
+ const headers = getSecurityHeaders({
+ NODE_ENV: 'production',
+ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: 'pk_live_Y2xlcmsuY2FsbGJhY2tjbG9zZXIuY29tJA',
+ });
assert.equal(headers['Strict-Transport-Security'], 'max-age=31536000; includeSubDomains; preload');
assert.match(headers['Content-Security-Policy'] || '', /default-src 'self'/);
- assert.match(headers['Content-Security-Policy'] || '', /form-action 'self' https:\/\/\*\.clerk\.com https:\/\/\*\.clerk\.accounts\.dev https:\/\/checkout\.stripe\.com https:\/\/billing\.stripe\.com/);
- assert.match(headers['Content-Security-Policy'] || '', /script-src 'self' 'unsafe-inline' https:\/\/js\.stripe\.com https:\/\/\*\.clerk\.com https:\/\/\*\.clerk\.accounts\.dev/);
+ assert.match(headers['Content-Security-Policy'] || '', /https:\/\/clerk\.callbackcloser\.com/);
+ assert.match(headers['Content-Security-Policy'] || '', /https:\/\/challenges\.cloudflare\.com/);
+ assert.match(headers['Content-Security-Policy'] || '', /img-src 'self' data: blob: https: https:\/\/img\.clerk\.com/);
+ assert.match(headers['Content-Security-Policy'] || '', /worker-src 'self' blob:/);
assert.match(headers['Content-Security-Policy'] || '', /frame-ancestors 'none'/);
assert.match(headers['Content-Security-Policy'] || '', /upgrade-insecure-requests/);
});