Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions app/(auth)/sign-in/[[...sign-in]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<main className="container grid min-h-screen gap-8 py-16 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<section className="space-y-4">
<p className="text-sm font-medium text-muted-foreground">Existing users</p>
<h1 className="text-3xl font-semibold tracking-tight">Sign in to your CallbackCloser workspace</h1>
<p className="text-muted-foreground">
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.
</p>
</section>
<div className="flex justify-center lg:justify-end">
<div className="w-full max-w-md rounded-2xl border bg-card p-6 shadow-sm">
<p className="font-medium">Authentication is temporarily unavailable.</p>
<p className="mt-2 text-sm text-muted-foreground">
CallbackCloser sign-in is unavailable until Clerk production configuration is restored. Please try again shortly or contact support.
</p>
</div>
</div>
</main>
);
}

const { userId } = await auth();
if (userId) {
const [adminSession, business] = await Promise.all([
Expand All @@ -23,8 +50,6 @@ export default async function SignInPage() {
);
}

const { signInUrl, signUpUrl } = getClerkAuthUrls();

return (
<main className="container grid min-h-screen gap-8 py-16 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<section className="space-y-4">
Expand All @@ -35,7 +60,12 @@ export default async function SignInPage() {
</p>
</section>
<div className="flex justify-center lg:justify-end">
<SignIn path={signInUrl} routing="path" signUpUrl={signUpUrl} fallbackRedirectUrl={DEFAULT_CLERK_AFTER_AUTH_URL} />
<SignIn
path={DEFAULT_CLERK_SIGN_IN_URL}
routing="path"
signUpUrl={DEFAULT_CLERK_SIGN_UP_URL}
fallbackRedirectUrl={DEFAULT_CLERK_AFTER_AUTH_URL}
/>
</div>
</main>
);
Expand Down
41 changes: 38 additions & 3 deletions app/(auth)/sign-up/[[...sign-up]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -30,6 +35,32 @@ export default async function SignUpPage({
}: {
searchParams?: Record<string, string | string[] | undefined>;
}) {
if (!hasRequiredValidClerkEnv()) {
const intent = typeof searchParams?.intent === 'string' ? searchParams.intent : undefined;
const copy = getIntentCopy(intent);

return (
<main className="container grid min-h-screen gap-8 py-16 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<section className="space-y-4">
<p className="text-sm font-medium text-muted-foreground">{copy.label}</p>
<h1 className="text-3xl font-semibold tracking-tight">{copy.title}</h1>
<p className="text-muted-foreground">{copy.detail}</p>
<p className="text-sm text-muted-foreground">
Existing users should sign in. CallbackCloser operators setting up a customer pilot should use the admin new-business flow, not public signup.
</p>
</section>
<div className="flex justify-center lg:justify-end">
<div className="w-full max-w-md rounded-2xl border bg-card p-6 shadow-sm">
<p className="font-medium">Authentication is temporarily unavailable.</p>
<p className="mt-2 text-sm text-muted-foreground">
CallbackCloser sign-up is unavailable until Clerk production configuration is restored. Please try again shortly or contact support.
</p>
</div>
</div>
</main>
);
}

const { userId } = await auth();
if (userId) {
const [adminSession, business] = await Promise.all([
Expand 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);

Expand All @@ -60,7 +90,12 @@ export default async function SignUpPage({
</p>
</section>
<div className="flex justify-center lg:justify-end">
<SignUp path={signUpUrl} routing="path" signInUrl={signInUrl} fallbackRedirectUrl={DEFAULT_CLERK_AFTER_AUTH_URL} />
<SignUp
path={DEFAULT_CLERK_SIGN_UP_URL}
routing="path"
signInUrl={DEFAULT_CLERK_SIGN_IN_URL}
fallbackRedirectUrl={DEFAULT_CLERK_AFTER_AUTH_URL}
/>
</div>
</main>
);
Expand Down
30 changes: 28 additions & 2 deletions lib/clerk-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
9 changes: 9 additions & 0 deletions lib/middleware-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}/`);
}
Expand All @@ -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);
}
58 changes: 51 additions & 7 deletions lib/security-headers.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,68 @@
import { getClerkFrontendApiOrigin } from '@/lib/clerk-config';

type EnvMap = Readonly<Record<string, string | undefined>>;

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('; ');
}
Expand All @@ -33,7 +77,7 @@ export function getSecurityHeaders(env: EnvMap = process.env): Record<string, st

if (isProductionEnv(env)) {
headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload';
headers['Content-Security-Policy'] = buildContentSecurityPolicy();
headers['Content-Security-Policy'] = buildContentSecurityPolicy(env);
}

return headers;
Expand Down
9 changes: 7 additions & 2 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { clerkMiddleware } from '@clerk/nextjs/server';

import { hasRequiredValidClerkEnv } from '@/lib/clerk-config';
import {
routeCanRenderClerkFallback,
routeCanRenderWithoutClerk,
routeNeedsClerkContext,
routeNeedsProtectedMutationRateLimit,
Expand Down Expand Up @@ -102,7 +103,9 @@ export default async function middleware(req: NextRequest, event: NextFetchEvent
}

return withSecurityHeaders(
needsClerkContext ? buildAuthUnavailableResponse(req) : NextResponse.next()
!needsClerkContext || routeCanRenderClerkFallback(pathname)
? NextResponse.next()
: buildAuthUnavailableResponse(req)
);
}

Expand All @@ -115,7 +118,9 @@ export default async function middleware(req: NextRequest, event: NextFetchEvent
message: error instanceof Error ? error.message : 'unknown_error',
});
return withSecurityHeaders(
routeCanRenderWithoutClerk(pathname) ? NextResponse.next() : buildAuthUnavailableResponse(req)
routeCanRenderWithoutClerk(pathname) || routeCanRenderClerkFallback(pathname)
? NextResponse.next()
: buildAuthUnavailableResponse(req)
);
}
}
Expand Down
38 changes: 38 additions & 0 deletions tests/clerk-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
DEFAULT_CLERK_SIGN_IN_URL,
DEFAULT_CLERK_SIGN_UP_URL,
getClerkAuthUrls,
getClerkFrontendApiOrigin,
} from '../lib/clerk-config.ts';

test('getClerkAuthUrls normalizes env routes to stable base paths for Clerk path routing', () => {
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
);
});
5 changes: 4 additions & 1 deletion tests/middleware-auth-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import test from 'node:test';

import {
routeCanRenderClerkFallback,
routeCanRenderWithoutClerk,
routeNeedsClerkContext,
routeNeedsProtectedMutationRateLimit,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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\(\);/);
Expand Down
12 changes: 12 additions & 0 deletions tests/public-auth-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<SignIn/);
assert.match(signInPage, /Sign in to your CallbackCloser workspace/);
assert.match(signInPage, /path=\{DEFAULT_CLERK_SIGN_IN_URL\}/);
assert.match(signInPage, /signUpUrl=\{DEFAULT_CLERK_SIGN_UP_URL\}/);
assert.match(signInPage, /hasRequiredValidClerkEnv/);
assert.match(signInPage, /Authentication is temporarily unavailable\./);
assert.match(signInPage, /resolveSignedInAppDestination/);
assert.match(signUpPage, /routing="path"/);
assert.match(signUpPage, /fallbackRedirectUrl=\{DEFAULT_CLERK_AFTER_AUTH_URL\}/);
assert.match(signUpPage, /<SignUp/);
assert.match(signUpPage, /Create your account and start pilot onboarding/);
assert.match(signUpPage, /path=\{DEFAULT_CLERK_SIGN_UP_URL\}/);
assert.match(signUpPage, /signInUrl=\{DEFAULT_CLERK_SIGN_IN_URL\}/);
assert.match(signUpPage, /hasRequiredValidClerkEnv/);
assert.match(signUpPage, /Authentication is temporarily unavailable\./);
assert.match(signUpPage, /resolveSignedInAppDestination/);
assert.match(signUpPage, /Founder-operated customer pilot setup is separate/i);
assert.match(pilotEntryPage, /resolvePublicPilotDestination/);
Expand Down
Loading
Loading