aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2026-02-01 17:20:17 +0000
committerGitHub <noreply@github.com>2026-02-01 17:20:17 +0000
commit4051594b2f410f01e883febad22eb9001a84f90e (patch)
tree37b74d93192e2399fb50a31436150ba671b2b5cc
parent67501ed6229a63efc29b34513fac35239bd4f8e4 (diff)
downloadkarakeep-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.tsx5
-rw-r--r--apps/web/app/signup/page.tsx15
-rw-r--r--apps/web/app/verify-email/page.tsx36
-rw-r--r--apps/web/components/signup/SignUpForm.tsx20
-rw-r--r--packages/shared/utils/redirectUrl.test.ts89
-rw-r--r--packages/shared/utils/redirectUrl.ts35
-rw-r--r--packages/trpc/email.ts6
-rw-r--r--packages/trpc/models/users.ts12
-rw-r--r--packages/trpc/routers/users.test.ts1
-rw-r--r--packages/trpc/routers/users.ts17
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