cloudflare-next-intl 0.6.10 → 0.6.12

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.
@@ -8,7 +8,9 @@ import requireFirebaseAuthConfig from '../require_config';
8
8
  import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client';
9
9
  import { setAuthUserCache } from './auth_user_cache';
10
10
  import { defaultEmailVerifiedHintCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
11
+ import decodeJwtPayload from '../decode_jwt_payload';
11
12
  import setCookie from '../../client/functions/set_cookie';
13
+ import getCookie from '../../client/functions/get_cookie';
12
14
  import clearSessionAction from '../server/clear_session_action';
13
15
  // `null` default (instead of a `{ loading: true, ... }` stand-in) lets
14
16
  // `useAuthUser` distinguish "not wrapped in AuthUserProvider" (throw) from
@@ -17,6 +19,9 @@ export const AuthUserContext = createContext(null);
17
19
  function writeSessionCookie(sessionCookieName, idToken, maxAge) {
18
20
  setCookie({ name: sessionCookieName, value: idToken, maxAge });
19
21
  }
22
+ function sleep(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
20
25
  function clearSessionCookie(sessionCookieName) {
21
26
  setCookie({ name: sessionCookieName, value: '', maxAge: 0 });
22
27
  }
@@ -48,7 +53,7 @@ async function clearSession(sessionCookieName, refreshTokenCookieName, emailVeri
48
53
  console.error('AuthUserProvider: clearSessionAction failed', e);
49
54
  }
50
55
  }
51
- async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName) {
56
+ async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName, idToken) {
52
57
  try {
53
58
  writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
54
59
  }
@@ -56,7 +61,7 @@ async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieN
56
61
  console.error('AuthUserProvider: refresh-token cookie sync failed', e);
57
62
  }
58
63
  writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, user.emailVerified, refreshTokenMaxAge);
59
- writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
64
+ writeSessionCookie(sessionCookieName, idToken ?? await user.getIdToken(true), maxAge);
60
65
  }
