cloudflare-next-intl 0.6.8 → 0.6.9

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.
@@ -7,7 +7,7 @@ import config from '@intl-config';
7
7
  import requireFirebaseAuthConfig from '../require_config';
8
8
  import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client';
9
9
  import { setAuthUserCache } from './auth_user_cache';
10
- import { defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
10
+ import { defaultEmailVerifiedHintCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
11
11
  import setCookie from '../../client/functions/set_cookie';
12
12
  import clearSessionAction from '../server/clear_session_action';
13
13
  // `null` default (instead of a `{ loading: true, ... }` stand-in) lets
@@ -26,9 +26,20 @@ function writeRefreshTokenCookie(refreshTokenCookieName, user, maxAge) {
26
26
  function clearRefreshTokenCookie(refreshTokenCookieName) {
27
27
  setCookie({ name: refreshTokenCookieName, value: '', maxAge: 0 });
28
28
  }
29
- async function clearSession(sessionCookieName, refreshTokenCookieName) {
29
+ // Mirrors the live SDK's `emailVerified` into a client-readable cookie so
30
+ // the middleware can tell "the client already agrees with the session JWT's
31
+ // claim" (skip the extra refresh) apart from "the client observed a change
32
+ // this claim hasn't caught up to yet" (worth one refresh to confirm).
33
+ function writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, emailVerified, maxAge) {
34
+ setCookie({ name: emailVerifiedHintCookieName, value: String(emailVerified), maxAge });
35
+ }
36
+ function clearEmailVerifiedHintCookie(emailVerifiedHintCookieName) {
37
+ setCookie({ name: emailVerifiedHintCookieName, value: '', maxAge: 0 });
38
+ }
39
+ async function clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName) {
30
40
  clearSessionCookie(sessionCookieName);
31
41
  clearRefreshTokenCookie(refreshTokenCookieName);
42
+ clearEmailVerifiedHintCookie(emailVerifiedHintCookieName);
32
43
  try {
33
44
  await clearSessionAction();
34
45
  }
@@ -36,13 +47,14 @@ async function clearSession(sessionCookieName, refreshTokenCookieName) {
36
47
  console.error('AuthUserProvider: clearSessionAction failed', e);
37
48
  }
38
49
  }
39
- async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge) {
50
+ async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName) {
40
51
  try {
41
52
  writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
42
53
  }
43
54
  catch (e) {
44
55
  console.error('AuthUserProvider: refresh-token cookie sync failed', e);
45
56
  }
57
+ writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, user.emailVerified, refreshTokenMaxAge);
46
58
  writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
47
59
  }
