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/); });