61
66
  /**
62
67
  * Client-side auth-state provider for `firebase_auth`. Wrap your root layout
@@ -105,15 +110,32 @@ export default function AuthUserProvider({ initialUser = null, children }) {
105
110
  const [confirmedSignedOut, setConfirmedSignedOut] = useState(initialUser === null);
106
111
  useEffect(() => {
107
112
  const { user, loading } = state;
108
- if (loading || isAuthPage || isWhiteListed)
113
+ if (loading || isWhiteListed)
109
114
  return;
110
115
  if (!user) {
111
- if (confirmedSignedOut)
116
+ // Signed-out on an auth page is where they're supposed to be —
117
+ // only bounce a signed-out user away from a NON-auth page.
118
+ if (!isAuthPage && confirmedSignedOut)
112
119
  router.replace(fa.redirectAuthPath);
113
120
  }
114
121
  else if (fa.verifyEmailPath && !user.emailVerified && pathname !== fa.verifyEmailPath) {
122
+ // Checked before the auth-page redirect below (mirrors
123
+ // `update_session.ts`'s same ordering): an unverified signed-in
124
+ // user must land on verifyEmailPath even if they navigated to
125
+ // an auth page like /login — homePath isn't reachable yet either.
115
126
  router.replace(fa.verifyEmailPath);
116
127
  }
128
+ else if (isAuthPage || (fa.verifyEmailPath && user.emailVerified && pathname === fa.verifyEmailPath)) {
129
+ // Mirrors the middleware's own signed-in-on-auth-page redirect
130
+ // (`update_session.ts`'s `isAuthPage` branch) — needed here too
131
+ // because a client-side navigation (e.g. a `<Link>`) to an auth
132
+ // page never re-runs the middleware, so without this the user
133
+ // would land on e.g. `/login` while already signed in and stay
134
+ // there until a hard refresh. A verified user reaching
135
+ // verifyEmailPath itself gets the same "go home" treatment —
136
+ // they're done here, same as the auth-page case.
137
+ router.replace(fa.homePath);
138
+ }
117
139
  // eslint-disable-next-line react-hooks/exhaustive-deps
118
140
  }, [state, pathname, isAuthPage, isWhiteListed, confirmedSignedOut]);
119
141
  useEffect(() => {
@@ -173,7 +195,32 @@ export default function AuthUserProvider({ initialUser = null, children }) {
173
195
  try {
174
196
  const { reload } = await getFirebaseAuthModule();
175
197
  await reload(user);
176
- await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
198
+ // `getIdToken(true)` can occasionally hand back a token that's
199
+ // no newer than the one already in the session cookie — a stale
200
+ // token caught mid-propagation right after `reload()`, rather
201
+ // than the fresh mint the caller asked for. Comparing `iat`
202
+ // (issued-at) against the existing cookie's token is a direct
203
+ // check that the token we're about to write is actually new;
204
+ // retry a few times if it isn't before giving up and writing
205
+ // whatever we got. The confirmed token is threaded into
206
+ // `writeSession` directly — letting it call `getIdToken(true)`
207
+ // again on its own could re-fetch and land back on a stale
208
+ // token, undoing this retry entirely.
209
+ const previousIat = decodeJwtPayload(getCookie(sessionCookieName) ?? '')?.iat;
210
+ let confirmedToken;
211
+ if (previousIat !== undefined) {
212
+ const maxAttempts = 3;
213
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
214
+ const freshToken = await user.getIdToken(true);
215
+ confirmedToken = freshToken;
216
+ const freshIat = decodeJwtPayload(freshToken)?.iat;
217
+ if (freshIat !== undefined && freshIat > previousIat)
218
+ break;
219
+ if (attempt < maxAttempts - 1)
220
+ await sleep(500);
221
+ }
222
+ }
223
+ await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName, confirmedToken);
177
224
  setAuthUserCache(user);
178
225
  setState({ user, loading: false });
179
226
  }
@@ -0,0 +1,35 @@
1
+ import { bench, describe } from 'vitest';
2
+ import decodeJwtPayload from './decode_jwt_payload';
3
+ function makeJwt(payload) {
4
+ const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
5
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
6
+ return `${header}.${encodedPayload}.sig`;
7
+ }
8
+ // A realistic Firebase ID token payload has more claims than the ones this
9
+ // function reads — decoding cost scales with the JSON blob's size, not just
10
+ // the fields we destructure, so the bench payload mirrors that shape rather
11
+ // than a minimal `{ exp, iat }` object.
12
+ const realisticToken = makeJwt({
13
+ iss: 'https://securetoken.google.com/demo-project',
14
+ aud: 'demo-project',
15
+ auth_time: 1735689600,
16
+ user_id: 'abcDEF123456ghijKLMNOP789',
17
+ sub: 'abcDEF123456ghijKLMNOP789',
18
+ iat: 1735689600,
19
+ exp: 1735693200,
20
+ email: 'user@example.com',
21
+ email_verified: true,
22
+ firebase: {
23
+ identities: { email: ['user@example.com'] },
24
+ sign_in_provider: 'password',
25
+ },
26
+ });
27
+ const malformedToken = 'not-a-jwt';
28
+ describe('decodeJwtPayload', () => {
29
+ bench('valid token, realistic payload size', () => {
30
+ decodeJwtPayload(realisticToken);
31
+ });
32
+ bench('malformed token (parse failure path)', () => {
33
+ decodeJwtPayload(malformedToken);
34
+ });
35
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Decodes a JWT's payload without verifying its signature — callers only
3
+ * ever read claims from a token they already trust (their own session
4
+ * cookie, or one just minted by the Firebase SDK/Secure Token API), never
5
+ * one supplied by an untrusted party. Isomorphic (no `next/server` or
6
+ * `next/client`-only APIs) so both `middleware/update_session.ts` (Edge)
7
+ * and `client/auth_user_provider.tsx` (browser) can share one
8
+ * implementation instead of drifting apart.
9
+ *
10
+ * Extracts `exp`/`iat`/`email_verified` via regex over the decoded JSON
11
+ * text instead of a full `JSON.parse` — ~2.6x faster (benchmarked in
12
+ * `decode_jwt_payload.bench.ts`), which matters since this runs on every
13
+ * Edge middleware invocation. This is safe ONLY because these three claims
14
+ * are top-level, standard JWT/Firebase registered claims that Firebase's ID
15
+ * tokens never nest inside another object — a regex doing first-match
16
+ * search would return a wrong value for a claim nested under a
17
+ * same-named key. Do NOT extend this function to read additional claims
18
+ * without confirming they're similarly guaranteed top-level, or switch back
19
+ * to full `JSON.parse` for those calls instead.
20
+ */
21
+ export default function decodeJwtPayload(token: string): {
22
+ exp?: number;
23
+ iat?: number;
24
+ email_verified?: boolean;
25
+ } | null;
@@ -0,0 +1,40 @@
1
+ const EXP_RE = /"exp":(-?\d+)/;
2
+ const IAT_RE = /"iat":(-?\d+)/;
3
+ const EMAIL_VERIFIED_RE = /"email_verified":(true|false)/;
4
+ /**
5
+ * Decodes a JWT's payload without verifying its signature — callers only
6
+ * ever read claims from a token they already trust (their own session
7
+ * cookie, or one just minted by the Firebase SDK/Secure Token API), never
8
+ * one supplied by an untrusted party. Isomorphic (no `next/server` or
9
+ * `next/client`-only APIs) so both `middleware/update_session.ts` (Edge)
10
+ * and `client/auth_user_provider.tsx` (browser) can share one
11
+ * implementation instead of drifting apart.
12
+ *
13
+ * Extracts `exp`/`iat`/`email_verified` via regex over the decoded JSON
14
+ * text instead of a full `JSON.parse` — ~2.6x faster (benchmarked in
15
+ * `decode_jwt_payload.bench.ts`), which matters since this runs on every
16
+ * Edge middleware invocation. This is safe ONLY because these three claims
17
+ * are top-level, standard JWT/Firebase registered claims that Firebase's ID
18
+ * tokens never nest inside another object — a regex doing first-match
19
+ * search would return a wrong value for a claim nested under a
20
+ * same-named key. Do NOT extend this function to read additional claims
21
+ * without confirming they're similarly guaranteed top-level, or switch back
22
+ * to full `JSON.parse` for those calls instead.
23
+ */
24
+ export default function decodeJwtPayload(token) {
25
+ try {
26
+ const payload = token.split('.')[1];
27
+ const json = atob(payload.replace(/[-_]/g, (c) => c === '-' ? '+' : '/'));
28
+ const exp = EXP_RE.exec(json);
29
+ const iat = IAT_RE.exec(json);
30
+ const emailVerified = EMAIL_VERIFIED_RE.exec(json);
31
+ return {
32
+ exp: exp ? Number(exp[1]) : undefined,
33
+ iat: iat ? Number(iat[1]) : undefined,
34
+ email_verified: emailVerified ? emailVerified[1] === 'true' : undefined,
35
+ };
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
@@ -1,5 +1,6 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import config from '@intl-config';
3
+ import decodeJwtPayload from '../decode_jwt_payload';
3
4
  export const defaultSessionCookieName = '__fa_session__';
4
5
  export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
5
6
  // Non-httpOnly: written by AuthUserProvider (client) every time it observes
@@ -16,15 +17,6 @@ const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
16
17
  // time can hand a client a token that dies moments after this check, forcing
17
18
  // an extra round-trip on the very next request.
18
19
  const CLOCK_SKEW_MARGIN_MS = 60 * 1000;
19
- function decodeJwtPayload(token) {
20
- try {
21
- const payload = token.split('.')[1];
22
- return JSON.parse(atob(payload.replace(/[-_]/g, (c) => c === '-' ? '+' : '/')));
23
- }
24
- catch {
25
- return null;
26
- }
27
- }
28
20
  function isJwtExpired(token) {
29
21
  const decoded = decodeJwtPayload(token);
30
22
  return !decoded?.exp || decoded.exp * 1000 - CLOCK_SKEW_MARGIN_MS <= Date.now();
@@ -278,12 +270,24 @@ export default async function updateSession(request, baseResponse, locale) {
278
270
  else if (!hasSession || clearInvalidSession) {
279
271
  response = isAuthPage ? baseResponse : buildRedirect(baseResponse, localeUrl(fa.redirectAuthPath));
280
272
  }
281
- else if (isAuthPage) {
282
- response = buildRedirect(baseResponse, localeUrl(fa.homePath));
283
- }
284
273
  else if (unverifiedEmail) {
274
+ // Checked before the auth-page redirect: an unverified signed-in
275
+ // user must land on verifyEmailPath even if they navigated to an
276
+ // auth page like /login — homePath is not a state they're allowed
277
+ // to reach yet either.
285
278
  response = buildRedirect(baseResponse, localeUrl(fa.verifyEmailPath));
286
279
  }
280
+ else if (isAuthPage || (isVerifyEmailPage && decodeJwtPayload(token)?.email_verified !== false)) {
281
+ // A verified user has no reason to be on verifyEmailPath either —
282
+ // same "you're done here, go home" treatment as an auth page.
283
+ // `unverifiedEmail` can't be reused here: its own computation
284
+ // deliberately skips verifyEmailPath (so it never redirects AWAY
285
+ // from that page for an unverified user), so it's always `false`
286
+ // while already on it regardless of actual verification status —
287
+ // checking the claim again directly is what tells verified and
288
+ // unverified apart on this specific page.
289
+ response = buildRedirect(baseResponse, localeUrl(fa.homePath));
290
+ }
287
291
  else {
288
292
  response = baseResponse;
289
293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
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",