@caffeinebounce/identity 0.12.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  "use client";
2
- import { cn, FieldLabel, Input, Button, PasswordInput, defaultPasswordRules, FieldError, PasswordRequirements, Checkbox, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Badge, Tooltip, TooltipTrigger, TooltipContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Label, DialogFooter, Spinner, extendedPasswordRules, VerificationCodeInput, Tabs, TabsList, TabsTrigger, TabsContent, InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from '@caffeinebounce/ui/primitives';
2
+ import { cn, FieldLabel, Input, Button, PasswordInput, defaultPasswordRules, FieldError, PasswordRequirements, Checkbox, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Badge, Tooltip, TooltipTrigger, TooltipContent, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Label, DialogFooter, TooltipProvider, Spinner, extendedPasswordRules, VerificationCodeInput, Tabs, TabsList, TabsTrigger, TabsContent, InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } from '@caffeinebounce/ui/primitives';
3
3
  import { useSearchParams, useRouter } from 'next/navigation';
4
4
  import { createContext, useState, useEffect, useCallback, useRef, useMemo, useContext } from 'react';
5
- import { ArrowLeft, AlertCircle, Loader2, Shield, AlertTriangle, Smartphone, Check, Mail, KeyRound, Info, Trash2, CheckCircle2, Link2, Unlink, Phone, Copy, Download, Lightbulb, X } from 'lucide-react';
5
+ import { ArrowLeft, AlertCircle, Loader2, Shield, AlertTriangle, Smartphone, Check, Mail, KeyRound, Info, Trash2, CheckCircle2, Link2, Unlink, Phone, Copy, Download, Lightbulb, X, PencilLine } from 'lucide-react';
6
6
  import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
7
7
  import { getClientOrigin, generateDeviceFingerprint, formatPhoneInput, isPreviewEnvironment, formatDate } from '@caffeinebounce/shared-utils';
8
8
  export { generateDeviceFingerprint, generateSecureToken, getClientIP, getGeolocationFromIP, hashString } from '@caffeinebounce/shared-utils';
@@ -14,6 +14,17 @@ import { REGEXP_ONLY_DIGITS } from 'input-otp';
14
14
  import { NextResponse } from 'next/server';
15
15
 
16
16
  // src/components/auth/EmailVerificationPending.tsx
17
+
18
+ // src/types.ts
19
+ var defaultAuthLinks = {
20
+ signIn: "/signin",
21
+ signUp: "/signup",
22
+ forgotPassword: "/forgot-password",
23
+ resetPassword: "/reset-password",
24
+ callback: "/callback",
25
+ home: "/",
26
+ defaultRedirect: "/dashboard"
27
+ };
17
28
  function AuthFormLayout({
18
29
  children,
19
30
  footer,
@@ -50,6 +61,122 @@ function AuthFormLayout({
50
61
  ] }) })
51
62
  ] });
52
63
  }
