cloudflare-next-intl 0.8.35 → 0.8.37
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/README.md +1 -0
- package/dist/src/firebase_auth/client/auth_user_provider.js +15 -2
- package/dist/src/firebase_auth/client/firebase_client.d.ts +18 -7
- package/dist/src/firebase_auth/client/firebase_client.js +133 -5
- package/dist/src/server/components/helper_script.d.ts +4 -0
- package/dist/src/server/components/helper_script.js +8 -1
- package/dist/src/types/types.d.ts +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -304,6 +304,7 @@ Features include:
|
|
|
304
304
|
- `createForgotPasswordAction(locale, actionCodeSettings?)`: Accepts optional Firebase `AuthActionCodeSettings` (e.g. `url` redirect link).
|
|
305
305
|
- `sendVerificationEmail(actionCodeSettings?)` on `useAuthUser()`: Custom action email settings when resending email verification.
|
|
306
306
|
- `followSameOriginContinueUrl`: Automatically forwards emailed action links with `continueUrl` to the specified path (or external URL) directly from `intlMiddleware` (default `true`; set `false` on `firebaseAuth` config to disable). If `continueUrl` points to home root (`/`), it resolves to `actionLinkPath` (if set) or the mode target path (e.g. `/reset-password`).
|
|
307
|
+
- `appCheck`: Firebase App Check integration supporting reCAPTCHA Enterprise (`recaptchaEnterpriseSiteKey`) and reCAPTCHA v3 (`recaptchaV3SiteKey`). For reCAPTCHA v3, defaults to a `CustomProvider` using `IntlHelperScript`'s explicit script tag to avoid iframe/webworker CDN integrity issues in private windows (`useExplicitRecaptchaScript: false` to opt out). Supports server-side token minting via service accounts.
|
|
307
308
|
|
|
308
309
|
```tsx
|
|
309
310
|
import ThemeSwitcher from "cloudflare-next-intl/ThemeSwitcher";
|
|
@@ -246,7 +246,20 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
const flipped = previous !== undefined && previous !== isSignedIn;
|
|
249
|
-
|
|
249
|
+
// `contradictsPage` infers "the server rendered this page for
|
|
250
|
+
// the opposite auth state, resync it" from the page's own
|
|
251
|
+
// auth/non-auth role. On a whitelisted path that inference is
|
|
252
|
+
// meaningless — the page renders identically signed-in or
|
|
253
|
+
// signed-out and neither guard ever redirects away from it —
|
|
254
|
+
// so a signed-out visitor on a non-auth whitelisted page
|
|
255
|
+
// (`isSignedIn === isAuthPage`, both false) matched on the
|
|
256
|
+
// first observation and refreshed. That refresh re-primes the
|
|
257
|
+
// router cache, re-firing every in-viewport `<Link>` prefetch,
|
|
258
|
+
// which lands back here and repeats: an unbounded refresh loop
|
|
259
|
+
// on exactly the public landing pages whitelisting exists for.
|
|
260
|
+
// A real `flipped` transition still refreshes, whitelisted or
|
|
261
|
+
// not — that one observed an actual state change.
|
|
262
|
+
const contradictsPage = !isWhiteListed && previous === undefined && isSignedIn === isAuthPage;
|
|
250
263
|
if (flipped || contradictsPage) {
|
|
251
264
|
router.refresh();
|
|
252
265
|
}
|
|
@@ -257,7 +270,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
257
270
|
unsubscribe?.();
|
|
258
271
|
};
|
|
259
272
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
260
|
-
}, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName, emailVerifiedHintCookieName, appCheckTokenCookieName, appCheckTokenMaxAge]);
|
|
273
|
+
}, [router, isAuthPage, isWhiteListed, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName, emailVerifiedHintCookieName, appCheckTokenCookieName, appCheckTokenMaxAge]);
|
|
261
274
|
const reloadUser = useCallback(async () => {
|
|
262
275
|
const { auth } = await getFirebaseAuthClient();
|
|
263
276
|
const user = auth.currentUser;
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import type { FirebaseApp } from 'firebase/app';
|
|
2
2
|
import type { Auth } from 'firebase/auth';
|
|
3
3
|
import type { FirebasePerformance } from 'firebase/performance';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
interface Grecaptcha {
|
|
5
|
+
ready: (callback: () => void) => void;
|
|
6
|
+
render: (container: HTMLElement, params: {
|
|
7
|
+
sitekey: string;
|
|
8
|
+
size: 'invisible';
|
|
9
|
+
callback: () => void;
|
|
10
|
+
'error-callback': () => void;
|
|
11
|
+
}) => string;
|
|
12
|
+
execute: (widgetId: string, options: {
|
|
13
|
+
action: string;
|
|
14
|
+
}) => Promise<string>;
|
|
15
|
+
}
|
|
16
|
+
declare global {
|
|
17
|
+
interface Window {
|
|
18
|
+
grecaptcha?: Grecaptcha;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
11
21
|
export declare function getAppCheckToken(): Promise<string | undefined>;
|
|
12
22
|
/**
|
|
13
23
|
* Lazily loads and initializes `firebase/app`/`firebase/auth` — a dynamic
|
|
@@ -29,3 +39,4 @@ export declare function getFirebaseAuthClientSync(): {
|
|
|
29
39
|
export declare function getFirebasePerformanceSync(): FirebasePerformance | undefined;
|
|
30
40
|
/** Memoized `import('firebase/auth')` — see {@link getFirebaseAuthClient} for why this is worth caching. */
|
|
31
41
|
export declare function getFirebaseAuthModule(): Promise<typeof import('firebase/auth')>;
|
|
42
|
+
export {};
|
|
@@ -3,15 +3,128 @@ import config from '@intl-config';
|
|
|
3
3
|
import requireFirebaseAuthConfig from '../require_config';
|
|
4
4
|
let cachedAppCheck;
|
|
5
5
|
let cachedPerformance;
|
|
6
|
+
const GRECAPTCHA_LOAD_TIMEOUT_MS = 15000;
|
|
7
|
+
const GRECAPTCHA_POLL_INTERVAL_MS = 50;
|
|
8
|
+
/**
|
|
9
|
+
* Resolves once the reCAPTCHA script has defined `window.grecaptcha`. The
|
|
10
|
+
* script tag `IntlHelperScript` renders is `async`, and
|
|
11
|
+
* `initializeAppCheck` fetches a token immediately when
|
|
12
|
+
* `isTokenAutoRefreshEnabled` is on, so the first `getToken` regularly runs
|
|
13
|
+
* before the script has landed — this polls instead of failing outright.
|
|
14
|
+
*/
|
|
15
|
+
function waitForGrecaptcha() {
|
|
16
|
+
if (window.grecaptcha)
|
|
17
|
+
return Promise.resolve(window.grecaptcha);
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const startedAt = Date.now();
|
|
20
|
+
const timer = setInterval(() => {
|
|
21
|
+
if (window.grecaptcha) {
|
|
22
|
+
clearInterval(timer);
|
|
23
|
+
resolve(window.grecaptcha);
|
|
24
|
+
}
|
|
25
|
+
else if (Date.now() - startedAt >= GRECAPTCHA_LOAD_TIMEOUT_MS) {
|
|
26
|
+
clearInterval(timer);
|
|
27
|
+
reject(new Error('window.grecaptcha never loaded; ensure the reCAPTCHA <script src="https://www.google.com/recaptcha/api.js?render=explicit"> tag is present'));
|
|
28
|
+
}
|
|
29
|
+
}, GRECAPTCHA_POLL_INTERVAL_MS);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Faithful reimplementation of `ReCaptchaV3Provider`'s internal
|
|
34
|
+
* widget-render + token-exchange flow (see `@firebase/app-check`'s
|
|
35
|
+
* `initializeV3`/`queueWidgetRender`/`getToken$1`/`exchangeToken`), minus
|
|
36
|
+
* its own internal `<script>` injection — that injection is what spawns the
|
|
37
|
+
* worker documented on `useExplicitRecaptchaScript`. Relies on the script
|
|
38
|
+
* tag `IntlHelperScript` renders, waiting for it rather than assuming it has
|
|
39
|
+
* already loaded. Hits the same public `exchangeRecaptchaV3Token` REST
|
|
40
|
+
* endpoint Firebase's own provider uses, so this stays correct even if
|
|
41
|
+
* `@firebase/app-check` changes its internal script-loading strategy.
|
|
42
|
+
*/
|
|
43
|
+
function createExplicitRecaptchaProvider(app, siteKey, CustomProvider) {
|
|
44
|
+
let widgetReady;
|
|
45
|
+
let widgetSucceeded = false;
|
|
46
|
+
function ensureWidget() {
|
|
47
|
+
if (widgetReady)
|
|
48
|
+
return widgetReady;
|
|
49
|
+
const pending = (async () => {
|
|
50
|
+
const grecaptcha = await waitForGrecaptcha();
|
|
51
|
+
return new Promise(resolve => {
|
|
52
|
+
grecaptcha.ready(() => {
|
|
53
|
+
const containerId = `fire_app_check_${app.name}`;
|
|
54
|
+
let container = document.getElementById(containerId);
|
|
55
|
+
if (!container) {
|
|
56
|
+
container = document.createElement('div');
|
|
57
|
+
container.id = containerId;
|
|
58
|
+
container.style.display = 'none';
|
|
59
|
+
document.body.appendChild(container);
|
|
60
|
+
}
|
|
61
|
+
resolve({
|
|
62
|
+
grecaptcha,
|
|
63
|
+
widgetId: grecaptcha.render(container, {
|
|
64
|
+
sitekey: siteKey,
|
|
65
|
+
size: 'invisible',
|
|
66
|
+
callback: () => {
|
|
67
|
+
widgetSucceeded = true;
|
|
68
|
+
},
|
|
69
|
+
'error-callback': () => {
|
|
70
|
+
widgetSucceeded = false;
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
})();
|
|
77
|
+
// Never memoize a rejection: a failed load (script still in flight,
|
|
78
|
+
// transient network error) must not permanently disable App Check for
|
|
79
|
+
// the rest of the page's lifetime.
|
|
80
|
+
widgetReady = pending.catch(error => {
|
|
81
|
+
widgetReady = undefined;
|
|
82
|
+
throw error;
|
|
83
|
+
});
|
|
84
|
+
return widgetReady;
|
|
85
|
+
}
|
|
86
|
+
return new CustomProvider({
|
|
87
|
+
getToken: async () => {
|
|
88
|
+
const { grecaptcha, widgetId } = await ensureWidget();
|
|
89
|
+
// `grecaptcha.execute()` rejects with `null` on failure, which
|
|
90
|
+
// surfaces as an unhelpful error — mirror Firebase's own remap.
|
|
91
|
+
const recaptchaToken = await grecaptcha
|
|
92
|
+
.execute(widgetId, { action: 'fire_app_check' })
|
|
93
|
+
.catch(() => {
|
|
94
|
+
throw new Error('reCAPTCHA error');
|
|
95
|
+
});
|
|
96
|
+
if (!widgetSucceeded) {
|
|
97
|
+
throw new Error('reCAPTCHA error');
|
|
98
|
+
}
|
|
99
|
+
const { projectId, appId, apiKey } = app.options;
|
|
100
|
+
const response = await fetch(`https://content-firebaseappcheck.googleapis.com/v1/projects/${projectId}/apps/${appId}:exchangeRecaptchaV3Token?key=${apiKey}`, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify({ recaptcha_v3_token: recaptchaToken }),
|
|
104
|
+
});
|
|
105
|
+
if (response.status !== 200) {
|
|
106
|
+
throw new Error(`App Check token exchange failed with status ${response.status}`);
|
|
107
|
+
}
|
|
108
|
+
const body = (await response.json());
|
|
109
|
+
const match = body.ttl.match(/^([\d.]+)s$/);
|
|
110
|
+
if (!match) {
|
|
111
|
+
throw new Error(`Unexpected ttl format in App Check exchange response: ${body.ttl}`);
|
|
112
|
+
}
|
|
113
|
+
return { token: body.token, expireTimeMillis: Date.now() + Number(match[1]) * 1000 };
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
6
117
|
async function initializeFirebaseAppCheck(app, appCheckConfig) {
|
|
7
|
-
const { initializeAppCheck, ReCaptchaV3Provider, ReCaptchaEnterpriseProvider } = await import('firebase/app-check');
|
|
118
|
+
const { initializeAppCheck, ReCaptchaV3Provider, ReCaptchaEnterpriseProvider, CustomProvider } = await import('firebase/app-check');
|
|
8
119
|
if (appCheckConfig.debugToken) {
|
|
9
120
|
globalThis.FIREBASE_APPCHECK_DEBUG_TOKEN =
|
|
10
121
|
appCheckConfig.debugToken;
|
|
11
122
|
}
|
|
12
123
|
const provider = appCheckConfig.recaptchaEnterpriseSiteKey
|
|
13
124
|
? new ReCaptchaEnterpriseProvider(appCheckConfig.recaptchaEnterpriseSiteKey)
|
|
14
|
-
:
|
|
125
|
+
: appCheckConfig.useExplicitRecaptchaScript !== false
|
|
126
|
+
? createExplicitRecaptchaProvider(app, appCheckConfig.recaptchaV3SiteKey, CustomProvider)
|
|
127
|
+
: new ReCaptchaV3Provider(appCheckConfig.recaptchaV3SiteKey);
|
|
15
128
|
return initializeAppCheck(app, {
|
|
16
129
|
provider,
|
|
17
130
|
isTokenAutoRefreshEnabled: appCheckConfig.isTokenAutoRefreshEnabled ?? true,
|
|
@@ -24,12 +137,22 @@ async function initializeFirebaseAppCheck(app, appCheckConfig) {
|
|
|
24
137
|
* here so callers (e.g. `AuthUserProvider`'s session-cookie sync) don't need
|
|
25
138
|
* to import `firebase/app-check` themselves.
|
|
26
139
|
*/
|
|
140
|
+
const APP_CHECK_TOKEN_TIMEOUT_MS = 10000;
|
|
27
141
|
export async function getAppCheckToken() {
|
|
28
142
|
if (!cachedAppCheck)
|
|
29
143
|
return undefined;
|
|
30
144
|
const { getToken } = await import('firebase/app-check');
|
|
31
|
-
|
|
32
|
-
|
|
145
|
+
try {
|
|
146
|
+
const result = await Promise.race([
|
|
147
|
+
getToken(cachedAppCheck),
|
|
148
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('App Check token timed out')), APP_CHECK_TOKEN_TIMEOUT_MS)),
|
|
149
|
+
]);
|
|
150
|
+
return result.token;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
console.warn('App Check token fetch failed, continuing without it', error);
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
33
156
|
}
|
|
34
157
|
let cached;
|
|
35
158
|
let cachedPromise;
|
|
@@ -63,7 +186,12 @@ export async function getFirebaseAuthClient() {
|
|
|
63
186
|
};
|
|
64
187
|
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
|
|
65
188
|
if (fa.appCheck && typeof window !== 'undefined') {
|
|
66
|
-
|
|
189
|
+
try {
|
|
190
|
+
cachedAppCheck = await initializeFirebaseAppCheck(app, fa.appCheck);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
console.warn('App Check initialization failed, continuing without it', error);
|
|
194
|
+
}
|
|
67
195
|
}
|
|
68
196
|
if (perfModule) {
|
|
69
197
|
cachedPerformance = perfModule.getPerformance(app);
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* - redirects to the locale-prefixed URL if the locale cookie disagrees
|
|
7
7
|
* with the current path (covers client-side navigation edge cases)
|
|
8
8
|
* - (prod only) checks `BUILD_ID` and force-reloads on stale deploys
|
|
9
|
+
* - loads `recaptcha/api.js?render=explicit` when `firebaseAuth.appCheck`
|
|
10
|
+
* has a `recaptchaV3SiteKey` and `useExplicitRecaptchaScript` isn't
|
|
11
|
+
* `false`, so `window.grecaptcha` is ready before App Check's
|
|
12
|
+
* `CustomProvider` needs it (see `firebase_client.ts`)
|
|
9
13
|
*
|
|
10
14
|
* Place it once in your root layout's `<head>`, alongside `IntlProvider`.
|
|
11
15
|
* No props.
|
|
@@ -3,6 +3,8 @@ import { isDarkCookieKey, localeCookieName } from "../../config/cookie_key";
|
|
|
3
3
|
import config from "../../config/intl_config";
|
|
4
4
|
import ClientHelperScript from "../../client/components/client_helper_script";
|
|
5
5
|
const isDev = process.env.NODE_ENV === 'development';
|
|
6
|
+
const appCheck = config.firebaseAuth?.appCheck;
|
|
7
|
+
const shouldLoadExplicitRecaptchaScript = !!appCheck?.recaptchaV3SiteKey && appCheck.useExplicitRecaptchaScript !== false;
|
|
6
8
|
const secureCookieAttribute = isDev ? '+ " Secure;"' : '';
|
|
7
9
|
/**
|
|
8
10
|
* Server component exported as `IntlHelperScript` from
|
|
@@ -12,6 +14,10 @@ const secureCookieAttribute = isDev ? '+ " Secure;"' : '';
|
|
|
12
14
|
* - redirects to the locale-prefixed URL if the locale cookie disagrees
|
|
13
15
|
* with the current path (covers client-side navigation edge cases)
|
|
14
16
|
* - (prod only) checks `BUILD_ID` and force-reloads on stale deploys
|
|
17
|
+
* - loads `recaptcha/api.js?render=explicit` when `firebaseAuth.appCheck`
|
|
18
|
+
* has a `recaptchaV3SiteKey` and `useExplicitRecaptchaScript` isn't
|
|
19
|
+
* `false`, so `window.grecaptcha` is ready before App Check's
|
|
20
|
+
* `CustomProvider` needs it (see `firebase_client.ts`)
|
|
15
21
|
*
|
|
16
22
|
* Place it once in your root layout's `<head>`, alongside `IntlProvider`.
|
|
17
23
|
* No props.
|
|
@@ -24,7 +30,8 @@ const secureCookieAttribute = isDev ? '+ " Secure;"' : '';
|
|
|
24
30
|
* ```
|
|
25
31
|
*/
|
|
26
32
|
export default function HelperScript() {
|
|
27
|
-
return _jsxs(_Fragment, { children: [
|
|
33
|
+
return _jsxs(_Fragment, { children: [shouldLoadExplicitRecaptchaScript &&
|
|
34
|
+
_jsx("script", { src: "https://www.google.com/recaptcha/api.js?render=explicit", async: true, defer: true }), !isDev &&
|
|
28
35
|
_jsx("script", { id: "build-id-script", children: `(async function() {
|
|
29
36
|
try {
|
|
30
37
|
const resp = await fetch('/BUILD_ID', { method: 'HEAD', cache: 'no-store' });
|
|
@@ -632,6 +632,25 @@ export interface FirebaseAppCheckConfig {
|
|
|
632
632
|
recaptchaV3SiteKey?: string;
|
|
633
633
|
/** reCAPTCHA Enterprise site key. Mutually exclusive with `recaptchaV3SiteKey`. */
|
|
634
634
|
recaptchaEnterpriseSiteKey?: string;
|
|
635
|
+
/**
|
|
636
|
+
* When `recaptchaV3SiteKey` is set, App Check defaults (`true`, or
|
|
637
|
+
* omitted) to a `CustomProvider` that renders an invisible reCAPTCHA
|
|
638
|
+
* widget and calls `window.grecaptcha.execute` directly, instead of
|
|
639
|
+
* Firebase's `ReCaptchaV3Provider`. `ReCaptchaV3Provider` injects its
|
|
640
|
+
* own `<script>` internally, which spawns a background worker
|
|
641
|
+
* (`api2/webworker.js`) that independently re-fetches
|
|
642
|
+
* `recaptcha__en.js` — in browsers with no persistent HTTP cache (e.g.
|
|
643
|
+
* Firefox private windows), that second fetch can hit a different CDN
|
|
644
|
+
* edge than the main-thread script tag, tripping a `sha384` integrity
|
|
645
|
+
* mismatch and an infinite retry loop that freezes the tab. The
|
|
646
|
+
* `CustomProvider` path requires the consumer to load
|
|
647
|
+
* `https://www.google.com/recaptcha/api.js?render=explicit` themselves
|
|
648
|
+
* (e.g. via `next/script`) before App Check initializes; it replicates
|
|
649
|
+
* `ReCaptchaV3Provider`'s widget-render and token-exchange flow against
|
|
650
|
+
* the same public `exchangeRecaptchaV3Token` endpoint. Set to `false`
|
|
651
|
+
* to use Firebase's own `ReCaptchaV3Provider` instead.
|
|
652
|
+
*/
|
|
653
|
+
useExplicitRecaptchaScript?: boolean;
|
|
635
654
|
/**
|
|
636
655
|
* Enables App Check's debug token on this client. Pass `true` to have
|
|
637
656
|
* the Firebase SDK generate a new random token each run (logged to the
|