cloudflare-next-intl 0.3.1 → 0.3.2

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,15 +7,15 @@ import config from '@intl-config';
7
7
  import requireFirebaseAuthConfig from '../require_config';
8
8
  import { getFirebaseAuthClient } from './firebase_client';
9
9
  import { setAuthUserCache } from './auth_user_cache';
10
- import { sessionCookieName } from '../middleware/update_session';
10
+ import { defaultSessionCookieName } from '../middleware/update_session';
11
11
  // `null` default (instead of a `{ loading: true, ... }` stand-in) lets
12
12
  // `useAuthUser` distinguish "not wrapped in AuthUserProvider" (throw) from
13
13
  // "wrapped, still loading" (`loading: true`).
14
14
  export const AuthUserContext = createContext(null);
15
- function writeSessionCookie(idToken, maxAge) {
15
+ function writeSessionCookie(sessionCookieName, idToken, maxAge) {
16
16
  document.cookie = `${sessionCookieName}=${idToken}; path=/; max-age=${maxAge}`;
17
17
  }
18
- function clearSessionCookie() {
18
+ function clearSessionCookie(sessionCookieName) {
19
19
  document.cookie = `${sessionCookieName}=; path=/; max-age=0`;
20
20
  }
21
21
  /**
@@ -44,6 +44,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
44
44
  const isAuthPage = fa.isAuthPath(pathname);
45
45
  const isWhiteListed = fa.whiteListPaths?.includes(pathname) ?? false;
46
46
  const maxAge = fa.sessionCookieMaxAge ?? 60 * 60 * 24 * 5;
47
+ const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
47
48
  const [state, setState] = useState({
48
49
  user: initialUser,
49
50
  loading: initialUser === null,
@@ -76,10 +77,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
76
77
  const previous = syncedSignedIn.current;
77
78
  try {
78
79
  if (user) {
79
- writeSessionCookie(await user.getIdToken(true), maxAge);
80
+ writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
80
81
  }
81
82
  else if (previous) {
82
- clearSessionCookie();
83
+ clearSessionCookie(sessionCookieName);
83
84
  }
84
85
  }
85
86
  catch (e) {
@@ -112,7 +113,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
112
113
  unsubscribe?.();
113
114
  };
114
115
  // eslint-disable-next-line react-hooks/exhaustive-deps
115
- }, [router, isAuthPage, maxAge]);
116
+ }, [router, isAuthPage, maxAge, sessionCookieName]);
116
117
  const reloadUser = useCallback(async () => {
117
118
  const { auth } = await getFirebaseAuthClient();
118
119
  const user = auth.currentUser;
@@ -120,10 +121,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
120
121
  return;
121
122
  const { reload } = await import('firebase/auth');
122
123
  await reload(user);
123
- writeSessionCookie(await user.getIdToken(true), maxAge);
124
+ writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
124
125
  setAuthUserCache(user);
125
126
  setState({ user, loading: false });
126
- }, [maxAge]);
127
+ }, [maxAge, sessionCookieName]);
127
128
  const sendVerificationEmail = useCallback(async () => {
128
129
  const { auth } = await getFirebaseAuthClient();
129
130
  const user = auth.currentUser;
@@ -139,10 +140,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
139
140
  await signOut(auth);
140
141
  }
141
142
  finally {
142
- clearSessionCookie();
143
+ clearSessionCookie(sessionCookieName);
143
144
  window.location.assign(fa.redirectAuthPath);
144
145
  }
145
146
  // eslint-disable-next-line react-hooks/exhaustive-deps
146
- }, [fa.redirectAuthPath]);
147
+ }, [fa.redirectAuthPath, sessionCookieName]);
147
148
  return _jsx(AuthUserContext.Provider, { value: { ...state, reloadUser, sendVerificationEmail, logout }, children: children });
148
149
  }
@@ -3,7 +3,7 @@ export { default as FirebaseAuthServerProvider } from './server/auth_user_server
3
3
  export { default as useFirebaseAuthUserClient } from './client/use_auth_user';
4
4
  export { default as useFirebaseAuthUserServer } from './server/use_auth_user_server';
5
5
  export { createLoginAction, createSignUpAction, createForgotPasswordAction } from './client/auth_actions';
6
- export { default as updateFirebaseAuthSession, sessionCookieName as firebaseAuthSessionCookieName } from './middleware/update_session';
6
+ export { default as updateFirebaseAuthSession, defaultSessionCookieName as firebaseAuthSessionCookieName } from './middleware/update_session';
7
7
  export { getFirebaseAuthClient } from './client/firebase_client';
8
8
  export type { SerializedAuthUser, AuthFormState, AuthActionMessages, AuthUser } from './types';
9
9
  export type { FirebaseAuthRoutingConfig } from '../types/types';
@@ -9,5 +9,5 @@ export { default as FirebaseAuthServerProvider } from './server/auth_user_server
9
9
  export { default as useFirebaseAuthUserClient } from './client/use_auth_user';
10
10
  export { default as useFirebaseAuthUserServer } from './server/use_auth_user_server';
11
11
  export { createLoginAction, createSignUpAction, createForgotPasswordAction } from './client/auth_actions';
12
- export { default as updateFirebaseAuthSession, sessionCookieName as firebaseAuthSessionCookieName } from './middleware/update_session';
12
+ export { default as updateFirebaseAuthSession, defaultSessionCookieName as firebaseAuthSessionCookieName } from './middleware/update_session';
13
13
  export { getFirebaseAuthClient } from './client/firebase_client';
@@ -1,6 +1,6 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- export declare const sessionCookieName = "__fa_session__";
3
- export declare const refreshTokenCookieName = "__fa_refresh_token__";
2
+ export declare const defaultSessionCookieName = "__fa_session__";
3
+ export declare const defaultRefreshTokenCookieName = "__fa_refresh_token__";
4
4
  /**
5
5
  * Layers Firebase session-cookie validation/refresh and auth redirects onto
6
6
  * an already-built middleware response. Called internally by `intlMiddleware`
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import config from '@intl-config';
3
- export const sessionCookieName = '__fa_session__';
4
- export const refreshTokenCookieName = '__fa_refresh_token__';
3
+ export const defaultSessionCookieName = '__fa_session__';
4
+ export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
5
5
  const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
6
6
  const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
7
7
  // Refresh slightly before the real expiry — treating a token as expired
@@ -77,6 +77,21 @@ async function setCachedRefresh(refreshToken, refreshed) {
77
77
  // refresh result already returned to the caller.
78
78
  }
79
79
  }
80
+ // Google's Secure Token API returns 400 with one of these error codes when
81
+ // the refresh token itself is the problem (expired/revoked/malformed/the
82
+ // associated user no longer exists) — this is the ONLY case that should
83
+ // sign the user out. Any other failure (5xx, network error, timeout,
84
+ // unrecognized 400 body) is transient/unexpected and must NOT clear the
85
+ // refresh-token cookie or redirect to login: doing so previously caused a
86
+ // signed-in user with a perfectly valid refresh token to flash to /login
87
+ // and bounce back home the moment their ID token merely expired and a
88
+ // single refresh attempt happened to fail.
89
+ const INVALID_REFRESH_TOKEN_ERRORS = new Set([
90
+ 'INVALID_REFRESH_TOKEN',
91
+ 'TOKEN_EXPIRED',
92
+ 'USER_DISABLED',
93
+ 'USER_NOT_FOUND',
94
+ ]);
80
95
  /**
81
96
  * Mints a fresh ID token from a stored refresh token via Google's Secure
82
97
  * Token API. No `firebase/auth` import: this runs in the Edge middleware
@@ -86,15 +101,28 @@ async function setCachedRefresh(refreshToken, refreshed) {
86
101
  async function refreshIdToken(apiKey, refreshToken) {
87
102
  const cached = await getCachedRefresh(refreshToken);
88
103
  if (cached)
89
- return cached;
104
+ return { status: 'refreshed', ...cached };
90
105
  try {
91
106
  const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${apiKey}`, {
92
107
  method: 'POST',
93
108
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
94
109
  body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`,
95
110
  });
96
- if (!res.ok)
97
- return null;
111
+ if (!res.ok) {
112
+ if (res.status === 400) {
113
+ try {
114
+ const errorBody = await res.json();
115
+ if (errorBody.error?.message && INVALID_REFRESH_TOKEN_ERRORS.has(errorBody.error.message)) {
116
+ return { status: 'invalid' };
117
+ }
118
+ }
119
+ catch {
120
+ // Unparseable body on a 400 — treat as transient rather
121
+ // than assuming the token is invalid.
122
+ }
123
+ }
124
+ return { status: 'transient-failure' };
125
+ }
98
126
  const data = await res.json();
99
127
  const refreshed = { idToken: data.id_token, refreshToken: data.refresh_token };
100
128
  // Not awaited: the cache write is a pure optimization for FUTURE
@@ -103,10 +131,10 @@ async function refreshIdToken(apiKey, refreshToken) {
103
131
  // setCachedRefresh already swallows its own errors, so a rejected
104
132
  // write here would otherwise surface as an unhandled rejection.
105
133
  void setCachedRefresh(refreshToken, refreshed);
106
- return refreshed;
134
+ return { status: 'refreshed', ...refreshed };
107
135
  }
108
136
  catch {
109
- return null;
137
+ return { status: 'transient-failure' };
110
138
  }
111
139
  }
112
140
  /**
@@ -131,6 +159,8 @@ export default async function updateSession(request, baseResponse, locale) {
131
159
  const fa = config.firebaseAuth;
132
160
  if (!fa || fa.middlewareEnabled === false)
133
161
  return baseResponse;
162
+ const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
163
+ const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
134
164
  const rawPath = request.nextUrl.pathname;
135
165
  const requestPrefix = `/${locale}`;
136
166
  const path = rawPath === requestPrefix || rawPath.startsWith(`${requestPrefix}/`)
@@ -149,19 +179,30 @@ export default async function updateSession(request, baseResponse, locale) {
149
179
  let token = request.cookies.get(sessionCookieName)?.value;
150
180
  let refreshedToken = null;
151
181
  let clearInvalidSession = false;
182
+ // A transient refresh failure (network blip, Google 5xx, timeout) means
183
+ // "couldn't confirm the session right now" — NOT "this user is signed
184
+ // out". Redirecting to login in that case is the bug this guards
185
+ // against: it signs a still-valid user out for a one-off hiccup, and
186
+ // the client SDK (which still has a live session independent of these
187
+ // cookies) then bounces them straight back, producing a login flash.
188
+ let refreshWasTransientFailure = false;
152
189
  if (token && isJwtExpired(token)) {
153
190
  token = undefined;
154
191
  }
155
192
  if (!token) {
156
193
  const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
157
194
  if (refreshToken) {
158
- refreshedToken = await refreshIdToken(fa.apiKey, refreshToken);
159
- if (refreshedToken) {
195
+ const result = await refreshIdToken(fa.apiKey, refreshToken);
196
+ if (result.status === 'refreshed') {
197
+ refreshedToken = { idToken: result.idToken, refreshToken: result.refreshToken };
160
198
  token = refreshedToken.idToken;
161
199
  }
162
- else {
200
+ else if (result.status === 'invalid') {
163
201
  clearInvalidSession = true;
164
202
  }
203
+ else {
204
+ refreshWasTransientFailure = true;
205
+ }
165
206
  }
166
207
  else if (request.cookies.get(sessionCookieName)) {
167
208
  clearInvalidSession = true;
@@ -169,7 +210,14 @@ export default async function updateSession(request, baseResponse, locale) {
169
210
  }
170
211
  const hasSession = !!token;
171
212
  let response;
172
- if (!hasSession) {
213
+ if (refreshWasTransientFailure) {
214
+ // Couldn't confirm the session either way — pass through without
215
+ // forcing a redirect in either direction. The next request (or the
216
+ // client SDK's own session, independent of these cookies) gets a
217
+ // chance to resolve this correctly instead of guessing wrong.
218
+ response = baseResponse;
219
+ }
220
+ else if (!hasSession) {
173
221
  response = isAuthPage ? baseResponse : buildRedirect(baseResponse, localeUrl(fa.redirectAuthPath));
174
222
  }
175
223
  else if (isAuthPage) {
@@ -2,7 +2,7 @@ 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 { sessionCookieName } from '../middleware/update_session';
5
+ import { defaultSessionCookieName } from '../middleware/update_session';
6
6
  let baseApp;
7
7
  /**
8
8
  * Resolves the signed-in user on the server from the session cookie.
@@ -16,6 +16,7 @@ let baseApp;
16
16
  export const getAuthenticatedAppForUser = cache(async function getAuthenticatedAppForUser() {
17
17
  const fa = config.firebaseAuth;
18
18
  requireFirebaseAuthConfig(fa);
19
+ const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
19
20
  const authIdToken = (await cookies()).get(sessionCookieName)?.value;
20
21
  if (!authIdToken) {
21
22
  return { firebaseServerApp: null, currentUser: null };
@@ -120,6 +120,10 @@ export interface FirebaseAuthRoutingConfig {
120
120
  sessionCookieMaxAge?: number;
121
121
  /** Refresh-token cookie max-age in seconds. Defaults to 365 days (31536000). */
122
122
  refreshTokenCookieMaxAge?: number;
123
+ /** Session cookie name. Defaults to `'__fa_session__'`. Override this if your app already uses a different name for its Firebase ID-token cookie. */
124
+ sessionCookieName?: string;
125
+ /** 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. */
126
+ refreshTokenCookieName?: string;
123
127
  }
124
128
  export interface CookieAttributes {
125
129
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",