64
+
65
+ // src/utils/redirect.ts
66
+ var INTERNAL_REDIRECT_BASE = new URL("https://identity.invalid");
67
+ var ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i;
68
+ function hasControlCharacter(value) {
69
+ return Array.from(value).some((character) => {
70
+ const code = character.charCodeAt(0);
71
+ return code <= 31 || code === 127;
72
+ });
73
+ }
74
+ function isStrictInternalRedirect(candidate) {
75
+ if (!candidate?.startsWith("/") || hasControlCharacter(candidate)) {
76
+ return false;
77
+ }
78
+ const pathEnd = candidate.search(/[?#]/);
79
+ const pathname = pathEnd === -1 ? candidate : candidate.slice(0, pathEnd);
80
+ if (pathname.includes("\\") || ENCODED_PATH_SEPARATOR.test(pathname)) {
81
+ return false;
82
+ }
83
+ try {
84
+ const target = new URL(candidate, INTERNAL_REDIRECT_BASE);
85
+ return target.origin === INTERNAL_REDIRECT_BASE.origin;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+ function getSafeInternalRedirect(candidate, fallback) {
91
+ if (isStrictInternalRedirect(candidate)) {
92
+ return candidate;
93
+ }
94
+ if (fallback === null) {
95
+ return null;
96
+ }
97
+ return isStrictInternalRedirect(fallback) ? fallback : "/";
98
+ }
99
+
100
+ // src/components/auth/utils.ts
101
+ var warnedSupabaseRedirectOrigins = /* @__PURE__ */ new Set();
102
+ function hardRedirect(redirectTo) {
103
+ window.location.href = redirectTo;
104
+ }
105
+ function sanitizeAuthError(message) {
106
+ const lowerMessage = message.toLowerCase();
107
+ if (lowerMessage.includes("user already registered") || lowerMessage.includes("already registered") || lowerMessage.includes("already exists") || lowerMessage.includes("email already") || lowerMessage.includes("duplicate")) {
108
+ return "An account with this email already exists. Please sign in instead.";
109
+ }
110
+ if (lowerMessage.includes("invalid login credentials") || lowerMessage.includes("invalid credentials") || lowerMessage.includes("wrong password") || lowerMessage.includes("incorrect password")) {
111
+ return "Invalid email or password. Please try again.";
112
+ }
113
+ if (lowerMessage.includes("invalid email") || lowerMessage.includes("password")) {
114
+ return message;
115
+ }
116
+ if (message === "The string did not match the expected pattern.") {
117
+ return "Please check your email address format and try again.";
118
+ }
119
+ if (lowerMessage.includes("rate limit") || lowerMessage.includes("too many requests") || lowerMessage.includes("try again later")) {
120
+ return "Too many attempts. Please wait a moment and try again.";
121
+ }
122
+ if (lowerMessage.includes("hook") || lowerMessage.includes("authorization") || lowerMessage.includes("token") || lowerMessage.includes("internal") || lowerMessage.includes("server") || lowerMessage.includes("database") || lowerMessage.includes("connection")) {
123
+ return "An error occurred. Please try again.";
124
+ }
125
+ return "An error occurred. Please try again.";
126
+ }
127
+ function sanitizeSignupError(message) {
128
+ const sanitized = sanitizeAuthError(message);
129
+ if (sanitized === "An error occurred. Please try again.") {
130
+ return "An error occurred during sign up. Please try again.";
131
+ }
132
+ return sanitized;
133
+ }
134
+ function buildOAuthRedirectTo(origin, nextPath) {
135
+ const siteUrl = origin.replace(/\/$/, "");
136
+ return `${siteUrl}/callback?next=${encodeURIComponent(nextPath)}`;
137
+ }
138
+ function buildOAuthSignInOptions(provider, redirectTo, options = {}) {
139
+ return {
140
+ redirectTo,
141
+ ...provider === "azure" ? { scopes: "email" } : {},
142
+ ...options
143
+ };
144
+ }
145
+ function warnAboutSupabaseRedirectAllowlist(origin) {
146
+ const normalizedOrigin = origin.replace(/\/$/, "");
147
+ let hostname = "";
148
+ try {
149
+ hostname = new URL(normalizedOrigin).host;
150
+ } catch {
151
+ return;
152
+ }
153
+ if (!isPreviewEnvironment(hostname)) {
154
+ return;
155
+ }
156
+ if (warnedSupabaseRedirectOrigins.has(normalizedOrigin)) {
157
+ return;
158
+ }
159
+ warnedSupabaseRedirectOrigins.add(normalizedOrigin);
160
+ console.warn(
161
+ `[identity] Supabase redirect allowlist reminder: add "${normalizedOrigin}/**" to Supabase Auth > URL Configuration > Additional Redirect URLs. The auth components already use the active origin for callbacks, but Supabase must allow that origin too or OAuth/email flows can bounce to another host.`
162
+ );
163
+ }
164
+ function clearStalePKCEState() {
165
+ if (typeof window === "undefined") return;
166
+ try {
167
+ const keysToRemove = [];
168
+ for (let i = 0; i < localStorage.length; i++) {
169
+ const key = localStorage.key(i);
170
+ if (key?.includes("-code-verifier")) {
171
+ keysToRemove.push(key);
172
+ }
173
+ }
174
+ for (const key of keysToRemove) {
175
+ localStorage.removeItem(key);
176
+ }
177
+ } catch {
178
+ }
179
+ }
53
180
  var POLL_INTERVAL_MS = 6e4;
54
181
  var MAX_POLL_COUNT = 60;
55
182
  function EmailVerificationPending({
@@ -120,9 +247,19 @@ function EmailVerificationPending({
120
247
  setResendStatus("sending");
121
248
  try {
122
249
  const supabase = createClient();
250
+ const safeRedirectTo = getSafeInternalRedirect(
251
+ redirectTo,
252
+ defaultAuthLinks.defaultRedirect
253
+ );
123
254
  const { error } = await supabase.auth.resend({
124
255
  type: "signup",
125
- email
256
+ email,
257
+ options: {
258
+ emailRedirectTo: buildOAuthRedirectTo(
259
+ window.location.origin,
260
+ safeRedirectTo
261
+ )
262
+ }
126
263
  });
127
264
  if (error) {
128
265
  setResendStatus("error");
@@ -242,17 +379,6 @@ function EmailVerificationPending({
242
379
  }
243
380
  );
244
381
  }
245
-
246
- // src/types.ts
247
- var defaultAuthLinks = {
248
- signIn: "/signin",
249
- signUp: "/signup",
250
- forgotPassword: "/forgot-password",
251
- resetPassword: "/reset-password",
252
- callback: "/callback",
253
- home: "/",
254
- defaultRedirect: "/dashboard"
255
- };
256
382
  function AuthHeader({
257
383
  logo,
258
384
  title,
@@ -274,82 +400,6 @@ function AuthHeader({
274
400
  /* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold tracking-tight text-foreground", children: title })
275
401
  ] });
276
402
  }
277
- var warnedSupabaseRedirectOrigins = /* @__PURE__ */ new Set();
278
- function sanitizeAuthError(message) {
279
- const lowerMessage = message.toLowerCase();
280
- if (lowerMessage.includes("user already registered") || lowerMessage.includes("already registered") || lowerMessage.includes("already exists") || lowerMessage.includes("email already") || lowerMessage.includes("duplicate")) {
281
- return "An account with this email already exists. Please sign in instead.";
282
- }
283
- if (lowerMessage.includes("invalid login credentials") || lowerMessage.includes("invalid credentials") || lowerMessage.includes("wrong password") || lowerMessage.includes("incorrect password")) {
284
- return "Invalid email or password. Please try again.";
285
- }
286
- if (lowerMessage.includes("invalid email") || lowerMessage.includes("password")) {
287
- return message;
288
- }
289
- if (message === "The string did not match the expected pattern.") {
290
- return "Please check your email address format and try again.";
291
- }
292
- if (lowerMessage.includes("rate limit") || lowerMessage.includes("too many requests") || lowerMessage.includes("try again later")) {
293
- return "Too many attempts. Please wait a moment and try again.";
294
- }
295
- if (lowerMessage.includes("hook") || lowerMessage.includes("authorization") || lowerMessage.includes("token") || lowerMessage.includes("internal") || lowerMessage.includes("server") || lowerMessage.includes("database") || lowerMessage.includes("connection")) {
296
- return "An error occurred. Please try again.";
297
- }
298
- return "An error occurred. Please try again.";
299
- }
300
- function sanitizeSignupError(message) {
301
- const sanitized = sanitizeAuthError(message);
302
- if (sanitized === "An error occurred. Please try again.") {
303
- return "An error occurred during sign up. Please try again.";
304
- }
305
- return sanitized;
306
- }
307
- function buildOAuthRedirectTo(origin, nextPath) {
308
- const siteUrl = origin.replace(/\/$/, "");
309
- return `${siteUrl}/callback?next=${encodeURIComponent(nextPath)}`;
310
- }
311
- function buildOAuthSignInOptions(provider, redirectTo, options = {}) {
312
- return {
313
- redirectTo,
314
- ...provider === "azure" ? { scopes: "email" } : {},
315
- ...options
316
- };
317
- }
318
- function warnAboutSupabaseRedirectAllowlist(origin) {
319
- const normalizedOrigin = origin.replace(/\/$/, "");
320
- let hostname = "";
321
- try {
322
- hostname = new URL(normalizedOrigin).host;
323
- } catch {
324
- return;
325
- }
326
- if (!isPreviewEnvironment(hostname)) {
327
- return;
328
- }
329
- if (warnedSupabaseRedirectOrigins.has(normalizedOrigin)) {
330
- return;
331
- }
332
- warnedSupabaseRedirectOrigins.add(normalizedOrigin);
333
- console.warn(
334
- `[identity] Supabase redirect allowlist reminder: add "${normalizedOrigin}/**" to Supabase Auth > URL Configuration > Additional Redirect URLs. The auth components already use the active origin for callbacks, but Supabase must allow that origin too or OAuth/email flows can bounce to another host.`
335
- );
336
- }
337
- function clearStalePKCEState() {
338
- if (typeof window === "undefined") return;
339
- try {
340
- const keysToRemove = [];
341
- for (let i = 0; i < localStorage.length; i++) {
342
- const key = localStorage.key(i);
343
- if (key?.includes("-code-verifier")) {
344
- keysToRemove.push(key);
345
- }
346
- }
347
- for (const key of keysToRemove) {
348
- localStorage.removeItem(key);
349
- }
350
- } catch {
351
- }
352
- }
353
403
  function ForgotPasswordForm({
354
404
  createClient,
355
405
  logo,
@@ -1090,7 +1140,14 @@ function SigninForm({
1090
1140
  const oauthTimeoutRef = useRef(null);
1091
1141
  const router = useRouter();
1092
1142
  const searchParams = useSearchParams();
1093
- const redirectTo = searchParams.get("redirect") || mergedLinks.defaultRedirect;
1143
+ const defaultRedirect = getSafeInternalRedirect(
1144
+ mergedLinks.defaultRedirect,
1145
+ defaultAuthLinks.defaultRedirect
1146
+ );
1147
+ const redirectTo = getSafeInternalRedirect(
1148
+ searchParams.get("redirect"),
1149
+ defaultRedirect
1150
+ );
1094
1151
  const message = searchParams.get("message");
1095
1152
  const mfaRequired = searchParams.get("mfa") === "required";
1096
1153
  const [showMFA, setShowMFA] = useState(mfaRequired);
@@ -1262,7 +1319,7 @@ function SigninForm({
1262
1319
  } else {
1263
1320
  onAuthEvent?.onSignInSuccess?.(userId, email.trim(), false);
1264
1321
  recordSignIn("email", email.trim());
1265
- window.location.href = redirectTo;
1322
+ hardRedirect(redirectTo);
1266
1323
  }
1267
1324
  } catch (err) {
1268
1325
  logError(err, {
@@ -1422,10 +1479,13 @@ function SigninForm({
1422
1479
  }
1423
1480
  );
1424
1481
  }
1482
+ var EMPTY_CONSENT_ITEMS = [];
1425
1483
  function SignupForm({
1426
1484
  createClient,
1427
1485
  logo,
1428
1486
  appName = "your account",
1487
+ title,
1488
+ initialEmail = "",
1429
1489
  termsUrl = "/terms",
1430
1490
  privacyUrl = "/privacy",
1431
1491
  links = {},
@@ -1437,7 +1497,7 @@ function SignupForm({
1437
1497
  oauthIconMonochromeOnHover = false,
1438
1498
  className,
1439
1499
  onAuthEvent,
1440
- consentItems = [],
1500
+ consentItems = EMPTY_CONSENT_ITEMS,
1441
1501
  consentPosition = "above",
1442
1502
  consentSize = "default",
1443
1503
  showHomeLink = true,
@@ -1447,7 +1507,11 @@ function SignupForm({
1447
1507
  const Link = LinkComponent;
1448
1508
  const Image = ImageComponent;
1449
1509
  const router = useRouter();
1450
- const [email, setEmail] = useState("");
1510
+ const redirectTo = getSafeInternalRedirect(
1511
+ mergedLinks.defaultRedirect,
1512
+ defaultAuthLinks.defaultRedirect
1513
+ );
1514
+ const [email, setEmail] = useState(initialEmail);
1451
1515
  const [emailTouched, setEmailTouched] = useState(false);
1452
1516
  const [password, setPassword] = useState("");
1453
1517
  const [confirmPassword, setConfirmPassword] = useState("");
@@ -1512,7 +1576,7 @@ function SignupForm({
1512
1576
  provider,
1513
1577
  options: buildOAuthSignInOptions(
1514
1578
  provider,
1515
- buildOAuthRedirectTo(siteUrl, mergedLinks.defaultRedirect)
1579
+ buildOAuthRedirectTo(siteUrl, redirectTo)
1516
1580
  )
1517
1581
  });
1518
1582
  if (error) {
@@ -1547,7 +1611,7 @@ function SignupForm({
1547
1611
  email: email.trim(),
1548
1612
  password,
1549
1613
  options: {
1550
- emailRedirectTo: `${siteUrl}/callback`,
1614
+ emailRedirectTo: buildOAuthRedirectTo(siteUrl, redirectTo),
1551
1615
  // Pass all consent values as user metadata
1552
1616
  data: consentState
1553
1617
  }
@@ -1565,7 +1629,7 @@ function SignupForm({
1565
1629
  } else if (data.user) {
1566
1630
  const userId = data.user.id;
1567
1631
  onAuthEvent?.onSignUpSuccess?.(userId, email.trim());
1568
- router.push(mergedLinks.defaultRedirect);
1632
+ router.push(redirectTo);
1569
1633
  router.refresh();
1570
1634
  } else {
1571
1635
  onAuthEvent?.onSignUpFailure?.(email.trim(), "no_user_data_in_response");
@@ -1581,7 +1645,7 @@ function SignupForm({
1581
1645
  logo,
1582
1646
  ImageComponent: Image,
1583
1647
  createClient,
1584
- redirectTo: mergedLinks.home,
1648
+ redirectTo,
1585
1649
  className
1586
1650
  }
1587
1651
  );
@@ -1603,7 +1667,7 @@ function SignupForm({
1603
1667
  AuthHeader,
1604
1668
  {
1605
1669
  logo: showLogo !== false ? logo : void 0,
1606
- title: `Sign up for ${appName}`,
1670
+ title: title ?? `Sign up for ${appName}`,
1607
1671
  ImageComponent: Image
1608
1672
  }
1609
1673
  ),
@@ -3717,6 +3781,7 @@ function useVerificationFlow({
3717
3781
  };
3718
3782
  }
3719
3783
  function EmailSection({
3784
+ changeButtonPresentation = "label",
3720
3785
  createClient,
3721
3786
  userId: _userId,
3722
3787
  email,
@@ -3744,6 +3809,26 @@ function EmailSection({
3744
3809
  const handleStartChange = () => {
3745
3810
  openEntryStep();
3746
3811
  };
3812
+ const changeButton = changeButtonPresentation === "icon" ? /* @__PURE__ */ jsx(
3813
+ Button,
3814
+ {
3815
+ "aria-label": "Change email",
3816
+ onClick: handleStartChange,
3817
+ size: "icon-sm",
3818
+ type: "button",
3819
+ variant: "outline",
3820
+ children: /* @__PURE__ */ jsx(PencilLine, { "aria-hidden": "true", className: "size-4" })
3821
+ }
3822
+ ) : /* @__PURE__ */ jsx(
3823
+ Button,
3824
+ {
3825
+ onClick: handleStartChange,
3826
+ size: "sm",
3827
+ type: "button",
3828
+ variant: "outline",
3829
+ children: "Change"
3830
+ }
3831
+ );
3747
3832
  const handleSendCode = async () => {
3748
3833
  if (!newEmail || !newEmail.includes("@")) {
3749
3834
  setError("Please enter a valid email address");
@@ -3823,16 +3908,10 @@ function EmailSection({
3823
3908
  /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: email })
3824
3909
  ] })
3825
3910
  ] }),
3826
- /* @__PURE__ */ jsx(
3827
- Button,
3828
- {
3829
- type: "button",
3830
- variant: "outline",
3831
- size: "sm",
3832
- onClick: handleStartChange,
3833
- children: "Change"
3834
- }
3835
- ),
3911
+ changeButtonPresentation === "icon" ? /* @__PURE__ */ jsx(TooltipProvider, { delayDuration: 300, children: /* @__PURE__ */ jsxs(Tooltip, { children: [
3912
+ /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: changeButton }),
3913
+ /* @__PURE__ */ jsx(TooltipContent, { side: "top", children: "Change email" })
3914
+ ] }) }) : changeButton,
3836
3915
  /* @__PURE__ */ jsx(Dialog, { open: dialogOpen, onOpenChange: handleOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-md", children: [
3837
3916
  step === "enter-email" && /* @__PURE__ */ jsxs(Fragment, { children: [
3838
3917
  /* @__PURE__ */ jsxs(DialogHeader, { children: [
@@ -5530,12 +5609,6 @@ var EMAIL_OTP_TYPES = [
5530
5609
  "email_change",
5531
5610
  "email"
5532
5611
  ];
5533
- function getSafeRedirectPath(candidate, fallback) {
5534
- if (candidate?.startsWith("/") && !candidate.startsWith("//")) {
5535
- return candidate;
5536
- }
5537
- return fallback;
5538
- }
5539
5612
  function getEmailOtpType(value) {
5540
5613
  if (!value) {
5541
5614
  return null;
@@ -5626,8 +5699,9 @@ function createRedirectResponse(target, origin) {
5626
5699
  if (target instanceof URL) {
5627
5700
  return NextResponse.redirect(target.toString());
5628
5701
  }
5629
- if (target.startsWith("/") && !target.startsWith("//")) {
5630
- return NextResponse.redirect(`${origin}${target}`);
5702
+ const safeTarget = getSafeInternalRedirect(target, null);
5703
+ if (safeTarget) {
5704
+ return NextResponse.redirect(`${origin}${safeTarget}`);
5631
5705
  }
5632
5706
  return null;
5633
5707
  }
@@ -5689,15 +5763,20 @@ function createAuthCallbackHandler({
5689
5763
  isLinkingFlow = isDefaultLinkingFlow,
5690
5764
  resolveLinkingErrorMessage
5691
5765
  }) {
5766
+ const safeDefaultRedirect = getSafeInternalRedirect(
5767
+ defaultRedirect,
5768
+ "/dashboard"
5769
+ );
5770
+ const safeSignInPath = getSafeInternalRedirect(signInPath, "/signin");
5692
5771
  return async function GET(request) {
5693
5772
  const requestUrl = new URL(request.url);
5694
5773
  const code = requestUrl.searchParams.get("code");
5695
5774
  const tokenHash = requestUrl.searchParams.get("token_hash");
5696
5775
  const rawOtpType = requestUrl.searchParams.get("type");
5697
5776
  const otpType = getEmailOtpType(rawOtpType);
5698
- const next = getSafeRedirectPath(
5777
+ const next = getSafeInternalRedirect(
5699
5778
  requestUrl.searchParams.get("next") ?? requestUrl.searchParams.get("redirect_to"),
5700
- defaultRedirect
5779
+ safeDefaultRedirect
5701
5780
  );
5702
5781
  const origin = requestUrl.origin;
5703
5782
  const error = requestUrl.searchParams.get("error");
@@ -5719,7 +5798,7 @@ function createAuthCallbackHandler({
5719
5798
  return linkingErrorRedirect;
5720
5799
  }
5721
5800
  return NextResponse.redirect(
5722
- `${origin}${signInPath}?error=${encodeURIComponent(message)}`
5801
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(message)}`
5723
5802
  );
5724
5803
  }
5725
5804
  if (code) {
@@ -5734,7 +5813,7 @@ function createAuthCallbackHandler({
5734
5813
  flow: "oauth",
5735
5814
  postAuthHook,
5736
5815
  postAuthHookErrorMode,
5737
- signInPath,
5816
+ signInPath: safeSignInPath,
5738
5817
  resolveSuccessRedirect
5739
5818
  });
5740
5819
  }
@@ -5754,13 +5833,13 @@ function createAuthCallbackHandler({
5754
5833
  return linkingErrorRedirect;
5755
5834
  }
5756
5835
  return NextResponse.redirect(
5757
- `${origin}${signInPath}?error=${encodeURIComponent(errorMessage)}`
5836
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(errorMessage)}`
5758
5837
  );
5759
5838
  }
5760
5839
  if (tokenHash || rawOtpType) {
5761
5840
  if (!tokenHash || !otpType) {
5762
5841
  return NextResponse.redirect(
5763
- `${origin}${signInPath}?error=${encodeURIComponent("Invalid verification link")}`
5842
+ `${origin}${safeSignInPath}?error=${encodeURIComponent("Invalid verification link")}`
5764
5843
  );
5765
5844
  }
5766
5845
  const supabase = await createClient();
@@ -5778,16 +5857,16 @@ function createAuthCallbackHandler({
5778
5857
  otpType,
5779
5858
  postAuthHook,
5780
5859
  postAuthHookErrorMode,
5781
- signInPath,
5860
+ signInPath: safeSignInPath,
5782
5861
  resolveSuccessRedirect
5783
5862
  });
5784
5863
  }
5785
5864
  return NextResponse.redirect(
5786
- `${origin}${signInPath}?error=${encodeURIComponent(verifyError.message)}`
5865
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(verifyError.message)}`
5787
5866
  );
5788
5867
  }
5789
5868
  return NextResponse.redirect(
5790
- `${origin}${signInPath}?error=No authorization code received`
5869
+ `${origin}${safeSignInPath}?error=No authorization code received`
5791
5870
  );
5792
5871
  };
5793
5872
  }
package/dist/server.js CHANGED
@@ -3,6 +3,43 @@
3
3
  var server = require('next/server');
4
4
  var sharedUtils = require('@caffeinebounce/shared-utils');
5
5
 
6
+ // src/handlers/callback.ts
7
+
8
+ // src/utils/redirect.ts
9
+ var INTERNAL_REDIRECT_BASE = new URL("https://identity.invalid");
10
+ var ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i;
11
+ function hasControlCharacter(value) {
12
+ return Array.from(value).some((character) => {
13
+ const code = character.charCodeAt(0);
14
+ return code <= 31 || code === 127;
15
+ });
16
+ }
17
+ function isStrictInternalRedirect(candidate) {
18
+ if (!candidate?.startsWith("/") || hasControlCharacter(candidate)) {
19
+ return false;
20
+ }
21
+ const pathEnd = candidate.search(/[?#]/);
22
+ const pathname = pathEnd === -1 ? candidate : candidate.slice(0, pathEnd);
23
+ if (pathname.includes("\\") || ENCODED_PATH_SEPARATOR.test(pathname)) {
24
+ return false;
25
+ }
26
+ try {
27
+ const target = new URL(candidate, INTERNAL_REDIRECT_BASE);
28
+ return target.origin === INTERNAL_REDIRECT_BASE.origin;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+ function getSafeInternalRedirect(candidate, fallback) {
34
+ if (isStrictInternalRedirect(candidate)) {
35
+ return candidate;
36
+ }
37
+ if (fallback === null) {
38
+ return null;
39
+ }
40
+ return isStrictInternalRedirect(fallback) ? fallback : "/";
41
+ }
42
+
6
43
  // src/handlers/callback.ts
7
44
  var POST_AUTH_HOOK_ERROR_MESSAGE = "Authentication completed, but setup failed. Please try again.";
8
45
  var DEFAULT_LINKING_ACCOUNT_ERROR_MESSAGE = "This account is already connected to another account. Each external account can only be linked to one account.";
@@ -14,12 +51,6 @@ var EMAIL_OTP_TYPES = [
14
51
  "email_change",
15
52
  "email"
16
53
  ];
17
- function getSafeRedirectPath(candidate, fallback) {
18
- if (candidate?.startsWith("/") && !candidate.startsWith("//")) {
19
- return candidate;
20
- }
21
- return fallback;
22
- }
23
54
  function getEmailOtpType(value) {
24
55
  if (!value) {
25
56
  return null;
@@ -110,8 +141,9 @@ function createRedirectResponse(target, origin) {
110
141
  if (target instanceof URL) {
111
142
  return server.NextResponse.redirect(target.toString());
112
143
  }
113
- if (target.startsWith("/") && !target.startsWith("//")) {
114
- return server.NextResponse.redirect(`${origin}${target}`);
144
+ const safeTarget = getSafeInternalRedirect(target, null);
145
+ if (safeTarget) {
146
+ return server.NextResponse.redirect(`${origin}${safeTarget}`);
115
147
  }
116
148
  return null;
117
149
  }
@@ -173,15 +205,20 @@ function createAuthCallbackHandler({
173
205
  isLinkingFlow = isDefaultLinkingFlow,
174
206
  resolveLinkingErrorMessage
175
207
  }) {
208
+ const safeDefaultRedirect = getSafeInternalRedirect(
209
+ defaultRedirect,
210
+ "/dashboard"
211
+ );
212
+ const safeSignInPath = getSafeInternalRedirect(signInPath, "/signin");
176
213
  return async function GET(request) {
177
214
  const requestUrl = new URL(request.url);
178
215
  const code = requestUrl.searchParams.get("code");
179
216
  const tokenHash = requestUrl.searchParams.get("token_hash");
180
217
  const rawOtpType = requestUrl.searchParams.get("type");
181
218
  const otpType = getEmailOtpType(rawOtpType);
182
- const next = getSafeRedirectPath(
219
+ const next = getSafeInternalRedirect(
183
220
  requestUrl.searchParams.get("next") ?? requestUrl.searchParams.get("redirect_to"),
184
- defaultRedirect
221
+ safeDefaultRedirect
185
222
  );
186
223
  const origin = requestUrl.origin;
187
224
  const error = requestUrl.searchParams.get("error");
@@ -203,7 +240,7 @@ function createAuthCallbackHandler({
203
240
  return linkingErrorRedirect;
204
241
  }
205
242
  return server.NextResponse.redirect(
206
- `${origin}${signInPath}?error=${encodeURIComponent(message)}`
243
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(message)}`
207
244
  );
208
245
  }
209
246
  if (code) {
@@ -218,7 +255,7 @@ function createAuthCallbackHandler({
218
255
  flow: "oauth",
219
256
  postAuthHook,
220
257
  postAuthHookErrorMode,
221
- signInPath,
258
+ signInPath: safeSignInPath,
222
259
  resolveSuccessRedirect
223
260
  });
224
261
  }
@@ -238,13 +275,13 @@ function createAuthCallbackHandler({
238
275
  return linkingErrorRedirect;
239
276
  }
240
277
  return server.NextResponse.redirect(
241
- `${origin}${signInPath}?error=${encodeURIComponent(errorMessage)}`
278
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(errorMessage)}`
242
279
  );
243
280
  }
244
281
  if (tokenHash || rawOtpType) {
245
282
  if (!tokenHash || !otpType) {
246
283
  return server.NextResponse.redirect(
247
- `${origin}${signInPath}?error=${encodeURIComponent("Invalid verification link")}`
284
+ `${origin}${safeSignInPath}?error=${encodeURIComponent("Invalid verification link")}`
248
285
  );
249
286
  }
250
287
  const supabase = await createClient();
@@ -262,16 +299,16 @@ function createAuthCallbackHandler({
262
299
  otpType,
263
300
  postAuthHook,
264
301
  postAuthHookErrorMode,
265
- signInPath,
302
+ signInPath: safeSignInPath,
266
303
  resolveSuccessRedirect
267
304
  });
268
305
  }
269
306
  return server.NextResponse.redirect(
270
- `${origin}${signInPath}?error=${encodeURIComponent(verifyError.message)}`
307
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(verifyError.message)}`
271
308
  );
272
309
  }
273
310
  return server.NextResponse.redirect(
274
- `${origin}${signInPath}?error=No authorization code received`
311
+ `${origin}${safeSignInPath}?error=No authorization code received`
275
312
  );
276
313
  };
277
314
  }