@caffeinebounce/identity 0.12.1 → 0.12.2

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.d.mts CHANGED
@@ -241,6 +241,10 @@ interface ConsentItem {
241
241
  errorMessage?: string;
242
242
  }
243
243
  interface SignupFormProps extends AuthFormConfig {
244
+ /** Optional heading override for the sign-up form */
245
+ title?: string;
246
+ /** Optional initial value for the email field */
247
+ initialEmail?: string;
244
248
  /** Navigation links configuration */
245
249
  links?: AuthLinks;
246
250
  /** Image component to use (e.g., Next.js Image) */
@@ -283,7 +287,7 @@ interface SignupFormProps extends AuthFormConfig {
283
287
  /**
284
288
  * SignupForm - Configurable sign-up form with email/password and OAuth support
285
289
  */
286
- declare function SignupForm({ createClient, logo, appName, termsUrl, privacyUrl, links, ImageComponent, LinkComponent, oauthProviders, googleComingSoon, azureComingSoon, oauthIconMonochromeOnHover, className, onAuthEvent, consentItems, consentPosition, consentSize, showHomeLink, showLogo, }: SignupFormProps): react_jsx_runtime.JSX.Element;
290
+ declare function SignupForm({ createClient, logo, appName, title, initialEmail, termsUrl, privacyUrl, links, ImageComponent, LinkComponent, oauthProviders, googleComingSoon, azureComingSoon, oauthIconMonochromeOnHover, className, onAuthEvent, consentItems, consentPosition, consentSize, showHomeLink, showLogo, }: SignupFormProps): react_jsx_runtime.JSX.Element;
287
291
 
288
292
  /**
289
293
  * MFA event logging callbacks
package/dist/index.d.ts CHANGED
@@ -241,6 +241,10 @@ interface ConsentItem {
241
241
  errorMessage?: string;
242
242
  }
243
243
  interface SignupFormProps extends AuthFormConfig {
244
+ /** Optional heading override for the sign-up form */
245
+ title?: string;
246
+ /** Optional initial value for the email field */
247
+ initialEmail?: string;
244
248
  /** Navigation links configuration */
245
249
  links?: AuthLinks;
246
250
  /** Image component to use (e.g., Next.js Image) */
@@ -283,7 +287,7 @@ interface SignupFormProps extends AuthFormConfig {
283
287
  /**
284
288
  * SignupForm - Configurable sign-up form with email/password and OAuth support
285
289
  */
286
- declare function SignupForm({ createClient, logo, appName, termsUrl, privacyUrl, links, ImageComponent, LinkComponent, oauthProviders, googleComingSoon, azureComingSoon, oauthIconMonochromeOnHover, className, onAuthEvent, consentItems, consentPosition, consentSize, showHomeLink, showLogo, }: SignupFormProps): react_jsx_runtime.JSX.Element;
290
+ declare function SignupForm({ createClient, logo, appName, title, initialEmail, termsUrl, privacyUrl, links, ImageComponent, LinkComponent, oauthProviders, googleComingSoon, azureComingSoon, oauthIconMonochromeOnHover, className, onAuthEvent, consentItems, consentPosition, consentSize, showHomeLink, showLogo, }: SignupFormProps): react_jsx_runtime.JSX.Element;
287
291
 
288
292
  /**
289
293
  * MFA event logging callbacks
package/dist/index.js CHANGED
@@ -19,6 +19,17 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
19
19
  var isEmail__default = /*#__PURE__*/_interopDefault(isEmail);
20
20
 
21
21
  // src/components/auth/EmailVerificationPending.tsx
22
+
23
+ // src/types.ts
24
+ var defaultAuthLinks = {
25
+ signIn: "/signin",
26
+ signUp: "/signup",
27
+ forgotPassword: "/forgot-password",
28
+ resetPassword: "/reset-password",
29
+ callback: "/callback",
30
+ home: "/",
31
+ defaultRedirect: "/dashboard"
32
+ };
22
33
  function AuthFormLayout({
23
34
  children,
24
35
  footer,
@@ -55,6 +66,122 @@ function AuthFormLayout({
55
66
  ] }) })
56
67
  ] });
57
68
  }
