cloudflare-next-intl 0.6.8 → 0.6.10

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,21 @@ 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
+ async function clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge) {
30
37
  clearSessionCookie(sessionCookieName);
31
38
  clearRefreshTokenCookie(refreshTokenCookieName);
39
+ // Signed-out is not "unknown" — it's a confirmed non-verified state, so
40
+ // write 'false' explicitly rather than clearing (an absent hint means
41
+ // "no signal yet", which forces the middleware to refresh unnecessarily
42
+ // if a stale session cookie somehow still lingers).
43
+ writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, false, refreshTokenMaxAge);
32
44
  try {
33
45
  await clearSessionAction();
34
46
  }
@@ -36,13 +48,14 @@ async function clearSession(sessionCookieName, refreshTokenCookieName) {
36
48
  console.error('AuthUserProvider: clearSessionAction failed', e);
37
49
  }
38
50
  }
39
- async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge) {
51
+ async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName) {
40
52
  try {
41
53
  writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
42
54
  }
43
55
  catch (e) {
44
56
  console.error('AuthUserProvider: refresh-token cookie sync failed', e);
45
57
  }
58
+ writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, user.emailVerified, refreshTokenMaxAge);
46
59
  writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
47
60
  }
48
61
  /**
@@ -74,6 +87,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
74
87
  const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
75
88
  const refreshTokenMaxAge = fa.refreshTokenCookieMaxAge ?? 60 * 60 * 24 * 365;
76
89
  const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
90
+ const emailVerifiedHintCookieName = fa.emailVerifiedHintCookieName ?? defaultEmailVerifiedHintCookieName;
77
91
  const [state, setState] = useState({
78
92
  user: initialUser,
79
93
  loading: initialUser === null,
@@ -114,10 +128,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
114
128
  const previous = syncedSignedIn.current;
115
129
  try {
116
130
  if (user) {
117
- await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge);
131
+ await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
118
132
  }
119
133
  else {
120
- await clearSession(sessionCookieName, refreshTokenCookieName);
134
+ await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge);
121
135
  }
122
136
  }
123
137
  catch (e) {
@@ -150,7 +164,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
150
164
  unsubscribe?.();
151
165
  };
152
166
  // eslint-disable-next-line react-hooks/exhaustive-deps
153
- }, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName]);
167
+ }, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName, emailVerifiedHintCookieName]);
154
168
  const reloadUser = useCallback(async () => {
155
169
  const { auth } = await getFirebaseAuthClient();
156
170
  const user = auth.currentUser;
@@ -159,7 +173,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
159
173
  try {
160
174
  const { reload } = await getFirebaseAuthModule();
161
175
  await reload(user);
162
- await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge);
176
+ await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
163
177
  setAuthUserCache(user);
164
178
  setState({ user, loading: false });
165
179
  }
@@ -182,10 +196,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
182
196
  await signOut(auth);
183
197
  }
184
198
  finally {
185
- await clearSession(sessionCookieName, refreshTokenCookieName);
199
+ await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge);
186
200
  router.push(fa.redirectAuthPath);
187
201
  }
188
202
  // eslint-disable-next-line react-hooks/exhaustive-deps
189
- }, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName]);
203
+ }, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName]);
190
204
  return _jsx(AuthUserContext.Provider, { value: { ...state, reloadUser, sendVerificationEmail, logout }, children: children });
191
205
  }
@@ -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
+ // Force one refresh to confirm before redirecting whenever that hint
232
+ // can't yet vouch for this claim: it disagrees outright, or it's absent
233
+ // (e.g. first request before the client has run at all, or a hint
234
+ // that expired/was never set) — either way there's no positive signal
235
+ // the claim is still accurate. Only when the hint AGREES with the claim
236
+ // is a refresh skipped, so a genuinely unverified user with an
237
+ // established, agreeing hint doesn't pay a refresh on every 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 hintConfirms = hint === 'false';
242
+ if (!hintConfirms && !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 confirms unverified — no reason to refresh, trust 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.10",
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",