cloudflare-next-intl 0.6.10 → 0.6.11

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
@@ -173,7 +178,32 @@ export default function AuthUserProvider({ initialUser = null, children }) {
173
178
  try {
174
179
  const { reload } = await getFirebaseAuthModule();
175
180
  await reload(user);
176
- await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
181
+ // `getIdToken(true)` can occasionally hand back a token that's
182
+ // no newer than the one already in the session cookie — a stale
183
+ // token caught mid-propagation right after `reload()`, rather
184
+ // than the fresh mint the caller asked for. Comparing `iat`
185
+ // (issued-at) against the existing cookie's token is a direct
186
+ // check that the token we're about to write is actually new;
187
+ // retry a few times if it isn't before giving up and writing
188
+ // whatever we got. The confirmed token is threaded into
189
+ // `writeSession` directly — letting it call `getIdToken(true)`
190
+ // again on its own could re-fetch and land back on a stale
191
+ // token, undoing this retry entirely.
192
+ const previousIat = decodeJwtPayload(getCookie(sessionCookieName) ?? '')?.iat;
193
+ let confirmedToken;
194
+ if (previousIat !== undefined) {
195
+ const maxAttempts = 3;
196
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
197
+ const freshToken = await user.getIdToken(true);
198
+ confirmedToken = freshToken;
199
+ const freshIat = decodeJwtPayload(freshToken)?.iat;
200
+ if (freshIat !== undefined && freshIat > previousIat)
201
+ break;
202
+ if (attempt < maxAttempts - 1)
203
+ await sleep(500);
204
+ }
205
+ }
206
+ await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName, confirmedToken);
177
207
  setAuthUserCache(user);
178
208
  setState({ user, loading: false });
179
209
  }
@@ -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();
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.11",
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",