diff options
| author | Mohamed Bassem <me@mbassem.com> | 2026-02-01 17:20:17 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-01 17:20:17 +0000 |
| commit | 4051594b2f410f01e883febad22eb9001a84f90e (patch) | |
| tree | 37b74d93192e2399fb50a31436150ba671b2b5cc | |
| parent | 67501ed6229a63efc29b34513fac35239bd4f8e4 (diff) | |
| download | karakeep-4051594b2f410f01e883febad22eb9001a84f90e.tar.zst | |
feat: add support for redirectUrl after signup (#2439)
* feat: add support for redirectUrl after signup
* pr review
* more fixes
* format
* another fix
| -rw-r--r-- | apps/web/app/check-email/page.tsx | 5 | ||||
| -rw-r--r-- | apps/web/app/signup/page.tsx | 15 | ||||
| -rw-r--r-- | apps/web/app/verify-email/page.tsx | 36 | ||||
| -rw-r--r-- | apps/web/components/signup/SignUpForm.tsx | 20 | ||||
| -rw-r--r-- | packages/shared/utils/redirectUrl.test.ts | 89 | ||||
| -rw-r--r-- | packages/shared/utils/redirectUrl.ts | 35 | ||||
| -rw-r--r-- | packages/trpc/email.ts | 6 | ||||
| -rw-r--r-- | packages/trpc/models/users.ts | 12 | ||||
| -rw-r--r-- | packages/trpc/routers/users.test.ts | 1 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 17 |
10 files changed, 215 insertions, 21 deletions
diff --git a/apps/web/app/check-email/page.tsx b/apps/web/app/check-email/page.tsx index 2fbc47fe..50eed4bd 100644 --- a/apps/web/app/check-email/page.tsx +++ b/apps/web/app/check-email/page.tsx @@ -15,6 +15,7 @@ import { useMutation } from "@tanstack/react-query"; import { Loader2, Mail } from "lucide-react"; import { useTRPC } from "@karakeep/shared-react/trpc"; +import { validateRedirectUrl } from "@karakeep/shared/utils/redirectUrl"; export default function CheckEmailPage() { const api = useTRPC(); @@ -23,6 +24,8 @@ export default function CheckEmailPage() { const [message, setMessage] = useState(""); const email = searchParams.get("email"); + const redirectUrl = + validateRedirectUrl(searchParams.get("redirectUrl")) ?? "/"; const resendEmailMutation = useMutation( api.users.resendVerificationEmail.mutationOptions({ @@ -39,7 +42,7 @@ export default function CheckEmailPage() { const handleResendEmail = () => { if (email) { - resendEmailMutation.mutate({ email }); + resendEmailMutation.mutate({ email, redirectUrl }); } }; diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx index ee77f65e..5c8b943e 100644 --- a/apps/web/app/signup/page.tsx +++ b/apps/web/app/signup/page.tsx @@ -3,10 +3,19 @@ import KarakeepLogo from "@/components/KarakeepIcon"; import SignUpForm from "@/components/signup/SignUpForm"; import { getServerAuthSession } from "@/server/auth"; -export default async function SignUpPage() { +import { validateRedirectUrl } from "@karakeep/shared/utils/redirectUrl"; + +export default async function SignUpPage({ + searchParams, +}: { + searchParams: Promise<{ redirectUrl?: string }>; +}) { const session = await getServerAuthSession(); + const { redirectUrl: rawRedirectUrl } = await searchParams; + const redirectUrl = validateRedirectUrl(rawRedirectUrl) ?? "/"; + if (session) { - redirect("/"); + redirect(redirectUrl); } return ( @@ -15,7 +24,7 @@ export default async function SignUpPage() { <div className="flex items-center justify-center"> <KarakeepLogo height={80} /> </div> - <SignUpForm /> + <SignUpForm redirectUrl={redirectUrl} /> </div> </div> ); diff --git a/apps/web/app/verify-email/page.tsx b/apps/web/app/verify-email/page.tsx index 899c94d6..5044c63e 100644 --- a/apps/web/app/verify-email/page.tsx +++ b/apps/web/app/verify-email/page.tsx @@ -15,6 +15,10 @@ import { useMutation } from "@tanstack/react-query"; import { CheckCircle, Loader2, XCircle } from "lucide-react"; import { useTRPC } from "@karakeep/shared-react/trpc"; +import { + isMobileAppRedirect, + validateRedirectUrl, +} from "@karakeep/shared/utils/redirectUrl"; export default function VerifyEmailPage() { const api = useTRPC(); @@ -27,14 +31,26 @@ export default function VerifyEmailPage() { const token = searchParams.get("token"); const email = searchParams.get("email"); + const redirectUrl = + validateRedirectUrl(searchParams.get("redirectUrl")) ?? "/"; const verifyEmailMutation = useMutation( api.users.verifyEmail.mutationOptions({ onSuccess: () => { setStatus("success"); - setMessage( - "Your email has been successfully verified! You can now sign in.", - ); + if (isMobileAppRedirect(redirectUrl)) { + setMessage( + "Your email has been successfully verified! Redirecting to the app...", + ); + // Redirect to mobile app after a brief delay + setTimeout(() => { + window.location.href = redirectUrl; + }, 1500); + } else { + setMessage( + "Your email has been successfully verified! You can now sign in.", + ); + } }, onError: (error) => { setStatus("error"); @@ -59,6 +75,8 @@ export default function VerifyEmailPage() { }), ); + const isMobileRedirect = isMobileAppRedirect(redirectUrl); + useEffect(() => { if (token && email) { verifyEmailMutation.mutate({ token, email }); @@ -70,12 +88,18 @@ export default function VerifyEmailPage() { const handleResendEmail = () => { if (email) { - resendEmailMutation.mutate({ email }); + resendEmailMutation.mutate({ email, redirectUrl }); } }; const handleSignIn = () => { - router.push("/signin"); + if (isMobileRedirect) { + window.location.href = redirectUrl; + } else if (redirectUrl !== "/") { + router.push(`/signin?redirectUrl=${encodeURIComponent(redirectUrl)}`); + } else { + router.push("/signin"); + } }; return ( @@ -109,7 +133,7 @@ export default function VerifyEmailPage() { </AlertDescription> </Alert> <Button onClick={handleSignIn} className="w-full"> - Sign In + {isMobileRedirect ? "Open App" : "Sign In"} </Button> </> )} diff --git a/apps/web/components/signup/SignUpForm.tsx b/apps/web/components/signup/SignUpForm.tsx index f758bfda..15b64fab 100644 --- a/apps/web/components/signup/SignUpForm.tsx +++ b/apps/web/components/signup/SignUpForm.tsx @@ -35,10 +35,15 @@ import { z } from "zod"; import { useTRPC } from "@karakeep/shared-react/trpc"; import { zSignUpSchema } from "@karakeep/shared/types/users"; +import { isMobileAppRedirect } from "@karakeep/shared/utils/redirectUrl"; const VERIFY_EMAIL_ERROR = "Please verify your email address before signing in"; -export default function SignUpForm() { +interface SignUpFormProps { + redirectUrl: string; +} + +export default function SignUpForm({ redirectUrl }: SignUpFormProps) { const api = useTRPC(); const form = useForm<z.infer<typeof zSignUpSchema>>({ resolver: zodResolver(zSignUpSchema), @@ -113,7 +118,10 @@ export default function SignUpForm() { } form.clearErrors("turnstileToken"); try { - await createUserMutation.mutateAsync(value); + await createUserMutation.mutateAsync({ + ...value, + redirectUrl, + }); } catch (e) { if (e instanceof TRPCClientError) { setErrorMessage(e.message); @@ -133,7 +141,7 @@ export default function SignUpForm() { if (!resp || !resp.ok || resp.error) { if (resp?.error === VERIFY_EMAIL_ERROR) { router.replace( - `/check-email?email=${encodeURIComponent(value.email.trim())}`, + `/check-email?email=${encodeURIComponent(value.email.trim())}&redirectUrl=${encodeURIComponent(redirectUrl)}`, ); } else { setErrorMessage( @@ -147,7 +155,11 @@ export default function SignUpForm() { } return; } - router.replace("/"); + if (isMobileAppRedirect(redirectUrl)) { + window.location.href = redirectUrl; + } else { + router.replace(redirectUrl); + } })} className="space-y-4" > diff --git a/packages/shared/utils/redirectUrl.test.ts b/packages/shared/utils/redirectUrl.test.ts new file mode 100644 index 00000000..97d52cf2 --- /dev/null +++ b/packages/shared/utils/redirectUrl.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { isMobileAppRedirect, validateRedirectUrl } from "./redirectUrl"; + +describe("validateRedirectUrl", () => { + it("should return undefined for null input", () => { + expect(validateRedirectUrl(null)).toBe(undefined); + }); + + it("should return undefined for undefined input", () => { + expect(validateRedirectUrl(undefined)).toBe(undefined); + }); + + it("should return undefined for empty string", () => { + expect(validateRedirectUrl("")).toBe(undefined); + }); + + it("should allow relative paths starting with '/'", () => { + expect(validateRedirectUrl("/")).toBe("/"); + expect(validateRedirectUrl("/dashboard")).toBe("/dashboard"); + expect(validateRedirectUrl("/settings/profile")).toBe("/settings/profile"); + expect(validateRedirectUrl("/path?query=value")).toBe("/path?query=value"); + expect(validateRedirectUrl("/path#hash")).toBe("/path#hash"); + }); + + it("should reject protocol-relative URLs (//)", () => { + expect(validateRedirectUrl("//evil.com")).toBe(undefined); + expect(validateRedirectUrl("//evil.com/path")).toBe(undefined); + }); + + it("should allow karakeep:// scheme for mobile app", () => { + expect(validateRedirectUrl("karakeep://")).toBe("karakeep://"); + expect(validateRedirectUrl("karakeep://callback")).toBe( + "karakeep://callback", + ); + expect(validateRedirectUrl("karakeep://callback/path")).toBe( + "karakeep://callback/path", + ); + expect(validateRedirectUrl("karakeep://callback?param=value")).toBe( + "karakeep://callback?param=value", + ); + }); + + it("should reject http:// scheme", () => { + expect(validateRedirectUrl("http://example.com")).toBe(undefined); + expect(validateRedirectUrl("http://localhost:3000")).toBe(undefined); + }); + + it("should reject https:// scheme", () => { + expect(validateRedirectUrl("https://example.com")).toBe(undefined); + expect(validateRedirectUrl("https://evil.com/phishing")).toBe(undefined); + }); + + it("should reject javascript: scheme", () => { + expect(validateRedirectUrl("javascript:alert(1)")).toBe(undefined); + }); + + it("should reject data: scheme", () => { + expect( + validateRedirectUrl("data:text/html,<script>alert(1)</script>"), + ).toBe(undefined); + }); + + it("should reject other custom schemes", () => { + expect(validateRedirectUrl("file:///etc/passwd")).toBe(undefined); + expect(validateRedirectUrl("ftp://example.com")).toBe(undefined); + expect(validateRedirectUrl("mailto:test@example.com")).toBe(undefined); + }); + + it("should reject paths not starting with /", () => { + expect(validateRedirectUrl("dashboard")).toBe(undefined); + expect(validateRedirectUrl("path/to/page")).toBe(undefined); + }); +}); + +describe("isMobileAppRedirect", () => { + it("should return true for karakeep:// URLs", () => { + expect(isMobileAppRedirect("karakeep://")).toBe(true); + expect(isMobileAppRedirect("karakeep://callback")).toBe(true); + expect(isMobileAppRedirect("karakeep://callback/path")).toBe(true); + }); + + it("should return false for other URLs", () => { + expect(isMobileAppRedirect("/")).toBe(false); + expect(isMobileAppRedirect("/dashboard")).toBe(false); + expect(isMobileAppRedirect("https://example.com")).toBe(false); + expect(isMobileAppRedirect("http://localhost")).toBe(false); + }); +}); diff --git a/packages/shared/utils/redirectUrl.ts b/packages/shared/utils/redirectUrl.ts new file mode 100644 index 00000000..c2adffc0 --- /dev/null +++ b/packages/shared/utils/redirectUrl.ts @@ -0,0 +1,35 @@ +/** + * Validates a redirect URL to prevent open redirect attacks. + * Only allows: + * - Relative paths starting with "/" (but not "//" to prevent protocol-relative URLs) + * - The karakeep:// scheme for the mobile app + * + * @returns The validated URL if valid, otherwise undefined. + */ +export function validateRedirectUrl( + url: string | null | undefined, +): string | undefined { + if (!url) { + return undefined; + } + + // Allow relative paths starting with "/" but not "//" (protocol-relative URLs) + if (url.startsWith("/") && !url.startsWith("//")) { + return url; + } + + // Allow karakeep:// scheme for mobile app deep links + if (url.startsWith("karakeep://")) { + return url; + } + + // Reject all other schemes (http, https, javascript, data, etc.) + return undefined; +} + +/** + * Checks if the redirect URL is a mobile app deep link. + */ +export function isMobileAppRedirect(url: string): boolean { + return url.startsWith("karakeep://"); +} diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts index b837656e..15e1ef74 100644 --- a/packages/trpc/email.ts +++ b/packages/trpc/email.ts @@ -55,8 +55,12 @@ export const sendVerificationEmail = withTracing( email: string, name: string, token: string, + redirectUrl?: string, ) => { - const verificationUrl = `${serverConfig.publicUrl}/verify-email?token=${encodeURIComponent(token)}&email=${encodeURIComponent(email)}`; + let verificationUrl = `${serverConfig.publicUrl}/verify-email?token=${encodeURIComponent(token)}&email=${encodeURIComponent(email)}`; + if (redirectUrl) { + verificationUrl += `&redirectUrl=${encodeURIComponent(redirectUrl)}`; + } const mailOptions = { from: serverConfig.email.smtp!.from, diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts index 5d3c3785..671f7d74 100644 --- a/packages/trpc/models/users.ts +++ b/packages/trpc/models/users.ts @@ -61,7 +61,7 @@ export class User { static async create( ctx: Context, - input: z.infer<typeof zSignUpSchema>, + input: z.infer<typeof zSignUpSchema> & { redirectUrl?: string }, role?: "user" | "admin", ) { const salt = generatePasswordSalt(); @@ -76,7 +76,12 @@ export class User { if (serverConfig.auth.emailVerificationRequired) { const token = await User.genEmailVerificationToken(ctx.db, input.email); try { - await sendVerificationEmail(input.email, input.name, token); + await sendVerificationEmail( + input.email, + input.name, + token, + input.redirectUrl, + ); } catch (error) { console.error("Failed to send verification email:", error); } @@ -227,6 +232,7 @@ export class User { static async resendVerificationEmail( ctx: Context, email: string, + redirectUrl?: string, ): Promise<void> { if ( !serverConfig.auth.emailVerificationRequired || @@ -255,7 +261,7 @@ export class User { const token = await User.genEmailVerificationToken(ctx.db, email); try { - await sendVerificationEmail(email, user.name, token); + await sendVerificationEmail(email, user.name, token, redirectUrl); } catch (error) { console.error("Failed to send verification email:", error); throw new TRPCError({ diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts index 605d14fc..ccde4c86 100644 --- a/packages/trpc/routers/users.test.ts +++ b/packages/trpc/routers/users.test.ts @@ -1116,6 +1116,7 @@ describe("User Routes", () => { "resend@test.com", "Test User", expect.any(String), // token + undefined, // redirectUrl ); }); diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts index abd50d63..c11a0ffd 100644 --- a/packages/trpc/routers/users.ts +++ b/packages/trpc/routers/users.ts @@ -11,6 +11,7 @@ import { zWhoAmIResponseSchema, zWrappedStatsResponseSchema, } from "@karakeep/shared/types/users"; +import { validateRedirectUrl } from "@karakeep/shared/utils/redirectUrl"; import { adminProcedure, @@ -31,7 +32,7 @@ export const usersAppRouter = router({ maxRequests: 3, }), ) - .input(zSignUpSchema) + .input(zSignUpSchema.and(z.object({ redirectUrl: z.string().optional() }))) .output( z.object({ id: z.string(), @@ -65,7 +66,11 @@ export const usersAppRouter = router({ }); } } - const user = await User.create(ctx, input); + const validatedRedirectUrl = validateRedirectUrl(input.redirectUrl); + const user = await User.create(ctx, { + ...input, + redirectUrl: validatedRedirectUrl, + }); return { id: user.id, name: user.name, @@ -206,10 +211,16 @@ export const usersAppRouter = router({ .input( z.object({ email: z.string().email(), + redirectUrl: z.string().optional(), }), ) .mutation(async ({ input, ctx }) => { - await User.resendVerificationEmail(ctx, input.email); + const validatedRedirectUrl = validateRedirectUrl(input.redirectUrl); + await User.resendVerificationEmail( + ctx, + input.email, + validatedRedirectUrl, + ); return { success: true }; }), forgotPassword: publicProcedure |
