cloudflare-next-intl 0.6.31 → 0.6.32

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.
@@ -1,9 +1,24 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
+ import config from '@intl-config';
2
3
  export declare const defaultSessionCookieName = "__fa_session__";
3
4
  export declare const defaultRefreshTokenCookieName = "__fa_refresh_token__";
4
5
  export declare const defaultEmailVerifiedHintCookieName = "__fa_email_verified_hint__";
5
6
  export declare const defaultAppCheckTokenCookieName = "__fa_app_check_token__";
6
7
  export declare const defaultResetPasswordPath = "/reset-password";
8
+ export declare const DEFAULT_SESSION_MAX_AGE: number;
9
+ export declare const DEFAULT_REFRESH_MAX_AGE: number;
10
+ /**
11
+ * The session/refresh cookie attributes, shared by every writer so the
12
+ * middleware and the RSC-side refresh can't drift into writing the same
13
+ * cookie pair with different flags or lifetimes.
14
+ *
15
+ * @param secure `false` only for a plain-http local dev origin — a `secure`
16
+ * cookie is silently dropped there.
17
+ */
18
+ export declare function sessionCookieOptions(fa: NonNullable<typeof config.firebaseAuth>, secure: boolean): {
19
+ session: Record<string, unknown>;
20
+ refresh: Record<string, unknown>;
21
+ };
7
22
  export declare function isIdTokenExpired(token: string): boolean;
