cloudflare-next-intl 0.8.35 → 0.8.36

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 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";
@@ -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
- * Current App Check token, or `undefined` if `appCheck` isn't configured or
6
- * hasn't initialized yet. Forces a refresh only when the cached token is
7
- * expired/near-expiry — mirrors `getToken`'s own semantics, just exposed
8
- * here so callers (e.g. `AuthUserProvider`'s session-cookie sync) don't need
9
- * to import `firebase/app-check` themselves.
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
- : new ReCaptchaV3Provider(appCheckConfig.recaptchaV3SiteKey);
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
- const result = await getToken(cachedAppCheck);
32
- return result.token;
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
- cachedAppCheck = await initializeFirebaseAppCheck(app, fa.appCheck);
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: [!isDev &&
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.35",
3
+ "version": "0.8.36",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",