diff --git a/app/(auth)/login/login-form.tsx b/app/(auth)/login/login-form.tsx index 2237429..cc820e6 100644 --- a/app/(auth)/login/login-form.tsx +++ b/app/(auth)/login/login-form.tsx @@ -9,17 +9,25 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { signIn } from 'next-auth/react'; +import { + getSafeCallbackUrl, + isInvitationCallbackUrl, + isSafeRelativePath, +} from '@/lib/safe-redirect'; -function getSafeCallbackUrl(value: string | null): string { - if (!value) return '/dashboard'; - try { - const baseOrigin = typeof window === 'undefined' ? 'http://localhost' : window.location.origin; - const parsed = new URL(value, baseOrigin); - if (parsed.origin !== baseOrigin) return '/dashboard'; - return `${parsed.pathname}${parsed.search}${parsed.hash}`; - } catch { - return '/dashboard'; +/** + * Sign-up link that carries the pending destination — and, when that destination is an + * invitation, the invitation token itself so the new account is bound to the invite. + */ +function buildRegisterHref(callbackUrl: string): string { + if (callbackUrl === '/dashboard') return '/register'; + + const params = new URLSearchParams({ callbackUrl }); + if (isInvitationCallbackUrl(callbackUrl)) { + const token = new URLSearchParams(callbackUrl.split('?')[1] ?? '').get('token'); + if (token) params.set('invitationToken', token); } + return `/register?${params.toString()}`; } const ERROR_MESSAGES: Record = { @@ -50,6 +58,8 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) { const [showSuccess, setShowSuccess] = useState(false); const [showVerifiedSuccess, setShowVerifiedSuccess] = useState(false); const callbackUrl = getSafeCallbackUrl(searchParams.get('callbackUrl')); + const isInvitationFlow = isInvitationCallbackUrl(callbackUrl); + const registerHref = buildRegisterHref(callbackUrl); useEffect(() => { if (searchParams.get('registered') === 'true') { @@ -82,8 +92,10 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) { return; } + // `result.url` is whatever next-auth resolved, so it is sanitized again here — and + // re-checked at the sink, because `router.push` happily leaves the origin. const destination = getSafeCallbackUrl(result?.url || callbackUrl); - router.push(destination); + router.push(isSafeRelativePath(destination) ? destination : '/dashboard'); router.refresh(); } catch { setError('Something went wrong. Please try again.'); @@ -105,7 +117,11 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) { Welcome back - Sign in to your account to continue + + {isInvitationFlow + ? 'Sign in to accept your invitation' + : 'Sign in to your account to continue'} + {showSuccess && ( @@ -242,7 +258,7 @@ function LoginFormInner({ googleEnabled, githubEnabled }: LoginFormInnerProps) {

Don't have an account?{' '} - + Sign up

diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 6e310c0..43a012a 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,15 +1,40 @@ import { isInviteCodeRequired } from '@/lib/feature-flags'; +import { getInvitationPreviewByToken } from '@/lib/invitations'; +import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit'; import RegisterPageClient from './register-page-client'; -export default function RegisterPage() { +interface RegisterPageProps { + searchParams: Promise<{ invitationToken?: string }>; +} + +export default async function RegisterPage({ searchParams }: RegisterPageProps) { const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); + const token = (await searchParams)?.invitationToken?.trim(); + + // Unauthenticated invitation lookup — throttled per IP (and per token) before it can + // touch the database. When throttled we skip the lookup instead of guessing at a verdict. + const previewAllowed = token ? await isInvitationPreviewAllowed(token) : false; + const preview = token && previewAllowed ? await getInvitationPreviewByToken(token) : null; + const invitation = + preview && preview.status === 'PENDING' && !preview.isExpired + ? { + email: preview.email, + inviterName: preview.inviterName, + roleLabel: preview.roleLabel, + scopeLabel: preview.scopeLabel, + targetName: preview.targetName, + } + : null; + return ( ); } diff --git a/app/(auth)/register/register-page-client.tsx b/app/(auth)/register/register-page-client.tsx index 6f12632..7c9a2f4 100644 --- a/app/(auth)/register/register-page-client.tsx +++ b/app/(auth)/register/register-page-client.tsx @@ -9,24 +9,52 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { getSafeCallbackUrl } from '@/lib/safe-redirect'; + +export interface RegisterInvitation { + email: string; + inviterName: string; + roleLabel: string; + scopeLabel: string; + targetName: string | null; +} interface RegisterPageClientProps { requireInviteCode: boolean; googleEnabled: boolean; githubEnabled: boolean; + invitation?: RegisterInvitation | null; + /** Preview lookup was rate-limited, so `invitation` says nothing about its validity. */ + invitationLookupThrottled?: boolean; } export default function RegisterPageClient({ requireInviteCode, googleEnabled, githubEnabled, + invitation = null, + invitationLookupThrottled = false, }: RegisterPageClientProps) { const router = useRouter(); const searchParams = useSearchParams(); const invitationToken = useMemo(() => searchParams.get('invitationToken') || '', [searchParams]); - const invitedEmail = useMemo(() => searchParams.get('email') || '', [searchParams]); + const invitedEmail = useMemo( + () => invitation?.email || searchParams.get('email') || '', + [invitation, searchParams] + ); + // Where to send the user once they are signed in — for invitations this points back + // at /invitations/accept so they land on the workspace/project they were invited to + // instead of the onboarding wizard. + const callbackUrl = useMemo( + () => getSafeCallbackUrl(searchParams.get('callbackUrl')), + [searchParams] + ); const isInvitationFlow = invitationToken.length > 0; const shouldShowInviteCode = requireInviteCode && !isInvitationFlow; + const loginHref = + callbackUrl === '/dashboard' + ? '/login' + : `/login?callbackUrl=${encodeURIComponent(callbackUrl)}`; const [isLoading, setIsLoading] = useState(false); const [oauthLoading, setOauthLoading] = useState(null); const [error, setError] = useState(''); @@ -91,10 +119,11 @@ export default function RegisterPageClient({ return; } + const callbackParam = `&callbackUrl=${encodeURIComponent(callbackUrl)}`; if (data.data?.emailVerificationRequired) { - router.push(`/verify-email?email=${encodeURIComponent(formData.email)}`); + router.push(`/verify-email?email=${encodeURIComponent(formData.email)}${callbackParam}`); } else { - router.push('/login?registered=true'); + router.push(`/login?registered=true${callbackParam}`); } } catch { setError('Something went wrong. Please try again.'); @@ -106,7 +135,7 @@ export default function RegisterPageClient({ const handleOAuthSignUp = async (provider: string) => { setOauthLoading(provider); setError(''); - await signIn(provider, { callbackUrl: '/dashboard' }); + await signIn(provider, { callbackUrl }); }; const hasOAuth = googleEnabled || githubEnabled; @@ -204,9 +233,29 @@ export default function RegisterPageClient({ )}
- {isInvitationFlow ? ( -
- You are registering via an invitation link. + {isInvitationFlow && invitation ? ( +
+

+ {invitation.inviterName} invited you to{' '} + + {invitation.targetName + ? `${invitation.targetName} (${invitation.scopeLabel})` + : `a ${invitation.scopeLabel}`} + {' '} + as {invitation.roleLabel}. +

+

+ Create your account below — you'll be taken straight to it. +

+
+ ) : isInvitationFlow && invitationLookupThrottled ? ( +
+ We couldn't check this invitation right now. Please wait a few minutes and + open the link again. +
+ ) : isInvitationFlow ? ( +
+ This invitation link is no longer valid. Ask whoever invited you for a new one.
) : shouldShowInviteCode ? ( <> @@ -261,7 +310,14 @@ export default function RegisterPageClient({ onChange={handleChange} required disabled={isLoading} + readOnly={Boolean(invitation)} + className={invitation ? 'bg-muted text-muted-foreground' : undefined} /> + {invitation && ( +

+ The invitation is tied to this address. +

+ )}
@@ -307,7 +363,7 @@ export default function RegisterPageClient({

Already have an account?{' '} - + Sign in

diff --git a/app/(auth)/verify-email/page.tsx b/app/(auth)/verify-email/page.tsx index 0cd6cde..b55076c 100644 --- a/app/(auth)/verify-email/page.tsx +++ b/app/(auth)/verify-email/page.tsx @@ -8,10 +8,16 @@ import { Video, Mail, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { getSafeCallbackUrl } from '@/lib/safe-redirect'; function VerifyEmailContent() { const searchParams = useSearchParams(); const emailParam = searchParams.get('email') || ''; + const callbackUrl = getSafeCallbackUrl(searchParams.get('callbackUrl')); + const loginHref = + callbackUrl === '/dashboard' + ? '/login' + : `/login?callbackUrl=${encodeURIComponent(callbackUrl)}`; const [resendEmail, setResendEmail] = useState(emailParam); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); @@ -107,7 +113,7 @@ function VerifyEmailContent() {

Already verified?{' '} - + Sign in

diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index ee6d1ae..7c28ba9 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -135,7 +135,13 @@ export async function POST(request: NextRequest) { // Send verification email if SMTP is configured if (emailVerificationRequired) { const verificationToken = await createVerificationToken(normalizedEmail); - await sendVerificationEmail(normalizedEmail, verificationToken); + // Invited users are sent back to the invitation after verifying, which forwards them + // to the workspace/project they joined instead of the generic dashboard. + await sendVerificationEmail(normalizedEmail, verificationToken, { + next: validatedInvitationToken + ? `/invitations/accept?token=${encodeURIComponent(validatedInvitationToken)}` + : undefined, + }); } const message = emailVerificationRequired diff --git a/app/api/auth/verify-email/route.ts b/app/api/auth/verify-email/route.ts index 4838fe6..40c9154 100644 --- a/app/api/auth/verify-email/route.ts +++ b/app/api/auth/verify-email/route.ts @@ -3,6 +3,7 @@ import { consumeVerificationToken } from '@/lib/email-verification'; import { rateLimit } from '@/lib/rate-limit'; import { logError } from '@/lib/logger'; import { getPublicOrigin } from '@/lib/request-origin'; +import { getSafeCallbackUrl } from '@/lib/safe-redirect'; // A raw 32-byte hex token is exactly 64 characters. const TOKEN_REGEX = /^[0-9a-f]{64}$/; @@ -31,7 +32,15 @@ export async function GET(request: NextRequest) { return redirectTo('/login?error=InvalidVerificationToken'); } - return redirectTo('/login?verified=true'); + // Keep the post-verification destination (e.g. an invitation) if one was carried along. + const next = getSafeCallbackUrl(request.nextUrl.searchParams.get('next'), { + origin, + fallback: '', + }); + + return redirectTo( + next ? `/login?verified=true&callbackUrl=${encodeURIComponent(next)}` : '/login?verified=true' + ); } catch (err) { logError('Email verification error:', err); return redirectTo('/login?error=VerificationFailed'); diff --git a/app/invitations/accept/invitation-landing.tsx b/app/invitations/accept/invitation-landing.tsx new file mode 100644 index 0000000..14810e3 --- /dev/null +++ b/app/invitations/accept/invitation-landing.tsx @@ -0,0 +1,200 @@ +import Link from 'next/link'; +import { Video, UserPlus, LogIn, MailWarning } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import type { InvitationPreview } from '@/lib/invitations'; + +interface InvitationLandingProps { + token: string; + preview: InvitationPreview | null; +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+
+ ); +} + +function UnusableInvitation({ title, message }: { title: string; message: string }) { + return ( + + + + + + {title} + + {message} + + + +

+ Ask whoever invited you to send a new invitation link. +

+
+
+
+ ); +} + +/** Too many unauthenticated invitation lookups from this client — nothing was queried. */ +export function InvitationRateLimited() { + return ( + + ); +} + +/** Signed in, but with an account whose address the invitation was not issued to. */ +export function InvitationAccountMismatch({ + invitedEmail, + signedInEmail, +}: { + invitedEmail: string; + signedInEmail: string; +}) { + return ( + + + + + + Wrong account + + + This invitation was sent to {invitedEmail}, but you are signed in as{' '} + {signedInEmail}. + + + + + +

+ After signing out, open the invitation link from your email again. +

+
+
+
+ ); +} + +export function InvitationLanding({ token, preview }: InvitationLandingProps) { + const acceptPath = `/invitations/accept?token=${encodeURIComponent(token)}`; + const loginHref = `/login?callbackUrl=${encodeURIComponent(acceptPath)}`; + + if (!preview) { + return ( + + ); + } + + if (preview.status === 'CANCELED') { + return ( + + ); + } + + if (preview.status === 'EXPIRED' || preview.isExpired) { + return ( + + ); + } + + const registerHref = + `/register?invitationToken=${encodeURIComponent(token)}` + + `&email=${encodeURIComponent(preview.email)}` + + `&callbackUrl=${encodeURIComponent(acceptPath)}`; + + const alreadyAccepted = preview.status === 'ACCEPTED'; + const targetLabel = preview.targetName + ? `${preview.targetName} (${preview.scopeLabel})` + : `a ${preview.scopeLabel}`; + + return ( + + + + You've been invited + + {preview.inviterName} invited you to join {targetLabel} on OpenFrame as{' '} + {preview.roleLabel}. + + + +
+

+ This invitation was sent to{' '} + {preview.email}.{' '} + {preview.hasAccount || alreadyAccepted ? 'Sign in with' : 'Use'} that address to + accept it. +

+
+ + {preview.hasAccount || alreadyAccepted ? ( + <> + + {!alreadyAccepted && ( +

+ Wrong address?{' '} + + Create an account instead + +

+ )} + + ) : ( + <> +

+ You don't have an OpenFrame account yet. Create one to open this{' '} + {preview.scopeLabel} — we'll bring you right back here once you're signed + in. +

+ +

+ Already have an account?{' '} + + Sign in + +

+ + )} +
+
+
+ ); +} diff --git a/app/invitations/accept/page.tsx b/app/invitations/accept/page.tsx index ad5c22a..7d97bdc 100644 --- a/app/invitations/accept/page.tsx +++ b/app/invitations/accept/page.tsx @@ -1,7 +1,13 @@ import { redirect } from 'next/navigation'; import { auth } from '@/lib/auth'; import { db } from '@/lib/db'; -import { acceptInvitationTokenForUser } from '@/lib/invitations'; +import { acceptInvitationTokenForUser, getInvitationPreviewByToken } from '@/lib/invitations'; +import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit'; +import { + InvitationAccountMismatch, + InvitationLanding, + InvitationRateLimited, +} from './invitation-landing'; interface InvitationAcceptPageProps { searchParams: Promise<{ @@ -19,14 +25,22 @@ export default async function InvitationAcceptPage({ searchParams }: InvitationA const session = await auth(); if (!session?.user?.id) { - const callbackUrl = `/invitations/accept?token=${encodeURIComponent(token)}`; - redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`); + // Signed-out visitors get the invitation itself instead of a bare login form: + // most of them have no account yet and need to be told to create one. This is the + // only unauthenticated read of invitation data, so it is IP-throttled. + if (!(await isInvitationPreviewAllowed(token))) { + return ; + } + + const preview = await getInvitationPreviewByToken(token); + return ; } const invitation = await db.invitation.findUnique({ where: { token }, select: { id: true, + email: true, status: true, scope: true, workspaceId: true, @@ -63,7 +77,14 @@ export default async function InvitationAcceptPage({ searchParams }: InvitationA redirect('/dashboard?invite=expired'); } if (result === 'forbidden') { - redirect('/dashboard?invite=wrong_account'); + // Signed in with a different address than the one invited — say so instead of + // dropping the user on the dashboard with no explanation. + return ( + + ); } if (result === 'not_found' && invitation?.status === 'ACCEPTED') { diff --git a/lib/email-verification.ts b/lib/email-verification.ts index f24b4d2..a36b8ee 100644 --- a/lib/email-verification.ts +++ b/lib/email-verification.ts @@ -94,7 +94,11 @@ function createTransport() { return nodemailer.createTransport({ host, port, secure: port === 465, auth: { user, pass } }); } -export async function sendVerificationEmail(email: string, token: string): Promise { +export async function sendVerificationEmail( + email: string, + token: string, + options?: { next?: string } +): Promise { const transporter = createTransport(); if (!transporter) return; @@ -109,7 +113,10 @@ export async function sendVerificationEmail(email: string, token: string): Promi return; } - const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`; + // `next` survives the round-trip so an invited user lands back on the invitation + // (and from there on the shared project) instead of a generic login page. + const nextParam = options?.next ? `&next=${encodeURIComponent(options.next)}` : ''; + const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}${nextParam}`; const from = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame '; const html = brandedEmailTemplate( diff --git a/lib/invitation-preview-limit.ts b/lib/invitation-preview-limit.ts new file mode 100644 index 0000000..fac13e8 --- /dev/null +++ b/lib/invitation-preview-limit.ts @@ -0,0 +1,30 @@ +import { headers } from 'next/headers'; +import { checkRateLimit, getClientIpFromHeaders } from '@/lib/rate-limit'; +import { createHash } from 'crypto'; + +/** + * The invitation preview surfaces (`/invitations/accept` and `/register?invitationToken=`) + * are reachable without a session and each render costs two database queries, so they are + * throttled the same way the emailed verify-email link is. + * + * Two buckets: a generous per-IP one that bounds enumeration across many tokens, and a + * tight per-IP+token one that stops repeated probing of a single invitation. + * + * Returns false when the caller should skip the lookup entirely. + */ +export async function isInvitationPreviewAllowed(token: string): Promise { + const ip = getClientIpFromHeaders(await headers()); + + const perIp = await checkRateLimit(`invitation-preview:${ip}`, 'invitation-preview'); + if (!perIp.allowed) return false; + + // Hash the token so raw invitation secrets never reach the rate_limits table (and stay + // within the 256-char key bound). + const tokenHash = createHash('sha256').update(token).digest('hex').slice(0, 32); + const perToken = await checkRateLimit( + `invitation-preview:${ip}:${tokenHash}`, + 'invitation-preview-token' + ); + + return perToken.allowed; +} diff --git a/lib/invitations.ts b/lib/invitations.ts index d35ebdc..4bbec4b 100644 --- a/lib/invitations.ts +++ b/lib/invitations.ts @@ -221,6 +221,64 @@ export async function createOrRefreshInvitation(params: { throw new Error('Failed to create invitation after retrying'); } +export interface InvitationPreview { + email: string; + role: InvitationRole; + roleLabel: string; + scope: InvitationScope; + scopeLabel: string; + status: InvitationStatus; + /** PENDING but past its expiry — the DB row is only flipped to EXPIRED on acceptance. */ + isExpired: boolean; + inviterName: string; + targetName: string | null; + /** Whether an account already exists for the invited address. */ + hasAccount: boolean; +} + +/** + * Public-facing summary of an invitation, safe to render to a signed-out visitor: + * the token itself is the secret, and everything here was already in the email we sent + * to that address. + */ +export async function getInvitationPreviewByToken( + token: string +): Promise { + const invitation = await db.invitation.findUnique({ + where: { token }, + select: { + email: true, + role: true, + scope: true, + status: true, + expiresAt: true, + invitedBy: { select: { name: true } }, + workspace: { select: { name: true } }, + project: { select: { name: true } }, + }, + }); + + if (!invitation) return null; + + const existingUser = await db.user.findUnique({ + where: { email: invitation.email }, + select: { id: true }, + }); + + return { + email: invitation.email, + role: invitation.role, + roleLabel: roleLabel(invitation.role), + scope: invitation.scope, + scopeLabel: scopeLabel(invitation.scope), + status: invitation.status, + isExpired: invitation.expiresAt <= new Date(), + inviterName: invitation.invitedBy?.name?.trim() || 'A team member', + targetName: invitation.workspace?.name ?? invitation.project?.name ?? null, + hasAccount: Boolean(existingUser), + }; +} + export async function getValidInvitationByToken(token: string) { const now = new Date(); return db.invitation.findFirst({ diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 23dd36c..30a792a 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -81,6 +81,11 @@ export const RATE_LIMIT_CONFIGS: Record = { // Member management 'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour + // Unauthenticated invitation preview (accept landing + invited sign-up). Two buckets, + // as with share-unlock: a generous per-IP one that bounds token enumeration, and a + // tight per-IP+token one that stops repeated probing of a single invitation. + 'invitation-preview': { windowMs: 15 * 60 * 1000, maxRequests: 120 }, // 120 per 15 min per IP + 'invitation-preview-token': { windowMs: 15 * 60 * 1000, maxRequests: 12 }, // 12 per 15 min per IP+token 'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute // Mutations (update/delete) — moderate @@ -188,12 +193,20 @@ function isPlausibleIp(value: string): boolean { * do so allows clients to spoof their IP and bypass rate limits. */ export function getClientIp(request: Request): string { + return getClientIpFromHeaders(request.headers); +} + +/** + * Same resolution as {@link getClientIp}, for callers that only have headers rather than a + * Request — server components reading `await headers()`. + */ +export function getClientIpFromHeaders(headers: Headers): string { const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase(); if (mode === 'cloudflare') { // cf-connecting-ip is injected by Cloudflare and cannot be set by clients // when origin access is restricted to Cloudflare's IP ranges. - const cfIp = request.headers.get('cf-connecting-ip'); + const cfIp = headers.get('cf-connecting-ip'); if (cfIp && isPlausibleIp(cfIp)) { return cfIp; } @@ -202,11 +215,11 @@ export function getClientIp(request: Request): string { if (mode === 'nginx') { // x-real-ip is set by Nginx's real_ip_header directive (connection-level, not spoofable // by clients when set_real_ip_from is configured for the upstream proxy). - const realIp = request.headers.get('x-real-ip'); + const realIp = headers.get('x-real-ip'); if (realIp && isPlausibleIp(realIp)) return realIp; // x-forwarded-for last entry added by Nginx when proxy_add_x_forwarded_for is used. - const forwardedFor = request.headers.get('x-forwarded-for'); + const forwardedFor = headers.get('x-forwarded-for'); if (forwardedFor) { const entries = forwardedFor.split(','); const last = entries[entries.length - 1].trim(); diff --git a/lib/safe-redirect.ts b/lib/safe-redirect.ts new file mode 100644 index 0000000..40dddae --- /dev/null +++ b/lib/safe-redirect.ts @@ -0,0 +1,47 @@ +/** + * Reduce an untrusted `callbackUrl`/`next` value to a same-origin relative path. + * Anything absolute, cross-origin or unparsable falls back to `fallback`. + * + * Works on both sides: in the browser the origin defaults to `window.location.origin` + * (so next-auth's absolute `result.url` still passes), on the server pass the public origin. + */ +export function getSafeCallbackUrl( + value: string | null | undefined, + options?: { origin?: string; fallback?: string } +): string { + const fallback = options?.fallback ?? '/dashboard'; + if (!value) return fallback; + + const baseOrigin = + options?.origin ?? + (typeof window === 'undefined' ? 'http://localhost' : window.location.origin); + + try { + const parsed = new URL(value, baseOrigin); + if (parsed.origin !== baseOrigin) return fallback; + const path = `${parsed.pathname}${parsed.search}${parsed.hash}`; + // The origin check alone is not enough: an attacker can smuggle their own host into + // the path of an otherwise same-origin URL. `new URL('https://app.example.com//evil.com')` + // has our origin but a pathname of `//evil.com`, which every navigation sink below + // resolves as protocol-relative and follows off-site. + return isSafeRelativePath(path) ? path : fallback; + } catch { + return fallback; + } +} + +/** + * True when a path is safe to hand to a navigation sink (`router.push`, ``, + * `NextResponse.redirect`): rooted at a single `/`, so it can only ever stay on this origin. + * + * `//evil.com` and `/\evil.com` are protocol-relative — browsers fill in the current + * scheme and navigate to `evil.com`. + */ +export function isSafeRelativePath(value: string): boolean { + return value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\'); +} + +/** True when a sanitized path points at the invitation acceptance route. */ +export function isInvitationCallbackUrl(path: string): boolean { + return path.startsWith('/invitations/accept'); +}