48
60
  /**
@@ -74,6 +86,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
74
86
  const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
75
87
  const refreshTokenMaxAge = fa.refreshTokenCookieMaxAge ?? 60 * 60 * 24 * 365;
76
88
  const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
89
+ const emailVerifiedHintCookieName = fa.emailVerifiedHintCookieName ?? defaultEmailVerifiedHintCookieName;
77
90
  const [state, setState] = useState({
78
91
  user: initialUser,
79
92
  loading: initialUser === null,
@@ -114,10 +127,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
114
127
  const previous = syncedSignedIn.current;
115
128
  try {
116
129
  if (user) {
117
- await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge);
130
+ await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
118
131
  }
119
132
  else {
120
- await clearSession(sessionCookieName, refreshTokenCookieName);
133
+ await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName);
121
134
  }
122
135
  }
123
136
  catch (e) {
@@ -150,7 +163,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
150
163
  unsubscribe?.();
151
164
  };
152
165
  // eslint-disable-next-line react-hooks/exhaustive-deps
153
- }, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName]);
166
+ }, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName, emailVerifiedHintCookieName]);
154
167
  const reloadUser = useCallback(async () => {
155
168
  const { auth } = await getFirebaseAuthClient();
156
169
  const user = auth.currentUser;
@@ -159,7 +172,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
159
172
  try {
160
173
  const { reload } = await getFirebaseAuthModule();
161
174
  await reload(user);
162
- await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge);
175
+ await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
163
176
  setAuthUserCache(user);
164
177
  setState({ user, loading: false });
165
178
  }
@@ -182,10 +195,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
182
195
  await signOut(auth);
183
196
  }
184
197
  finally {
185
- await clearSession(sessionCookieName, refreshTokenCookieName);
198
+ await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName);
186
199
  router.push(fa.redirectAuthPath);
187
200
  }
188
201
  // eslint-disable-next-line react-hooks/exhaustive-deps
189
- }, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName]);
202
+ }, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName]);
190
203
  return _jsx(AuthUserContext.Provider, { value: { ...state, reloadUser, sendVerificationEmail, logout }, children: children });
191
204
  }
@@ -1,6 +1,7 @@
1
1
  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
+ export declare const defaultEmailVerifiedHintCookieName = "__fa_email_verified_hint__";
4
5
  /**
5
6
  * Layers Firebase session-cookie validation/refresh and auth redirects onto
6
7
  * an already-built middleware response. Called internally by `intlMiddleware`
@@ -2,6 +2,13 @@ import { NextResponse } from 'next/server';
2
2
  import config from '@intl-config';
3
3
  export const defaultSessionCookieName = '__fa_session__';
4
4
  export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
5
+ // Non-httpOnly: written by AuthUserProvider (client) every time it observes
6
+ // the live Firebase user's emailVerified state, so the middleware can tell
7
+ // "the client already agrees with this claim" (no refresh needed) apart
8
+ // from "the client last observed something different" (claim may be stale
9
+ // — force one refresh). Readable client-side is fine: it carries no secret,
10
+ // only a boolean mirror of a claim already inside the session JWT.
11
+ export const defaultEmailVerifiedHintCookieName = '__fa_email_verified_hint__';
5
12
  const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
6
13
  const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
7
14
  // Refresh slightly before the real expiry — treating a token as expired
@@ -164,6 +171,7 @@ export default async function updateSession(request, baseResponse, locale) {
164
171
  return baseResponse;
165
172
  const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
166
173
  const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
174
+ const emailVerifiedHintCookieName = fa.emailVerifiedHintCookieName ?? defaultEmailVerifiedHintCookieName;
167
175
  const rawPath = request.nextUrl.pathname;
168
176
  const requestPrefix = `/${locale}`;
169
177
  const path = rawPath === requestPrefix || rawPath.startsWith(`${requestPrefix}/`)
@@ -214,6 +222,52 @@ export default async function updateSession(request, baseResponse, locale) {
214
222
  const hasSession = !!token;
215
223
  let response;
216
224
  const isVerifyEmailPage = !!fa.verifyEmailPath && path === fa.verifyEmailPath;
225
+ // The session cookie's `email_verified` claim is only as fresh as the
226
+ // last ID-token mint — it does NOT update the moment a user clicks an
227
+ // emailed verification link, only once the token naturally refreshes
228
+ // (up to ~1hr later). AuthUserProvider (client) mirrors the live SDK
229
+ // state into `emailVerifiedHintCookieName` on every auth-state change,
230
+ // so it reflects verification status sooner than the session JWT does.
231
+ // If that hint disagrees with the stale claim, something changed since
232
+ // this claim was minted — force one refresh to confirm before
233
+ // redirecting, so this claim is no staler than a single refresh
234
+ // round-trip instead of up to an hour. If the hint AGREES with the
235
+ // claim (or is absent, e.g. first request before the client has run),
236
+ // trust the claim as-is — no extra network call, so a genuinely
237
+ // unverified user doesn't pay a refresh on every single request.
238
+ let unverifiedEmail = false;
239
+ if (fa.verifyEmailPath && !isVerifyEmailPage && hasSession && decodeJwtPayload(token)?.email_verified === false) {
240
+ const hint = request.cookies.get(emailVerifiedHintCookieName)?.value;
241
+ const hintDisagrees = hint === 'true';
242
+ if (hintDisagrees && !refreshedToken) {
243
+ const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
244
+ if (refreshToken) {
245
+ const result = await refreshIdToken(fa.apiKey, refreshToken);
246
+ if (result.status === 'refreshed') {
247
+ refreshedToken = { idToken: result.idToken, refreshToken: result.refreshToken };
248
+ token = refreshedToken.idToken;
249
+ unverifiedEmail = decodeJwtPayload(token)?.email_verified === false;
250
+ }
251
+ else if (result.status === 'invalid') {
252
+ clearInvalidSession = true;
253
+ }
254
+ else {
255
+ // Transient failure — can't confirm the live claim, fall
256
+ // back to trusting the (possibly stale) existing claim
257
+ // rather than blocking the request.
258
+ unverifiedEmail = true;
259
+ }
260
+ }
261
+ else {
262
+ // No refresh token to re-check with — trust the existing claim.
263
+ unverifiedEmail = true;
264
+ }
265
+ }
266
+ else {
267
+ // Hint agrees (or is absent) — no reason to distrust the claim.
268
+ unverifiedEmail = true;
269
+ }
270
+ }
217
271
  if (refreshWasTransientFailure) {
218
272
  // Couldn't confirm the session either way — pass through without
219
273
  // forcing a redirect in either direction. The next request (or the
@@ -221,13 +275,13 @@ export default async function updateSession(request, baseResponse, locale) {
221
275
  // chance to resolve this correctly instead of guessing wrong.
222
276
  response = baseResponse;
223
277
  }
224
- else if (!hasSession) {
278
+ else if (!hasSession || clearInvalidSession) {
225
279
  response = isAuthPage ? baseResponse : buildRedirect(baseResponse, localeUrl(fa.redirectAuthPath));
226
280
  }
227
281
  else if (isAuthPage) {
228
282
  response = buildRedirect(baseResponse, localeUrl(fa.homePath));
229
283
  }
230
- else if (fa.verifyEmailPath && !isVerifyEmailPage && token && decodeJwtPayload(token)?.email_verified === false) {
284
+ else if (unverifiedEmail) {
231
285
  response = buildRedirect(baseResponse, localeUrl(fa.verifyEmailPath));
232
286
  }
233
287
  else {
@@ -457,6 +457,8 @@ export interface FirebaseAuthRoutingConfig {
457
457
  sessionCookieName?: string;
458
458
  /** Refresh-token cookie name. Defaults to `'__fa_refresh_token__'`. Override this if your app already uses a different name for its Firebase refresh-token cookie. */
459
459
  refreshTokenCookieName?: string;
460
+ /** Email-verified hint cookie name. Defaults to `'__fa_email_verified_hint__'`. Client-written, non-httpOnly; lets the middleware avoid an unnecessary token refresh when its view already matches the client's. */
461
+ emailVerifiedHintCookieName?: string;
460
462
  }
461
463
  export interface CookieAttributes {
462
464
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
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",