69
+
70
+ // src/utils/redirect.ts
71
+ var INTERNAL_REDIRECT_BASE = new URL("https://identity.invalid");
72
+ var ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i;
73
+ function hasControlCharacter(value) {
74
+ return Array.from(value).some((character) => {
75
+ const code = character.charCodeAt(0);
76
+ return code <= 31 || code === 127;
77
+ });
78
+ }
79
+ function isStrictInternalRedirect(candidate) {
80
+ if (!candidate?.startsWith("/") || hasControlCharacter(candidate)) {
81
+ return false;
82
+ }
83
+ const pathEnd = candidate.search(/[?#]/);
84
+ const pathname = pathEnd === -1 ? candidate : candidate.slice(0, pathEnd);
85
+ if (pathname.includes("\\") || ENCODED_PATH_SEPARATOR.test(pathname)) {
86
+ return false;
87
+ }
88
+ try {
89
+ const target = new URL(candidate, INTERNAL_REDIRECT_BASE);
90
+ return target.origin === INTERNAL_REDIRECT_BASE.origin;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+ function getSafeInternalRedirect(candidate, fallback) {
96
+ if (isStrictInternalRedirect(candidate)) {
97
+ return candidate;
98
+ }
99
+ if (fallback === null) {
100
+ return null;
101
+ }
102
+ return isStrictInternalRedirect(fallback) ? fallback : "/";
103
+ }
104
+
105
+ // src/components/auth/utils.ts
106
+ var warnedSupabaseRedirectOrigins = /* @__PURE__ */ new Set();
107
+ function hardRedirect(redirectTo) {
108
+ window.location.href = redirectTo;
109
+ }
110
+ function sanitizeAuthError(message) {
111
+ const lowerMessage = message.toLowerCase();
112
+ if (lowerMessage.includes("user already registered") || lowerMessage.includes("already registered") || lowerMessage.includes("already exists") || lowerMessage.includes("email already") || lowerMessage.includes("duplicate")) {
113
+ return "An account with this email already exists. Please sign in instead.";
114
+ }
115
+ if (lowerMessage.includes("invalid login credentials") || lowerMessage.includes("invalid credentials") || lowerMessage.includes("wrong password") || lowerMessage.includes("incorrect password")) {
116
+ return "Invalid email or password. Please try again.";
117
+ }
118
+ if (lowerMessage.includes("invalid email") || lowerMessage.includes("password")) {
119
+ return message;
120
+ }
121
+ if (message === "The string did not match the expected pattern.") {
122
+ return "Please check your email address format and try again.";
123
+ }
124
+ if (lowerMessage.includes("rate limit") || lowerMessage.includes("too many requests") || lowerMessage.includes("try again later")) {
125
+ return "Too many attempts. Please wait a moment and try again.";
126
+ }
127
+ if (lowerMessage.includes("hook") || lowerMessage.includes("authorization") || lowerMessage.includes("token") || lowerMessage.includes("internal") || lowerMessage.includes("server") || lowerMessage.includes("database") || lowerMessage.includes("connection")) {
128
+ return "An error occurred. Please try again.";
129
+ }
130
+ return "An error occurred. Please try again.";
131
+ }
132
+ function sanitizeSignupError(message) {
133
+ const sanitized = sanitizeAuthError(message);
134
+ if (sanitized === "An error occurred. Please try again.") {
135
+ return "An error occurred during sign up. Please try again.";
136
+ }
137
+ return sanitized;
138
+ }
139
+ function buildOAuthRedirectTo(origin, nextPath) {
140
+ const siteUrl = origin.replace(/\/$/, "");
141
+ return `${siteUrl}/callback?next=${encodeURIComponent(nextPath)}`;
142
+ }
143
+ function buildOAuthSignInOptions(provider, redirectTo, options = {}) {
144
+ return {
145
+ redirectTo,
146
+ ...provider === "azure" ? { scopes: "email" } : {},
147
+ ...options
148
+ };
149
+ }
150
+ function warnAboutSupabaseRedirectAllowlist(origin) {
151
+ const normalizedOrigin = origin.replace(/\/$/, "");
152
+ let hostname = "";
153
+ try {
154
+ hostname = new URL(normalizedOrigin).host;
155
+ } catch {
156
+ return;
157
+ }
158
+ if (!sharedUtils.isPreviewEnvironment(hostname)) {
159
+ return;
160
+ }
161
+ if (warnedSupabaseRedirectOrigins.has(normalizedOrigin)) {
162
+ return;
163
+ }
164
+ warnedSupabaseRedirectOrigins.add(normalizedOrigin);
165
+ console.warn(
166
+ `[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.`
167
+ );
168
+ }
169
+ function clearStalePKCEState() {
170
+ if (typeof window === "undefined") return;
171
+ try {
172
+ const keysToRemove = [];
173
+ for (let i = 0; i < localStorage.length; i++) {
174
+ const key = localStorage.key(i);
175
+ if (key?.includes("-code-verifier")) {
176
+ keysToRemove.push(key);
177
+ }
178
+ }
179
+ for (const key of keysToRemove) {
180
+ localStorage.removeItem(key);
181
+ }
182
+ } catch {
183
+ }
184
+ }
58
185
  var POLL_INTERVAL_MS = 6e4;
59
186
  var MAX_POLL_COUNT = 60;
60
187
  function EmailVerificationPending({
@@ -125,9 +252,19 @@ function EmailVerificationPending({
125
252
  setResendStatus("sending");
126
253
  try {
127
254
  const supabase = createClient();
255
+ const safeRedirectTo = getSafeInternalRedirect(
256
+ redirectTo,
257
+ defaultAuthLinks.defaultRedirect
258
+ );
128
259
  const { error } = await supabase.auth.resend({
129
260
  type: "signup",
130
- email
261
+ email,
262
+ options: {
263
+ emailRedirectTo: buildOAuthRedirectTo(
264
+ window.location.origin,
265
+ safeRedirectTo
266
+ )
267
+ }
131
268
  });
132
269
  if (error) {
133
270
  setResendStatus("error");
@@ -247,17 +384,6 @@ function EmailVerificationPending({
247
384
  }
248
385
  );
249
386
  }
250
-
251
- // src/types.ts
252
- var defaultAuthLinks = {
253
- signIn: "/signin",
254
- signUp: "/signup",
255
- forgotPassword: "/forgot-password",
256
- resetPassword: "/reset-password",
257
- callback: "/callback",
258
- home: "/",
259
- defaultRedirect: "/dashboard"
260
- };
261
387
  function AuthHeader({
262
388
  logo,
263
389
  title,
@@ -279,82 +405,6 @@ function AuthHeader({
279
405
  /* @__PURE__ */ jsxRuntime.jsx("h1", { className: "text-2xl font-semibold tracking-tight text-foreground", children: title })
280
406
  ] });
281
407
  }
282
- var warnedSupabaseRedirectOrigins = /* @__PURE__ */ new Set();
283
- function sanitizeAuthError(message) {
284
- const lowerMessage = message.toLowerCase();
285
- if (lowerMessage.includes("user already registered") || lowerMessage.includes("already registered") || lowerMessage.includes("already exists") || lowerMessage.includes("email already") || lowerMessage.includes("duplicate")) {
286
- return "An account with this email already exists. Please sign in instead.";
287
- }
288
- if (lowerMessage.includes("invalid login credentials") || lowerMessage.includes("invalid credentials") || lowerMessage.includes("wrong password") || lowerMessage.includes("incorrect password")) {
289
- return "Invalid email or password. Please try again.";
290
- }
291
- if (lowerMessage.includes("invalid email") || lowerMessage.includes("password")) {
292
- return message;
293
- }
294
- if (message === "The string did not match the expected pattern.") {
295
- return "Please check your email address format and try again.";
296
- }
297
- if (lowerMessage.includes("rate limit") || lowerMessage.includes("too many requests") || lowerMessage.includes("try again later")) {
298
- return "Too many attempts. Please wait a moment and try again.";
299
- }
300
- if (lowerMessage.includes("hook") || lowerMessage.includes("authorization") || lowerMessage.includes("token") || lowerMessage.includes("internal") || lowerMessage.includes("server") || lowerMessage.includes("database") || lowerMessage.includes("connection")) {
301
- return "An error occurred. Please try again.";
302
- }
303
- return "An error occurred. Please try again.";
304
- }
305
- function sanitizeSignupError(message) {
306
- const sanitized = sanitizeAuthError(message);
307
- if (sanitized === "An error occurred. Please try again.") {
308
- return "An error occurred during sign up. Please try again.";
309
- }
310
- return sanitized;
311
- }
312
- function buildOAuthRedirectTo(origin, nextPath) {
313
- const siteUrl = origin.replace(/\/$/, "");
314
- return `${siteUrl}/callback?next=${encodeURIComponent(nextPath)}`;
315
- }
316
- function buildOAuthSignInOptions(provider, redirectTo, options = {}) {
317
- return {
318
- redirectTo,
319
- ...provider === "azure" ? { scopes: "email" } : {},
320
- ...options
321
- };
322
- }
323
- function warnAboutSupabaseRedirectAllowlist(origin) {
324
- const normalizedOrigin = origin.replace(/\/$/, "");
325
- let hostname = "";
326
- try {
327
- hostname = new URL(normalizedOrigin).host;
328
- } catch {
329
- return;
330
- }
331
- if (!sharedUtils.isPreviewEnvironment(hostname)) {
332
- return;
333
- }
334
- if (warnedSupabaseRedirectOrigins.has(normalizedOrigin)) {
335
- return;
336
- }
337
- warnedSupabaseRedirectOrigins.add(normalizedOrigin);
338
- console.warn(
339
- `[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.`
340
- );
341
- }
342
- function clearStalePKCEState() {
343
- if (typeof window === "undefined") return;
344
- try {
345
- const keysToRemove = [];
346
- for (let i = 0; i < localStorage.length; i++) {
347
- const key = localStorage.key(i);
348
- if (key?.includes("-code-verifier")) {
349
- keysToRemove.push(key);
350
- }
351
- }
352
- for (const key of keysToRemove) {
353
- localStorage.removeItem(key);
354
- }
355
- } catch {
356
- }
357
- }
358
408
  function ForgotPasswordForm({
359
409
  createClient,
360
410
  logo,
@@ -1095,7 +1145,14 @@ function SigninForm({
1095
1145
  const oauthTimeoutRef = react.useRef(null);
1096
1146
  const router = navigation.useRouter();
1097
1147
  const searchParams = navigation.useSearchParams();
1098
- const redirectTo = searchParams.get("redirect") || mergedLinks.defaultRedirect;
1148
+ const defaultRedirect = getSafeInternalRedirect(
1149
+ mergedLinks.defaultRedirect,
1150
+ defaultAuthLinks.defaultRedirect
1151
+ );
1152
+ const redirectTo = getSafeInternalRedirect(
1153
+ searchParams.get("redirect"),
1154
+ defaultRedirect
1155
+ );
1099
1156
  const message = searchParams.get("message");
1100
1157
  const mfaRequired = searchParams.get("mfa") === "required";
1101
1158
  const [showMFA, setShowMFA] = react.useState(mfaRequired);
@@ -1267,7 +1324,7 @@ function SigninForm({
1267
1324
  } else {
1268
1325
  onAuthEvent?.onSignInSuccess?.(userId, email.trim(), false);
1269
1326
  recordSignIn("email", email.trim());
1270
- window.location.href = redirectTo;
1327
+ hardRedirect(redirectTo);
1271
1328
  }
1272
1329
  } catch (err) {
1273
1330
  logError(err, {
@@ -1427,10 +1484,13 @@ function SigninForm({
1427
1484
  }
1428
1485
  );
1429
1486
  }
1487
+ var EMPTY_CONSENT_ITEMS = [];
1430
1488
  function SignupForm({
1431
1489
  createClient,
1432
1490
  logo,
1433
1491
  appName = "your account",
1492
+ title,
1493
+ initialEmail = "",
1434
1494
  termsUrl = "/terms",
1435
1495
  privacyUrl = "/privacy",
1436
1496
  links = {},
@@ -1442,7 +1502,7 @@ function SignupForm({
1442
1502
  oauthIconMonochromeOnHover = false,
1443
1503
  className,
1444
1504
  onAuthEvent,
1445
- consentItems = [],
1505
+ consentItems = EMPTY_CONSENT_ITEMS,
1446
1506
  consentPosition = "above",
1447
1507
  consentSize = "default",
1448
1508
  showHomeLink = true,
@@ -1452,7 +1512,11 @@ function SignupForm({
1452
1512
  const Link = LinkComponent;
1453
1513
  const Image = ImageComponent;
1454
1514
  const router = navigation.useRouter();
1455
- const [email, setEmail] = react.useState("");
1515
+ const redirectTo = getSafeInternalRedirect(
1516
+ mergedLinks.defaultRedirect,
1517
+ defaultAuthLinks.defaultRedirect
1518
+ );
1519
+ const [email, setEmail] = react.useState(initialEmail);
1456
1520
  const [emailTouched, setEmailTouched] = react.useState(false);
1457
1521
  const [password, setPassword] = react.useState("");
1458
1522
  const [confirmPassword, setConfirmPassword] = react.useState("");
@@ -1517,7 +1581,7 @@ function SignupForm({
1517
1581
  provider,
1518
1582
  options: buildOAuthSignInOptions(
1519
1583
  provider,
1520
- buildOAuthRedirectTo(siteUrl, mergedLinks.defaultRedirect)
1584
+ buildOAuthRedirectTo(siteUrl, redirectTo)
1521
1585
  )
1522
1586
  });
1523
1587
  if (error) {
@@ -1552,7 +1616,7 @@ function SignupForm({
1552
1616
  email: email.trim(),
1553
1617
  password,
1554
1618
  options: {
1555
- emailRedirectTo: `${siteUrl}/callback`,
1619
+ emailRedirectTo: buildOAuthRedirectTo(siteUrl, redirectTo),
1556
1620
  // Pass all consent values as user metadata
1557
1621
  data: consentState
1558
1622
  }
@@ -1570,7 +1634,7 @@ function SignupForm({
1570
1634
  } else if (data.user) {
1571
1635
  const userId = data.user.id;
1572
1636
  onAuthEvent?.onSignUpSuccess?.(userId, email.trim());
1573
- router.push(mergedLinks.defaultRedirect);
1637
+ router.push(redirectTo);
1574
1638
  router.refresh();
1575
1639
  } else {
1576
1640
  onAuthEvent?.onSignUpFailure?.(email.trim(), "no_user_data_in_response");
@@ -1586,7 +1650,7 @@ function SignupForm({
1586
1650
  logo,
1587
1651
  ImageComponent: Image,
1588
1652
  createClient,
1589
- redirectTo: mergedLinks.home,
1653
+ redirectTo,
1590
1654
  className
1591
1655
  }
1592
1656
  );
@@ -1608,7 +1672,7 @@ function SignupForm({
1608
1672
  AuthHeader,
1609
1673
  {
1610
1674
  logo: showLogo !== false ? logo : void 0,
1611
- title: `Sign up for ${appName}`,
1675
+ title: title ?? `Sign up for ${appName}`,
1612
1676
  ImageComponent: Image
1613
1677
  }
1614
1678
  ),
@@ -5535,12 +5599,6 @@ var EMAIL_OTP_TYPES = [
5535
5599
  "email_change",
5536
5600
  "email"
5537
5601
  ];
5538
- function getSafeRedirectPath(candidate, fallback) {
5539
- if (candidate?.startsWith("/") && !candidate.startsWith("//")) {
5540
- return candidate;
5541
- }
5542
- return fallback;
5543
- }
5544
5602
  function getEmailOtpType(value) {
5545
5603
  if (!value) {
5546
5604
  return null;
@@ -5631,8 +5689,9 @@ function createRedirectResponse(target, origin) {
5631
5689
  if (target instanceof URL) {
5632
5690
  return server.NextResponse.redirect(target.toString());
5633
5691
  }
5634
- if (target.startsWith("/") && !target.startsWith("//")) {
5635
- return server.NextResponse.redirect(`${origin}${target}`);
5692
+ const safeTarget = getSafeInternalRedirect(target, null);
5693
+ if (safeTarget) {
5694
+ return server.NextResponse.redirect(`${origin}${safeTarget}`);
5636
5695
  }
5637
5696
  return null;
5638
5697
  }
@@ -5694,15 +5753,20 @@ function createAuthCallbackHandler({
5694
5753
  isLinkingFlow = isDefaultLinkingFlow,
5695
5754
  resolveLinkingErrorMessage
5696
5755
  }) {
5756
+ const safeDefaultRedirect = getSafeInternalRedirect(
5757
+ defaultRedirect,
5758
+ "/dashboard"
5759
+ );
5760
+ const safeSignInPath = getSafeInternalRedirect(signInPath, "/signin");
5697
5761
  return async function GET(request) {
5698
5762
  const requestUrl = new URL(request.url);
5699
5763
  const code = requestUrl.searchParams.get("code");
5700
5764
  const tokenHash = requestUrl.searchParams.get("token_hash");
5701
5765
  const rawOtpType = requestUrl.searchParams.get("type");
5702
5766
  const otpType = getEmailOtpType(rawOtpType);
5703
- const next = getSafeRedirectPath(
5767
+ const next = getSafeInternalRedirect(
5704
5768
  requestUrl.searchParams.get("next") ?? requestUrl.searchParams.get("redirect_to"),
5705
- defaultRedirect
5769
+ safeDefaultRedirect
5706
5770
  );
5707
5771
  const origin = requestUrl.origin;
5708
5772
  const error = requestUrl.searchParams.get("error");
@@ -5724,7 +5788,7 @@ function createAuthCallbackHandler({
5724
5788
  return linkingErrorRedirect;
5725
5789
  }
5726
5790
  return server.NextResponse.redirect(
5727
- `${origin}${signInPath}?error=${encodeURIComponent(message)}`
5791
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(message)}`
5728
5792
  );
5729
5793
  }
5730
5794
  if (code) {
@@ -5739,7 +5803,7 @@ function createAuthCallbackHandler({
5739
5803
  flow: "oauth",
5740
5804
  postAuthHook,
5741
5805
  postAuthHookErrorMode,
5742
- signInPath,
5806
+ signInPath: safeSignInPath,
5743
5807
  resolveSuccessRedirect
5744
5808
  });
5745
5809
  }
@@ -5759,13 +5823,13 @@ function createAuthCallbackHandler({
5759
5823
  return linkingErrorRedirect;
5760
5824
  }
5761
5825
  return server.NextResponse.redirect(
5762
- `${origin}${signInPath}?error=${encodeURIComponent(errorMessage)}`
5826
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(errorMessage)}`
5763
5827
  );
5764
5828
  }
5765
5829
  if (tokenHash || rawOtpType) {
5766
5830
  if (!tokenHash || !otpType) {
5767
5831
  return server.NextResponse.redirect(
5768
- `${origin}${signInPath}?error=${encodeURIComponent("Invalid verification link")}`
5832
+ `${origin}${safeSignInPath}?error=${encodeURIComponent("Invalid verification link")}`
5769
5833
  );
5770
5834
  }
5771
5835
  const supabase = await createClient();
@@ -5783,16 +5847,16 @@ function createAuthCallbackHandler({
5783
5847
  otpType,
5784
5848
  postAuthHook,
5785
5849
  postAuthHookErrorMode,
5786
- signInPath,
5850
+ signInPath: safeSignInPath,
5787
5851
  resolveSuccessRedirect
5788
5852
  });
5789
5853
  }
5790
5854
  return server.NextResponse.redirect(
5791
- `${origin}${signInPath}?error=${encodeURIComponent(verifyError.message)}`
5855
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(verifyError.message)}`
5792
5856
  );
5793
5857
  }
5794
5858
  return server.NextResponse.redirect(
5795
- `${origin}${signInPath}?error=No authorization code received`
5859
+ `${origin}${safeSignInPath}?error=No authorization code received`
5796
5860
  );
5797
5861
  };
5798
5862
  }
package/dist/index.mjs CHANGED
@@ -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
  ),
@@ -5530,12 +5594,6 @@ var EMAIL_OTP_TYPES = [
5530
5594
  "email_change",
5531
5595
  "email"
5532
5596
  ];
5533
- function getSafeRedirectPath(candidate, fallback) {
5534
- if (candidate?.startsWith("/") && !candidate.startsWith("//")) {
5535
- return candidate;
5536
- }
5537
- return fallback;
5538
- }
5539
5597
  function getEmailOtpType(value) {
5540
5598
  if (!value) {
5541
5599
  return null;
@@ -5626,8 +5684,9 @@ function createRedirectResponse(target, origin) {
5626
5684
  if (target instanceof URL) {
5627
5685
  return NextResponse.redirect(target.toString());
5628
5686
  }
5629
- if (target.startsWith("/") && !target.startsWith("//")) {
5630
- return NextResponse.redirect(`${origin}${target}`);
5687
+ const safeTarget = getSafeInternalRedirect(target, null);
5688
+ if (safeTarget) {
5689
+ return NextResponse.redirect(`${origin}${safeTarget}`);
5631
5690
  }
5632
5691
  return null;
5633
5692
  }
@@ -5689,15 +5748,20 @@ function createAuthCallbackHandler({
5689
5748
  isLinkingFlow = isDefaultLinkingFlow,
5690
5749
  resolveLinkingErrorMessage
5691
5750
  }) {
5751
+ const safeDefaultRedirect = getSafeInternalRedirect(
5752
+ defaultRedirect,
5753
+ "/dashboard"
5754
+ );
5755
+ const safeSignInPath = getSafeInternalRedirect(signInPath, "/signin");
5692
5756
  return async function GET(request) {
5693
5757
  const requestUrl = new URL(request.url);
5694
5758
  const code = requestUrl.searchParams.get("code");
5695
5759
  const tokenHash = requestUrl.searchParams.get("token_hash");
5696
5760
  const rawOtpType = requestUrl.searchParams.get("type");
5697
5761
  const otpType = getEmailOtpType(rawOtpType);
5698
- const next = getSafeRedirectPath(
5762
+ const next = getSafeInternalRedirect(
5699
5763
  requestUrl.searchParams.get("next") ?? requestUrl.searchParams.get("redirect_to"),
5700
- defaultRedirect
5764
+ safeDefaultRedirect
5701
5765
  );
5702
5766
  const origin = requestUrl.origin;
5703
5767
  const error = requestUrl.searchParams.get("error");
@@ -5719,7 +5783,7 @@ function createAuthCallbackHandler({
5719
5783
  return linkingErrorRedirect;
5720
5784
  }
5721
5785
  return NextResponse.redirect(
5722
- `${origin}${signInPath}?error=${encodeURIComponent(message)}`
5786
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(message)}`
5723
5787
  );
5724
5788
  }
5725
5789
  if (code) {
@@ -5734,7 +5798,7 @@ function createAuthCallbackHandler({
5734
5798
  flow: "oauth",
5735
5799
  postAuthHook,
5736
5800
  postAuthHookErrorMode,
5737
- signInPath,
5801
+ signInPath: safeSignInPath,
5738
5802
  resolveSuccessRedirect
5739
5803
  });
5740
5804
  }
@@ -5754,13 +5818,13 @@ function createAuthCallbackHandler({
5754
5818
  return linkingErrorRedirect;
5755
5819
  }
5756
5820
  return NextResponse.redirect(
5757
- `${origin}${signInPath}?error=${encodeURIComponent(errorMessage)}`
5821
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(errorMessage)}`
5758
5822
  );
5759
5823
  }
5760
5824
  if (tokenHash || rawOtpType) {
5761
5825
  if (!tokenHash || !otpType) {
5762
5826
  return NextResponse.redirect(
5763
- `${origin}${signInPath}?error=${encodeURIComponent("Invalid verification link")}`
5827
+ `${origin}${safeSignInPath}?error=${encodeURIComponent("Invalid verification link")}`
5764
5828
  );
5765
5829
  }
5766
5830
  const supabase = await createClient();
@@ -5778,16 +5842,16 @@ function createAuthCallbackHandler({
5778
5842
  otpType,
5779
5843
  postAuthHook,
5780
5844
  postAuthHookErrorMode,
5781
- signInPath,
5845
+ signInPath: safeSignInPath,
5782
5846
  resolveSuccessRedirect
5783
5847
  });
5784
5848
  }
5785
5849
  return NextResponse.redirect(
5786
- `${origin}${signInPath}?error=${encodeURIComponent(verifyError.message)}`
5850
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(verifyError.message)}`
5787
5851
  );
5788
5852
  }
5789
5853
  return NextResponse.redirect(
5790
- `${origin}${signInPath}?error=No authorization code received`
5854
+ `${origin}${safeSignInPath}?error=No authorization code received`
5791
5855
  );
5792
5856
  };
5793
5857
  }
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
  }
package/dist/server.mjs CHANGED
@@ -1,6 +1,43 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  export { generateSecureToken, getClientIP, getGeolocationFromIP, hashString } from '@caffeinebounce/shared-utils';
3
3
 
4
+ // src/handlers/callback.ts
5
+
6
+ // src/utils/redirect.ts
7
+ var INTERNAL_REDIRECT_BASE = new URL("https://identity.invalid");
8
+ var ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i;
9
+ function hasControlCharacter(value) {
10
+ return Array.from(value).some((character) => {
11
+ const code = character.charCodeAt(0);
12
+ return code <= 31 || code === 127;
13
+ });
14
+ }
15
+ function isStrictInternalRedirect(candidate) {
16
+ if (!candidate?.startsWith("/") || hasControlCharacter(candidate)) {
17
+ return false;
18
+ }
19
+ const pathEnd = candidate.search(/[?#]/);
20
+ const pathname = pathEnd === -1 ? candidate : candidate.slice(0, pathEnd);
21
+ if (pathname.includes("\\") || ENCODED_PATH_SEPARATOR.test(pathname)) {
22
+ return false;
23
+ }
24
+ try {
25
+ const target = new URL(candidate, INTERNAL_REDIRECT_BASE);
26
+ return target.origin === INTERNAL_REDIRECT_BASE.origin;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+ function getSafeInternalRedirect(candidate, fallback) {
32
+ if (isStrictInternalRedirect(candidate)) {
33
+ return candidate;
34
+ }
35
+ if (fallback === null) {
36
+ return null;
37
+ }
38
+ return isStrictInternalRedirect(fallback) ? fallback : "/";
39
+ }
40
+
4
41
  // src/handlers/callback.ts
5
42
  var POST_AUTH_HOOK_ERROR_MESSAGE = "Authentication completed, but setup failed. Please try again.";
6
43
  var DEFAULT_LINKING_ACCOUNT_ERROR_MESSAGE = "This account is already connected to another account. Each external account can only be linked to one account.";
@@ -12,12 +49,6 @@ var EMAIL_OTP_TYPES = [
12
49
  "email_change",
13
50
  "email"
14
51
  ];
15
- function getSafeRedirectPath(candidate, fallback) {
16
- if (candidate?.startsWith("/") && !candidate.startsWith("//")) {
17
- return candidate;
18
- }
19
- return fallback;
20
- }
21
52
  function getEmailOtpType(value) {
22
53
  if (!value) {
23
54
  return null;
@@ -108,8 +139,9 @@ function createRedirectResponse(target, origin) {
108
139
  if (target instanceof URL) {
109
140
  return NextResponse.redirect(target.toString());
110
141
  }
111
- if (target.startsWith("/") && !target.startsWith("//")) {
112
- return NextResponse.redirect(`${origin}${target}`);
142
+ const safeTarget = getSafeInternalRedirect(target, null);
143
+ if (safeTarget) {
144
+ return NextResponse.redirect(`${origin}${safeTarget}`);
113
145
  }
114
146
  return null;
115
147
  }
@@ -171,15 +203,20 @@ function createAuthCallbackHandler({
171
203
  isLinkingFlow = isDefaultLinkingFlow,
172
204
  resolveLinkingErrorMessage
173
205
  }) {
206
+ const safeDefaultRedirect = getSafeInternalRedirect(
207
+ defaultRedirect,
208
+ "/dashboard"
209
+ );
210
+ const safeSignInPath = getSafeInternalRedirect(signInPath, "/signin");
174
211
  return async function GET(request) {
175
212
  const requestUrl = new URL(request.url);
176
213
  const code = requestUrl.searchParams.get("code");
177
214
  const tokenHash = requestUrl.searchParams.get("token_hash");
178
215
  const rawOtpType = requestUrl.searchParams.get("type");
179
216
  const otpType = getEmailOtpType(rawOtpType);
180
- const next = getSafeRedirectPath(
217
+ const next = getSafeInternalRedirect(
181
218
  requestUrl.searchParams.get("next") ?? requestUrl.searchParams.get("redirect_to"),
182
- defaultRedirect
219
+ safeDefaultRedirect
183
220
  );
184
221
  const origin = requestUrl.origin;
185
222
  const error = requestUrl.searchParams.get("error");
@@ -201,7 +238,7 @@ function createAuthCallbackHandler({
201
238
  return linkingErrorRedirect;
202
239
  }
203
240
  return NextResponse.redirect(
204
- `${origin}${signInPath}?error=${encodeURIComponent(message)}`
241
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(message)}`
205
242
  );
206
243
  }
207
244
  if (code) {
@@ -216,7 +253,7 @@ function createAuthCallbackHandler({
216
253
  flow: "oauth",
217
254
  postAuthHook,
218
255
  postAuthHookErrorMode,
219
- signInPath,
256
+ signInPath: safeSignInPath,
220
257
  resolveSuccessRedirect
221
258
  });
222
259
  }
@@ -236,13 +273,13 @@ function createAuthCallbackHandler({
236
273
  return linkingErrorRedirect;
237
274
  }
238
275
  return NextResponse.redirect(
239
- `${origin}${signInPath}?error=${encodeURIComponent(errorMessage)}`
276
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(errorMessage)}`
240
277
  );
241
278
  }
242
279
  if (tokenHash || rawOtpType) {
243
280
  if (!tokenHash || !otpType) {
244
281
  return NextResponse.redirect(
245
- `${origin}${signInPath}?error=${encodeURIComponent("Invalid verification link")}`
282
+ `${origin}${safeSignInPath}?error=${encodeURIComponent("Invalid verification link")}`
246
283
  );
247
284
  }
248
285
  const supabase = await createClient();
@@ -260,16 +297,16 @@ function createAuthCallbackHandler({
260
297
  otpType,
261
298
  postAuthHook,
262
299
  postAuthHookErrorMode,
263
- signInPath,
300
+ signInPath: safeSignInPath,
264
301
  resolveSuccessRedirect
265
302
  });
266
303
  }
267
304
  return NextResponse.redirect(
268
- `${origin}${signInPath}?error=${encodeURIComponent(verifyError.message)}`
305
+ `${origin}${safeSignInPath}?error=${encodeURIComponent(verifyError.message)}`
269
306
  );
270
307
  }
271
308
  return NextResponse.redirect(
272
- `${origin}${signInPath}?error=No authorization code received`
309
+ `${origin}${safeSignInPath}?error=No authorization code received`
273
310
  );
274
311
  };
275
312
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caffeinebounce/identity",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "Authentication components and handlers for Caffeine Bounce projects",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -61,7 +61,7 @@
61
61
  "dependencies": {
62
62
  "@caffeinebounce/logger": "^0.10.0",
63
63
  "@caffeinebounce/shared-utils": "^0.7.136",
64
- "@caffeinebounce/ui": "^0.62.2",
64
+ "@caffeinebounce/ui": "^0.62.3",
65
65
  "@supabase/ssr": "^0.8.0",
66
66
  "@supabase/supabase-js": "^2.49.4",
67
67
  "input-otp": "^1.4.2",