cloudflare-next-intl 0.6.18 → 0.6.20
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/src/config/init_config.js +8 -1
- package/dist/src/firebase_auth/client/auth_user_provider.js +2 -1
- package/dist/src/firebase_auth/is_whitelisted.d.ts +8 -0
- package/dist/src/firebase_auth/is_whitelisted.js +12 -0
- package/dist/src/firebase_auth/middleware/update_session.d.ts +1 -0
- package/dist/src/firebase_auth/middleware/update_session.js +46 -1
- package/dist/src/server/components/helper_script.bench.d.ts +1 -0
- package/dist/src/server/components/helper_script.bench.js +73 -0
- package/dist/src/types/types.d.ts +42 -0
- package/dist/vitest.bench.config.d.ts +2 -0
- package/dist/vitest.bench.config.js +18 -0
- package/package.json +1 -1
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
// Auto-prepending `/` here fixes the common typo (`'login'` instead of
|
|
8
8
|
// `'/login'`) at the source, for every consumer, instead of requiring each
|
|
9
9
|
// one to notice and fix it themselves.
|
|
10
|
-
const FIREBASE_AUTH_PATH_FIELDS = [
|
|
10
|
+
const FIREBASE_AUTH_PATH_FIELDS = [
|
|
11
|
+
'redirectAuthPath',
|
|
12
|
+
'homePath',
|
|
13
|
+
'verifyEmailPath',
|
|
14
|
+
'resetPasswordPath',
|
|
15
|
+
'recoverEmailPath',
|
|
16
|
+
'actionLinkPath',
|
|
17
|
+
];
|
|
11
18
|
function normalizeFirebaseAuthPaths(config) {
|
|
12
19
|
const fa = config.firebaseAuth;
|
|
13
20
|
if (!fa)
|
|
@@ -9,6 +9,7 @@ import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client'
|
|
|
9
9
|
import { setAuthUserCache } from './auth_user_cache';
|
|
10
10
|
import { defaultEmailVerifiedHintCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
|
|
11
11
|
import decodeJwtPayload from '../decode_jwt_payload';
|
|
12
|
+
import isWhitelisted from '../is_whitelisted';
|
|
12
13
|
import setCookie from '../../client/functions/set_cookie';
|
|
13
14
|
import getCookie from '../../client/functions/get_cookie';
|
|
14
15
|
import clearSessionAction from '../server/clear_session_action';
|
|
@@ -87,7 +88,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
87
88
|
const router = useRouter();
|
|
88
89
|
const pathname = usePathname();
|
|
89
90
|
const isAuthPage = fa.isAuthPath(pathname);
|
|
90
|
-
const isWhiteListed = fa.whiteListPaths
|
|
91
|
+
const isWhiteListed = isWhitelisted(pathname, fa.whiteListPaths);
|
|
91
92
|
const maxAge = fa.sessionCookieMaxAge ?? 60 * 60 * 24 * 5;
|
|
92
93
|
const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
|
|
93
94
|
const refreshTokenMaxAge = fa.refreshTokenCookieMaxAge ?? 60 * 60 * 24 * 365;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `path` is exempt from auth redirects under `whiteListPaths`.
|
|
3
|
+
* Matches an entry exactly, OR as a path-segment prefix (`/bonds` also
|
|
4
|
+
* covers `/bonds/some-slug`, but NOT `/bonds-extra`) — a plain
|
|
5
|
+
* `startsWith` would let a differently-named sibling route slip through
|
|
6
|
+
* whenever one route's name happens to prefix another's.
|
|
7
|
+
*/
|
|
8
|
+
export default function isWhitelisted(path: string, whiteListPaths: readonly string[] | undefined): boolean;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `path` is exempt from auth redirects under `whiteListPaths`.
|
|
3
|
+
* Matches an entry exactly, OR as a path-segment prefix (`/bonds` also
|
|
4
|
+
* covers `/bonds/some-slug`, but NOT `/bonds-extra`) — a plain
|
|
5
|
+
* `startsWith` would let a differently-named sibling route slip through
|
|
6
|
+
* whenever one route's name happens to prefix another's.
|
|
7
|
+
*/
|
|
8
|
+
export default function isWhitelisted(path, whiteListPaths) {
|
|
9
|
+
if (!whiteListPaths)
|
|
10
|
+
return false;
|
|
11
|
+
return whiteListPaths.some((entry) => path === entry || path.startsWith(`${entry}/`));
|
|
12
|
+
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server';
|
|
|
2
2
|
export declare const defaultSessionCookieName = "__fa_session__";
|
|
3
3
|
export declare const defaultRefreshTokenCookieName = "__fa_refresh_token__";
|
|
4
4
|
export declare const defaultEmailVerifiedHintCookieName = "__fa_email_verified_hint__";
|
|
5
|
+
export declare const defaultResetPasswordPath = "/reset-password";
|
|
5
6
|
/**
|
|
6
7
|
* Layers Firebase session-cookie validation/refresh and auth redirects onto
|
|
7
8
|
* an already-built middleware response. Called internally by `intlMiddleware`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import config from '@intl-config';
|
|
3
3
|
import decodeJwtPayload from '../decode_jwt_payload';
|
|
4
|
+
import isWhitelisted from '../is_whitelisted';
|
|
4
5
|
export const defaultSessionCookieName = '__fa_session__';
|
|
5
6
|
export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
|
|
6
7
|
// Non-httpOnly: written by AuthUserProvider (client) every time it observes
|
|
@@ -10,6 +11,24 @@ export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
|
|
|
10
11
|
// — force one refresh). Readable client-side is fine: it carries no secret,
|
|
11
12
|
// only a boolean mirror of a claim already inside the session JWT.
|
|
12
13
|
export const defaultEmailVerifiedHintCookieName = '__fa_email_verified_hint__';
|
|
14
|
+
export const defaultResetPasswordPath = '/reset-password';
|
|
15
|
+
/**
|
|
16
|
+
* Firebase's console exposes ONE project-wide action URL, so every email
|
|
17
|
+
* template (password reset, email verification, email recovery) lands on that
|
|
18
|
+
* same URL and distinguishes itself only by `?mode=`. This maps those raw
|
|
19
|
+
* `mode` values onto the app's own pages so each link reaches the page that
|
|
20
|
+
* knows how to consume its `oobCode`.
|
|
21
|
+
*/
|
|
22
|
+
function resolveActionModePaths(fa) {
|
|
23
|
+
const paths = {
|
|
24
|
+
resetPassword: fa.resetPasswordPath ?? defaultResetPasswordPath,
|
|
25
|
+
};
|
|
26
|
+
if (fa.verifyEmailPath)
|
|
27
|
+
paths.verifyEmail = fa.verifyEmailPath;
|
|
28
|
+
if (fa.recoverEmailPath)
|
|
29
|
+
paths.recoverEmail = fa.recoverEmailPath;
|
|
30
|
+
return { ...paths, ...fa.actionModePaths };
|
|
31
|
+
}
|
|
13
32
|
const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
|
|
14
33
|
const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
|
|
15
34
|
// Refresh slightly before the real expiry — treating a token as expired
|
|
@@ -175,7 +194,33 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
175
194
|
}
|
|
176
195
|
const localePrefix = locale === config.defaultLocale ? '' : requestPrefix;
|
|
177
196
|
const localeUrl = (target) => new URL(`${localePrefix}${target === '/' ? '' : target}` || '/', request.url);
|
|
178
|
-
|
|
197
|
+
// Emailed Firebase action links all arrive on the single project-wide
|
|
198
|
+
// action URL carrying `?mode=<action>&oobCode=...`. Forward them to the
|
|
199
|
+
// page for that mode BEFORE any auth/whitelist check below: these links
|
|
200
|
+
// are followed by users who are typically signed OUT (a password reset,
|
|
201
|
+
// or verification opened in another browser), so letting the guest
|
|
202
|
+
// redirect run first would bounce them to `redirectAuthPath` and discard
|
|
203
|
+
// the `oobCode` they came to spend. The whole query string is preserved
|
|
204
|
+
// so the destination page still receives `oobCode`/`continueUrl`/`lang`.
|
|
205
|
+
// When `actionLinkPath` is set, only requests to that exact (locale-
|
|
206
|
+
// stripped) path are eligible — matches a Firebase Console action URL
|
|
207
|
+
// pinned to one static path (e.g. "https://example.com/auth/action")
|
|
208
|
+
// rather than the bare domain root.
|
|
209
|
+
const isEligibleActionPath = !fa.actionLinkPath || path === fa.actionLinkPath;
|
|
210
|
+
if (fa.actionLinkRedirectEnabled !== false && isEligibleActionPath) {
|
|
211
|
+
const mode = request.nextUrl.searchParams.get('mode');
|
|
212
|
+
if (mode) {
|
|
213
|
+
const target = resolveActionModePaths(fa)[mode];
|
|
214
|
+
// Skip when already on the destination — the forward sets the same
|
|
215
|
+
// `?mode=` it matched on, so redirecting again would loop forever.
|
|
216
|
+
if (target && target !== path) {
|
|
217
|
+
const url = localeUrl(target);
|
|
218
|
+
url.search = request.nextUrl.search;
|
|
219
|
+
return buildRedirect(baseResponse, url);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const isWhiteListed = isWhitelisted(path, fa.whiteListPaths);
|
|
179
224
|
if (isWhiteListed)
|
|
180
225
|
return baseResponse;
|
|
181
226
|
const isAuthPage = fa.isAuthPath(path);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { bench, describe } from 'vitest';
|
|
2
|
+
import { isDarkCookieKey } from '../../config/cookie_key';
|
|
3
|
+
const getCookieRegex = (name) => {
|
|
4
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
|
5
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
6
|
+
};
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
const getCookieCachedRegex = (name) => {
|
|
9
|
+
let re = cache.get(name);
|
|
10
|
+
if (!re) {
|
|
11
|
+
re = new RegExp(`(?:^|; )${name}=([^;]*)`);
|
|
12
|
+
cache.set(name, re);
|
|
13
|
+
}
|
|
14
|
+
const match = document.cookie.match(re);
|
|
15
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
16
|
+
};
|
|
17
|
+
const getCookieIndexOf = (name) => {
|
|
18
|
+
const raw = document.cookie;
|
|
19
|
+
const key = name + '=';
|
|
20
|
+
let i = raw.indexOf(key);
|
|
21
|
+
while (i !== -1) {
|
|
22
|
+
if (i === 0 || (raw[i - 1] === ' ' && raw[i - 2] === ';')) {
|
|
23
|
+
const end = raw.indexOf(';', i);
|
|
24
|
+
return raw.slice(i + key.length, end === -1 ? undefined : end);
|
|
25
|
+
}
|
|
26
|
+
i = raw.indexOf(key, i + 1);
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
document.cookie = `${isDarkCookieKey}=true`;
|
|
31
|
+
document.cookie = 'NEXT_LOCALE=uk';
|
|
32
|
+
document.cookie = 'session=abcdefghijklmnop';
|
|
33
|
+
describe('cookie read strategies', () => {
|
|
34
|
+
bench('regex constructed per call (current)', () => {
|
|
35
|
+
getCookieRegex(isDarkCookieKey);
|
|
36
|
+
});
|
|
37
|
+
bench('regex cached', () => {
|
|
38
|
+
getCookieCachedRegex(isDarkCookieKey);
|
|
39
|
+
});
|
|
40
|
+
bench('indexOf scan, no decode', () => {
|
|
41
|
+
getCookieIndexOf(isDarkCookieKey);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('theme apply', () => {
|
|
45
|
+
bench('guarded toggle (current)', () => {
|
|
46
|
+
const classList = document.documentElement.classList;
|
|
47
|
+
const isDark = true;
|
|
48
|
+
if (classList.contains('dark') !== isDark) {
|
|
49
|
+
classList.toggle('dark', isDark);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
bench('unguarded toggle', () => {
|
|
53
|
+
document.documentElement.classList.toggle('dark', true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('full inline script path', () => {
|
|
57
|
+
bench('current: read cookie + guarded toggle', () => {
|
|
58
|
+
const isDark = getCookieRegex(isDarkCookieKey);
|
|
59
|
+
const classList = document.documentElement.classList;
|
|
60
|
+
const want = isDark === 'true';
|
|
61
|
+
if (classList.contains('dark') !== want) {
|
|
62
|
+
classList.toggle('dark', want);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
bench('optimized: indexOf + guarded toggle', () => {
|
|
66
|
+
const isDark = getCookieIndexOf(isDarkCookieKey);
|
|
67
|
+
const classList = document.documentElement.classList;
|
|
68
|
+
const want = isDark === 'true';
|
|
69
|
+
if (classList.contains('dark') !== want) {
|
|
70
|
+
classList.toggle('dark', want);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -446,6 +446,48 @@ export interface FirebaseAuthRoutingConfig {
|
|
|
446
446
|
homePath: string;
|
|
447
447
|
/** Path to redirect unverified-email users to. Omit to skip email-verification redirects. Must start with "/" — `setIntlConfig` auto-corrects a missing leading slash with a warning. */
|
|
448
448
|
verifyEmailPath?: string;
|
|
449
|
+
/**
|
|
450
|
+
* Path handling an emailed password-reset link. Firebase allows only ONE
|
|
451
|
+
* project-wide action URL, so every template lands on the same URL with
|
|
452
|
+
* a `?mode=` query param; the middleware reads that param and forwards
|
|
453
|
+
* the request (query string intact, `oobCode` included) to the path for
|
|
454
|
+
* that mode. Defaults to `'/reset-password'`. Must start with "/" —
|
|
455
|
+
* `setIntlConfig` auto-corrects a missing leading slash with a warning.
|
|
456
|
+
*/
|
|
457
|
+
resetPasswordPath?: string;
|
|
458
|
+
/**
|
|
459
|
+
* Path handling an emailed `recoverEmail` action link (undo an email
|
|
460
|
+
* change). Omit to leave that mode unhandled — the request then falls
|
|
461
|
+
* through to normal routing instead of being forwarded. Must start with
|
|
462
|
+
* "/" — `setIntlConfig` auto-corrects a missing leading slash with a
|
|
463
|
+
* warning.
|
|
464
|
+
*/
|
|
465
|
+
recoverEmailPath?: string;
|
|
466
|
+
/**
|
|
467
|
+
* Extra/overriding `?mode=` → path entries for the emailed-action-link
|
|
468
|
+
* forward described on {@link resetPasswordPath}. Merged over the
|
|
469
|
+
* defaults derived from `resetPasswordPath`/`verifyEmailPath`/
|
|
470
|
+
* `recoverEmailPath`, so this is how you handle a mode this config has
|
|
471
|
+
* no dedicated field for (e.g. `verifyAndChangeEmail`) or point one of
|
|
472
|
+
* the known modes somewhere else. Keys are raw Firebase `mode` values.
|
|
473
|
+
*/
|
|
474
|
+
actionModePaths?: Readonly<Record<string, string>>;
|
|
475
|
+
/**
|
|
476
|
+
* Set `false` to disable the emailed-action-link forward entirely (see
|
|
477
|
+
* {@link resetPasswordPath}) and let `?mode=` URLs route normally.
|
|
478
|
+
* Defaults to `true`.
|
|
479
|
+
*/
|
|
480
|
+
actionLinkRedirectEnabled?: boolean;
|
|
481
|
+
/**
|
|
482
|
+
* Restricts the emailed-action-link forward (see {@link resetPasswordPath})
|
|
483
|
+
* to this exact static path — set this to whatever path your Firebase
|
|
484
|
+
* Console "action URL" is pinned to (e.g. `'/auth/action'`) so a `?mode=`
|
|
485
|
+
* on any other page is left alone instead of being treated as an action
|
|
486
|
+
* link. Omit to match Firebase's bare-domain-root default: any path
|
|
487
|
+
* carrying `?mode=` is eligible. Must start with "/" — `setIntlConfig`
|
|
488
|
+
* auto-corrects a missing leading slash with a warning.
|
|
489
|
+
*/
|
|
490
|
+
actionLinkPath?: string;
|
|
449
491
|
/** Returns true if the given (locale-stripped) path is an auth page (login/signup/etc). */
|
|
450
492
|
isAuthPath: (path: string) => boolean;
|
|
451
493
|
/** Locale-stripped paths exempt from all auth redirects (e.g. public marketing pages). */
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
environment: 'jsdom',
|
|
6
|
+
setupFiles: ['./vitest.setup.ts'],
|
|
7
|
+
benchmark: {
|
|
8
|
+
include: ['src/server/components/helper_script.bench.ts'],
|
|
9
|
+
outputJson: '/private/tmp/claude-501/-Volumes-External-own-projects-cloudflare-next-intl/5816d729-594e-4dc8-b38e-1378f1a159bd/scratchpad/bres.json',
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
resolve: {
|
|
13
|
+
alias: {
|
|
14
|
+
'@intl-config': path.resolve(__dirname, './src/test_utils/mock_intl_config.ts'),
|
|
15
|
+
'@locale-file': path.resolve(__dirname, './src/test_utils/mock_locale_file'),
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|