8
23
  export type RefreshResult = {
9
24
  status: 'refreshed';
@@ -20,7 +35,9 @@ export type RefreshResult = {
20
35
  * runtime, and `firebase/auth` pulls in Node-only APIs that break Edge
21
36
  * bundles even though this function never touches that module.
22
37
  */
23
- export declare function refreshIdToken(apiKey: string, refreshToken: string): Promise<RefreshResult>;
38
+ export declare function refreshIdToken(apiKey: string, refreshToken: string, options?: {
39
+ skipCache?: boolean;
40
+ }): Promise<RefreshResult>;
24
41
  /**
25
42
  * Layers Firebase session-cookie validation/refresh and auth redirects onto
26
43
  * an already-built middleware response. Called internally by `intlMiddleware`
@@ -37,8 +37,23 @@ function resolveActionModePaths(fa) {
37
37
  paths.recoverEmail = fa.recoverEmailPath;
38
38
  return { ...paths, ...fa.actionModePaths };
39
39
  }
40
- const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
41
- const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
40
+ export const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
41
+ export const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
42
+ /**
43
+ * The session/refresh cookie attributes, shared by every writer so the
44
+ * middleware and the RSC-side refresh can't drift into writing the same
45
+ * cookie pair with different flags or lifetimes.
46
+ *
47
+ * @param secure `false` only for a plain-http local dev origin — a `secure`
48
+ * cookie is silently dropped there.
49
+ */
50
+ export function sessionCookieOptions(fa, secure) {
51
+ const shared = { httpOnly: true, secure, sameSite: 'lax', path: '/' };
52
+ return {
53
+ session: { ...shared, maxAge: fa.sessionCookieMaxAge ?? DEFAULT_SESSION_MAX_AGE },
54
+ refresh: { ...shared, maxAge: fa.refreshTokenCookieMaxAge ?? DEFAULT_REFRESH_MAX_AGE },
55
+ };
56
+ }
42
57
  // Refresh slightly before the real expiry — treating a token as expired
43
58
  // right up to its last second means normal clock skew or in-flight request
44
59
  // time can hand a client a token that dies moments after this check, forcing
@@ -68,7 +83,7 @@ function isJwtExpired(token) {
68
83
  // `.internal` is a non-resolvable TLD reserved by convention — no real
69
84
  // `fetch()` in this Worker could ever have populated (or could ever
70
85
  // collide with) an entry under it.
71
- const REFRESH_CACHE_TTL_SECONDS = 50 * 60;
86
+ const REFRESH_CACHE_TTL_SECONDS = 30 * 60;
72
87
  const REFRESH_CACHE_KEY_ORIGIN = 'https://firebase-auth-refresh-cache.internal';
73
88
  function getEdgeCache() {
74
89
  const cachesApi = globalThis.caches;
@@ -130,9 +145,13 @@ const INVALID_REFRESH_TOKEN_ERRORS = new Set([
130
145
  * runtime, and `firebase/auth` pulls in Node-only APIs that break Edge
131
146
  * bundles even though this function never touches that module.
132
147
  */
133
- export async function refreshIdToken(apiKey, refreshToken) {
134
- const cached = await getCachedRefresh(refreshToken);
135
- if (cached)
148
+ export async function refreshIdToken(apiKey, refreshToken, options) {
149
+ // `skipCache` exists for the caller that already HAS a token the Auth
150
+ // service rejected: the cache entry is what produced that token, so a
151
+ // normal cache hit would hand back the exact same rejected token and the
152
+ // retry could never recover.
153
+ const cached = options?.skipCache ? null : await getCachedRefresh(refreshToken);
154
+ if (cached && !isJwtExpired(cached.idToken))
136
155
  return { status: 'refreshed', ...cached };
137
156
  try {
138
157
  const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${apiKey}`, {
@@ -372,20 +391,9 @@ export default async function updateSession(request, baseResponse, locale, rebui
372
391
  baseResponse.headers.forEach((value, key) => rebuilt.headers.set(key, value));
373
392
  response = rebuilt;
374
393
  }
375
- response.cookies.set(sessionCookieName, refreshedToken.idToken, {
376
- httpOnly: true,
377
- secure: request.nextUrl.protocol === 'https',
378
- sameSite: 'lax',
379
- path: '/',
380
- maxAge: fa.sessionCookieMaxAge ?? DEFAULT_SESSION_MAX_AGE,
381
- });
382
- response.cookies.set(refreshTokenCookieName, refreshedToken.refreshToken, {
383
- httpOnly: true,
384
- secure: request.nextUrl.protocol === 'https',
385
- sameSite: 'lax',
386
- path: '/',
387
- maxAge: fa.refreshTokenCookieMaxAge ?? DEFAULT_REFRESH_MAX_AGE,
388
- });
394
+ const cookieOptions = sessionCookieOptions(fa, request.nextUrl.protocol === 'https');
395
+ response.cookies.set(sessionCookieName, refreshedToken.idToken, cookieOptions.session);
396
+ response.cookies.set(refreshTokenCookieName, refreshedToken.refreshToken, cookieOptions.refresh);
389
397
  }
390
398
  return response;
391
399
  }
@@ -3,9 +3,10 @@ import type { User } from 'firebase/auth';
3
3
  /**
4
4
  * Resolves the signed-in user on the server from the session cookie.
5
5
  * `initializeServerApp` validates the token with the Auth service, so a
6
- * missing, expired, or forged token yields `currentUser === null`.
7
- * Wrapped in React's `cache()` so multiple server components in one request
8
- * share a single Auth service lookup. Lazily imports `firebase/app`/
6
+ * missing, expired, or forged token yields `currentUser === null` — in which
7
+ * case one refresh from the refresh-token cookie is attempted before giving
8
+ * up. Wrapped in React's `cache()` so multiple server components in one
9
+ * request share a single Auth service lookup. Lazily imports `firebase/app`/
9
10
  * `firebase/auth` — never touched unless this is actually called, and
10
11
  * throws if `firebaseAuth` is missing from `RoutingConfig`.
11
12
  */
@@ -2,18 +2,39 @@ import { cookies } from 'next/headers';
2
2
  import { cache } from 'react';
3
3
  import config from '@intl-config';
4
4
  import requireFirebaseAuthConfig from '../require_config';
5
- import { defaultAppCheckTokenCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName, isIdTokenExpired, refreshIdToken } from '../middleware/update_session';
5
+ import { defaultAppCheckTokenCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName, isIdTokenExpired, refreshIdToken, sessionCookieOptions } from '../middleware/update_session';
6
6
  import reportError from '../../error_handling/report_error';
7
7
  import mintServerAppCheckToken from './mint_server_app_check_token';
8
8
  let baseApp;
9
9
  let firebaseAppModule;
10
10
  let firebaseAuthModule;
11
+ /**
12
+ * Writes a freshly-minted session/refresh pair back to the cookie jar so the
13
+ * NEXT request doesn't repeat the rejected-token round-trip. Next only allows
14
+ * cookie writes from Server Actions and Route Handlers — during an RSC render
15
+ * this throws, which is expected and harmless: the middleware persists the
16
+ * same pair on the following request, so this is a best-effort shortcut only.
17
+ */
18
+ function persistRefreshedSession(cookieStore, sessionCookieName, refreshTokenCookieName, idToken, refreshToken) {
19
+ // No request URL here (unlike the middleware), so `secure` can't be
20
+ // derived from the protocol — always secure, which is correct for every
21
+ // origin a session cookie should be sent to anyway.
22
+ const options = sessionCookieOptions(config.firebaseAuth, true);
23
+ try {
24
+ cookieStore.set(sessionCookieName, idToken, options.session);
25
+ cookieStore.set(refreshTokenCookieName, refreshToken, options.refresh);
26
+ }
27
+ catch {
28
+ // Read-only cookie jar (RSC render) — middleware handles it next request.
29
+ }
30
+ }
11
31
  /**
12
32
  * Resolves the signed-in user on the server from the session cookie.
13
33
  * `initializeServerApp` validates the token with the Auth service, so a
14
- * missing, expired, or forged token yields `currentUser === null`.
15
- * Wrapped in React's `cache()` so multiple server components in one request
16
- * share a single Auth service lookup. Lazily imports `firebase/app`/
34
+ * missing, expired, or forged token yields `currentUser === null` — in which
35
+ * case one refresh from the refresh-token cookie is attempted before giving
36
+ * up. Wrapped in React's `cache()` so multiple server components in one
37
+ * request share a single Auth service lookup. Lazily imports `firebase/app`/
17
38
  * `firebase/auth` — never touched unless this is actually called, and
18
39
  * throws if `firebaseAuth` is missing from `RoutingConfig`.
19
40
  */
@@ -60,7 +81,14 @@ export const getAuthenticatedAppForUser = cache(async function getAuthenticatedA
60
81
  // behavior.
61
82
  const appCheckToken = cookieStore.get(appCheckTokenCookieName)?.value
62
83
  ?? await mintServerAppCheckToken(fa.projectId, fa.apiKey, fa.appCheck);
63
- try {
84
+ // A token that isn't merely expired-by-clock (revoked session, password
85
+ // change, a token minted for a different project) is rejected with
86
+ // `auth/invalid-user-token`. `initializeServerApp` does NOT surface that
87
+ // as a rejection though — it logs "FirebaseServerApp could not login user
88
+ // with provided authIdToken" itself and simply resolves `authStateReady()`
89
+ // with `currentUser === null`. So a null user (not a throw) is the signal
90
+ // to drop the bad token and mint a replacement from the refresh cookie.
91
+ const attempt = async (idToken) => {
64
92
  if (!firebaseAppModule)
65
93
  firebaseAppModule = await import('firebase/app');
66
94
  if (!firebaseAuthModule)
@@ -83,12 +111,46 @@ export const getAuthenticatedAppForUser = cache(async function getAuthenticatedA
83
111
  // registering a new named app in Firebase's global app registry.
84
112
  if (!baseApp)
85
113
  baseApp = initializeApp(firebaseConfig, 'firebase-auth-server-base');
86
- const firebaseServerApp = initializeServerApp(baseApp, { authIdToken, appCheckToken });
114
+ const firebaseServerApp = initializeServerApp(baseApp, { authIdToken: idToken, appCheckToken });
87
115
  const auth = getAuth(firebaseServerApp);
88
116
  await auth.authStateReady();
89
117
  return { firebaseServerApp, currentUser: auth.currentUser };
118
+ };
119
+ const retryWithFreshToken = async (rejectedToken) => {
120
+ const refreshToken = cookieStore.get(refreshTokenCookieName)?.value;
121
+ if (!refreshToken)
122
+ return { firebaseServerApp: null, currentUser: null };
123
+ // The cached entry is what produced `rejectedToken` in the first
124
+ // place, so a plain refresh would hand back the same rejected token.
125
+ const result = await refreshIdToken(fa.apiKey, refreshToken, { skipCache: true });
126
+ if (result.status !== 'refreshed' || result.idToken === rejectedToken) {
127
+ return { firebaseServerApp: null, currentUser: null };
128
+ }
129
+ try {
130
+ const retried = await attempt(result.idToken);
131
+ if (retried.currentUser) {
132
+ persistRefreshedSession(cookieStore, sessionCookieName, refreshTokenCookieName, result.idToken, result.refreshToken);
133
+ }
134
+ return retried;
135
+ }
136
+ catch (retryError) {
137
+ await reportError(config, { error: retryError, classOrMethodName: 'getAuthenticatedAppForUser' });
138
+ return { firebaseServerApp: null, currentUser: null };
139
+ }
140
+ };
141
+ try {
142
+ const first = await attempt(authIdToken);
143
+ // `initializeServerApp` reports an invalid/revoked token by resolving
144
+ // with a null user rather than throwing — retry rather than render
145
+ // this request as signed-out.
146
+ if (!first.currentUser)
147
+ return await retryWithFreshToken(authIdToken);
148
+ return first;
90
149
  }
91
150
  catch (error) {
151
+ if (error?.code === 'auth/invalid-user-token') {
152
+ return await retryWithFreshToken(authIdToken);
153
+ }
92
154
  await reportError(config, { error, classOrMethodName: 'getAuthenticatedAppForUser' });
93
155
  return { firebaseServerApp: null, currentUser: null };
94
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.6.31",
3
+ "version": "0.6.32",
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",