cloudflare-next-intl 0.3.1 → 0.3.3
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.
- package/dist/src/client/components/client_provider.d.ts +3 -1
- package/dist/src/client/components/client_provider.js +10 -3
- package/dist/src/client/functions/get_cookie.js +16 -2
- package/dist/src/config/middleware.js +5 -2
- package/dist/src/firebase_auth/client/auth_actions.bench.js +1 -0
- package/dist/src/firebase_auth/client/auth_actions.js +4 -4
- package/dist/src/firebase_auth/client/auth_user_provider.js +35 -20
- package/dist/src/firebase_auth/client/firebase_client.d.ts +2 -0
- package/dist/src/firebase_auth/client/firebase_client.js +8 -0
- package/dist/src/firebase_auth/error_messages/firebase_auth_error_helper.js +3 -2
- package/dist/src/firebase_auth/index.d.ts +1 -1
- package/dist/src/firebase_auth/index.js +1 -1
- package/dist/src/firebase_auth/middleware/update_session.d.ts +2 -2
- package/dist/src/firebase_auth/middleware/update_session.js +60 -16
- package/dist/src/firebase_auth/server/auth_user_server_provider.d.ts +10 -5
- package/dist/src/firebase_auth/server/auth_user_server_provider.js +10 -5
- package/dist/src/firebase_auth/server/firebase_server.js +10 -3
- package/dist/src/server/components/server_provider.js +8 -4
- package/dist/src/server/functions/server.js +5 -2
- package/dist/src/types/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -5,10 +5,12 @@ interface LocaleContextType {
|
|
|
5
5
|
messages: TranslationObject;
|
|
6
6
|
}
|
|
7
7
|
export declare const LocaleContext: import("react").Context<LocaleContextType | undefined>;
|
|
8
|
-
export default function LocationzationClientProvider({ language, messages, initialAuthUser, children }: {
|
|
8
|
+
export default function LocationzationClientProvider({ language, messages, initialAuthUser, skipAuthProvider, children }: {
|
|
9
9
|
language: string;
|
|
10
10
|
messages: TranslationObject;
|
|
11
11
|
initialAuthUser?: SerializedAuthUser | null;
|
|
12
|
+
/** Set when `firebaseAuth.autoWireClientProvider` is `false` — skips wrapping `children` in the client `AuthUserProvider` entirely. */
|
|
13
|
+
skipAuthProvider?: boolean;
|
|
12
14
|
children: React.ReactNode;
|
|
13
15
|
}): Component;
|
|
14
16
|
export {};
|
|
@@ -5,7 +5,15 @@ import { createContext, useMemo } from "react";
|
|
|
5
5
|
import dynamic from "next/dynamic";
|
|
6
6
|
import config from "@intl-config";
|
|
7
7
|
export const LocaleContext = createContext(undefined);
|
|
8
|
-
|
|
8
|
+
// Hoisted to module scope — calling `dynamic()` inside the component body
|
|
9
|
+
// creates a brand-new component identity every render, forcing React to
|
|
10
|
+
// unmount/remount `AuthUserProvider` on every render instead of reusing the
|
|
11
|
+
// existing instance. That remount re-subscribes `onIdTokenChanged`, which
|
|
12
|
+
// Firebase immediately replays with the current user, triggering a state
|
|
13
|
+
// update (and a `getIdToken(true)` refresh) that causes another render —
|
|
14
|
+
// an infinite loop of session-cookie writes, one per render.
|
|
15
|
+
const AuthUserProvider = dynamic(() => import("../../firebase_auth/client/auth_user_provider"));
|
|
16
|
+
export default function LocationzationClientProvider({ language, messages, initialAuthUser = null, skipAuthProvider = false, children }) {
|
|
9
17
|
setLocaleCache(language);
|
|
10
18
|
setMessageForLocaleCache(language, messages);
|
|
11
19
|
// `LocaleContext.Provider` stays the outermost element here — the
|
|
@@ -14,8 +22,7 @@ export default function LocationzationClientProvider({ language, messages, initi
|
|
|
14
22
|
// sibling wrapping it, or those hooks would throw for running outside
|
|
15
23
|
// the provider.
|
|
16
24
|
let providedChildren = children;
|
|
17
|
-
if (config.firebaseAuth) {
|
|
18
|
-
const AuthUserProvider = dynamic(() => import("../../firebase_auth/client/auth_user_provider"));
|
|
25
|
+
if (config.firebaseAuth && !skipAuthProvider) {
|
|
19
26
|
providedChildren = _jsx(AuthUserProvider, { initialUser: initialAuthUser, children: children });
|
|
20
27
|
}
|
|
21
28
|
const contextValue = useMemo(() => ({ language, messages }), [language, messages]);
|
|
@@ -10,8 +10,22 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export default function getCookie(name) {
|
|
12
12
|
try {
|
|
13
|
-
const
|
|
14
|
-
|
|
13
|
+
const cookie = document.cookie;
|
|
14
|
+
const prefix = `${name}=`;
|
|
15
|
+
let start = -1;
|
|
16
|
+
if (cookie.startsWith(prefix)) {
|
|
17
|
+
start = prefix.length;
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const idx = cookie.indexOf(`; ${prefix}`);
|
|
21
|
+
if (idx !== -1)
|
|
22
|
+
start = idx + 2 + prefix.length;
|
|
23
|
+
}
|
|
24
|
+
if (start === -1)
|
|
25
|
+
return null;
|
|
26
|
+
const end = cookie.indexOf(';', start);
|
|
27
|
+
const value = end === -1 ? cookie.slice(start) : cookie.slice(start, end);
|
|
28
|
+
return decodeURIComponent(value);
|
|
15
29
|
}
|
|
16
30
|
catch (e) {
|
|
17
31
|
console.error(`Get cookie on client side error: ${e}`);
|
|
@@ -11,13 +11,16 @@ const defaultCookieOption = {
|
|
|
11
11
|
secure: false, // Send cookie only over HTTPS in production
|
|
12
12
|
sameSite: sameSite, // Protection against CSRF attacks. 'strict' or 'lax' are good choices.
|
|
13
13
|
};
|
|
14
|
+
let userAgentModule;
|
|
14
15
|
async function getIsBotValue(userAgent) {
|
|
15
16
|
if (userAgent === null)
|
|
16
17
|
return false;
|
|
17
|
-
|
|
18
|
+
if (!userAgentModule) {
|
|
19
|
+
userAgentModule = await import('next/dist/server/web/spec-extension/user-agent');
|
|
20
|
+
}
|
|
18
21
|
// Unreachable: userAgent is already narrowed to non-null string above,
|
|
19
22
|
// so the ?? '' fallback never triggers.
|
|
20
|
-
return isBot(userAgent ?? '');
|
|
23
|
+
return userAgentModule.isBot(userAgent ?? '');
|
|
21
24
|
}
|
|
22
25
|
const getIsBotValueCache = cache(getIsBotValue);
|
|
23
26
|
export const localesSet = new Set(config.locales);
|
|
@@ -11,6 +11,7 @@ const fa = {
|
|
|
11
11
|
vi.mock('@intl-config', () => ({ default: { firebaseAuth: fa } }));
|
|
12
12
|
vi.mock('./firebase_client', () => ({
|
|
13
13
|
getFirebaseAuthClient: vi.fn(async () => ({ auth: {} })),
|
|
14
|
+
getFirebaseAuthModule: () => import('firebase/auth'),
|
|
14
15
|
}));
|
|
15
16
|
vi.mock('../error_messages/firebase_auth_error_helper', () => ({
|
|
16
17
|
default: vi.fn(() => 'translated error'),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import config from '@intl-config';
|
|
3
3
|
import requireFirebaseAuthConfig from '../require_config';
|
|
4
|
-
import { getFirebaseAuthClient } from './firebase_client';
|
|
4
|
+
import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client';
|
|
5
5
|
import firebaseAuthErrorMessage from '../error_messages/firebase_auth_error_helper';
|
|
6
6
|
function readCredentials(formData) {
|
|
7
7
|
return {
|
|
@@ -27,7 +27,7 @@ export function createLoginAction(locale, messages) {
|
|
|
27
27
|
return async function loginAction(_prevState, formData) {
|
|
28
28
|
requireFirebaseAuthConfig(config.firebaseAuth);
|
|
29
29
|
const { auth } = await getFirebaseAuthClient();
|
|
30
|
-
const { signInWithEmailAndPassword } = await
|
|
30
|
+
const { signInWithEmailAndPassword } = await getFirebaseAuthModule();
|
|
31
31
|
const { email, password } = readCredentials(formData);
|
|
32
32
|
try {
|
|
33
33
|
await signInWithEmailAndPassword(auth, email, password);
|
|
@@ -57,7 +57,7 @@ export function createSignUpAction(locale, messages) {
|
|
|
57
57
|
return async function signUpAction(_prevState, formData) {
|
|
58
58
|
requireFirebaseAuthConfig(config.firebaseAuth);
|
|
59
59
|
const { auth } = await getFirebaseAuthClient();
|
|
60
|
-
const { createUserWithEmailAndPassword } = await
|
|
60
|
+
const { createUserWithEmailAndPassword } = await getFirebaseAuthModule();
|
|
61
61
|
const { email, password } = readCredentials(formData);
|
|
62
62
|
const confirmPassword = (formData.get('confirmPassword')?.toString() ?? '').trim();
|
|
63
63
|
if (messages.mismatch && password !== confirmPassword) {
|
|
@@ -89,7 +89,7 @@ export function createForgotPasswordAction(locale, messages) {
|
|
|
89
89
|
return async function forgotPasswordAction(_prevState, formData) {
|
|
90
90
|
requireFirebaseAuthConfig(config.firebaseAuth);
|
|
91
91
|
const { auth } = await getFirebaseAuthClient();
|
|
92
|
-
const { sendPasswordResetEmail } = await
|
|
92
|
+
const { sendPasswordResetEmail } = await getFirebaseAuthModule();
|
|
93
93
|
const email = (formData.get('email')?.toString() ?? '').trim();
|
|
94
94
|
try {
|
|
95
95
|
await sendPasswordResetEmail(auth, email);
|
|
@@ -5,18 +5,19 @@ import { useRouter } from 'next/navigation';
|
|
|
5
5
|
import usePathname from '../../client/hooks/use_path_name';
|
|
6
6
|
import config from '@intl-config';
|
|
7
7
|
import requireFirebaseAuthConfig from '../require_config';
|
|
8
|
-
import { getFirebaseAuthClient } from './firebase_client';
|
|
8
|
+
import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client';
|
|
9
9
|
import { setAuthUserCache } from './auth_user_cache';
|
|
10
|
-
import {
|
|
10
|
+
import { defaultSessionCookieName } from '../middleware/update_session';
|
|
11
|
+
import setCookie from '../../client/functions/set_cookie';
|
|
11
12
|
// `null` default (instead of a `{ loading: true, ... }` stand-in) lets
|
|
12
13
|
// `useAuthUser` distinguish "not wrapped in AuthUserProvider" (throw) from
|
|
13
14
|
// "wrapped, still loading" (`loading: true`).
|
|
14
15
|
export const AuthUserContext = createContext(null);
|
|
15
|
-
function writeSessionCookie(idToken, maxAge) {
|
|
16
|
-
|
|
16
|
+
function writeSessionCookie(sessionCookieName, idToken, maxAge) {
|
|
17
|
+
setCookie({ name: sessionCookieName, value: idToken, maxAge });
|
|
17
18
|
}
|
|
18
|
-
function clearSessionCookie() {
|
|
19
|
-
|
|
19
|
+
function clearSessionCookie(sessionCookieName) {
|
|
20
|
+
setCookie({ name: sessionCookieName, value: '', maxAge: 0 });
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
23
|
* Client-side auth-state provider for `firebase_auth`. Wrap your root layout
|
|
@@ -44,11 +45,20 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
44
45
|
const isAuthPage = fa.isAuthPath(pathname);
|
|
45
46
|
const isWhiteListed = fa.whiteListPaths?.includes(pathname) ?? false;
|
|
46
47
|
const maxAge = fa.sessionCookieMaxAge ?? 60 * 60 * 24 * 5;
|
|
48
|
+
const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
|
|
47
49
|
const [state, setState] = useState({
|
|
48
50
|
user: initialUser,
|
|
49
51
|
loading: initialUser === null,
|
|
50
52
|
});
|
|
53
|
+
// The signed-in state the last successful cookie write left behind, so a
|
|
54
|
+
// plain token refresh (same state) does not trigger a needless re-render.
|
|
51
55
|
const syncedSignedIn = useRef(undefined);
|
|
56
|
+
// Consecutive `onIdTokenChanged(null)` callbacks since the last confirmed
|
|
57
|
+
// user. A single null here can be a transient client-SDK hiccup (e.g. its
|
|
58
|
+
// token-refresh scheduling misbehaving under local clock skew) rather
|
|
59
|
+
// than a real sign-out — the server already proved the session valid via
|
|
60
|
+
// `initialUser`, so redirecting on the very first null caused a
|
|
61
|
+
// login-then-bounce-home flash whenever the two disagreed.
|
|
52
62
|
const consecutiveNulls = useRef(0);
|
|
53
63
|
const [confirmedSignedOut, setConfirmedSignedOut] = useState(initialUser === null);
|
|
54
64
|
useEffect(() => {
|
|
@@ -70,16 +80,16 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
70
80
|
getFirebaseAuthClient().then(async ({ auth }) => {
|
|
71
81
|
if (cancelled)
|
|
72
82
|
return;
|
|
73
|
-
const { onIdTokenChanged } = await
|
|
83
|
+
const { onIdTokenChanged } = await getFirebaseAuthModule();
|
|
74
84
|
unsubscribe = onIdTokenChanged(auth, async (user) => {
|
|
75
85
|
const isSignedIn = !!user;
|
|
76
86
|
const previous = syncedSignedIn.current;
|
|
77
87
|
try {
|
|
78
88
|
if (user) {
|
|
79
|
-
writeSessionCookie(await user.getIdToken(true), maxAge);
|
|
89
|
+
writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
|
|
80
90
|
}
|
|
81
91
|
else if (previous) {
|
|
82
|
-
clearSessionCookie();
|
|
92
|
+
clearSessionCookie(sessionCookieName);
|
|
83
93
|
}
|
|
84
94
|
}
|
|
85
95
|
catch (e) {
|
|
@@ -112,37 +122,42 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
112
122
|
unsubscribe?.();
|
|
113
123
|
};
|
|
114
124
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
115
|
-
}, [router, isAuthPage, maxAge]);
|
|
125
|
+
}, [router, isAuthPage, maxAge, sessionCookieName]);
|
|
116
126
|
const reloadUser = useCallback(async () => {
|
|
117
127
|
const { auth } = await getFirebaseAuthClient();
|
|
118
128
|
const user = auth.currentUser;
|
|
119
129
|
if (!user)
|
|
120
130
|
return;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
try {
|
|
132
|
+
const { reload } = await getFirebaseAuthModule();
|
|
133
|
+
await reload(user);
|
|
134
|
+
writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
|
|
135
|
+
setAuthUserCache(user);
|
|
136
|
+
setState({ user, loading: false });
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
console.error('AuthUserProvider: reloadUser failed', e);
|
|
140
|
+
}
|
|
141
|
+
}, []);
|
|
127
142
|
const sendVerificationEmail = useCallback(async () => {
|
|
128
143
|
const { auth } = await getFirebaseAuthClient();
|
|
129
144
|
const user = auth.currentUser;
|
|
130
145
|
if (!user)
|
|
131
146
|
return;
|
|
132
|
-
const { sendEmailVerification } = await
|
|
147
|
+
const { sendEmailVerification } = await getFirebaseAuthModule();
|
|
133
148
|
await sendEmailVerification(user);
|
|
134
149
|
}, []);
|
|
135
150
|
const logout = useCallback(async () => {
|
|
136
151
|
try {
|
|
137
152
|
const { auth } = await getFirebaseAuthClient();
|
|
138
|
-
const { signOut } = await
|
|
153
|
+
const { signOut } = await getFirebaseAuthModule();
|
|
139
154
|
await signOut(auth);
|
|
140
155
|
}
|
|
141
156
|
finally {
|
|
142
|
-
clearSessionCookie();
|
|
157
|
+
clearSessionCookie(sessionCookieName);
|
|
143
158
|
window.location.assign(fa.redirectAuthPath);
|
|
144
159
|
}
|
|
145
160
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
146
|
-
}, [fa.redirectAuthPath]);
|
|
161
|
+
}, [fa.redirectAuthPath, sessionCookieName]);
|
|
147
162
|
return _jsx(AuthUserContext.Provider, { value: { ...state, reloadUser, sendVerificationEmail, logout }, children: children });
|
|
148
163
|
}
|
|
@@ -16,3 +16,5 @@ export declare function getFirebaseAuthClientSync(): {
|
|
|
16
16
|
app: FirebaseApp;
|
|
17
17
|
auth: Auth;
|
|
18
18
|
} | undefined;
|
|
19
|
+
/** Memoized `import('firebase/auth')` — see {@link getFirebaseAuthClient} for why this is worth caching. */
|
|
20
|
+
export declare function getFirebaseAuthModule(): Promise<typeof import('firebase/auth')>;
|
|
@@ -38,3 +38,11 @@ export async function getFirebaseAuthClient() {
|
|
|
38
38
|
export function getFirebaseAuthClientSync() {
|
|
39
39
|
return cached;
|
|
40
40
|
}
|
|
41
|
+
let cachedAuthModule;
|
|
42
|
+
/** Memoized `import('firebase/auth')` — see {@link getFirebaseAuthClient} for why this is worth caching. */
|
|
43
|
+
export function getFirebaseAuthModule() {
|
|
44
|
+
if (!cachedAuthModule) {
|
|
45
|
+
cachedAuthModule = import('firebase/auth');
|
|
46
|
+
}
|
|
47
|
+
return cachedAuthModule;
|
|
48
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getTranslationsImpl } from '../../general/general_functions';
|
|
2
|
-
import { getMessageCache } from '../../general/cache_variables';
|
|
2
|
+
import { getMessageCache, getTranslationCache } from '../../general/cache_variables';
|
|
3
3
|
import { DEFAULT_MESSAGES_EN } from './default_messages.en';
|
|
4
4
|
const ERROR_CODE_TO_KEY = {
|
|
5
5
|
'auth/invalid-email': 'invalidEmail',
|
|
@@ -29,7 +29,8 @@ export default function firebaseAuthErrorMessage(locale, error) {
|
|
|
29
29
|
const messages = getMessageCache(locale);
|
|
30
30
|
if (messages) {
|
|
31
31
|
try {
|
|
32
|
-
const
|
|
32
|
+
const cacheKey = `${locale}-firebaseAuth`;
|
|
33
|
+
const t = getTranslationCache(cacheKey) ?? getTranslationsImpl(locale, messages, 'firebaseAuth', cacheKey);
|
|
33
34
|
const translated = t(key);
|
|
34
35
|
if (typeof translated === 'string' && translated !== key)
|
|
35
36
|
return translated;
|
|
@@ -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,
|
|
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,
|
|
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
|
|
3
|
-
export declare const
|
|
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
|
|
4
|
-
export const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
if (
|
|
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 (
|
|
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) {
|
|
@@ -184,11 +232,7 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
184
232
|
}
|
|
185
233
|
if (refreshedToken) {
|
|
186
234
|
response.cookies.set(sessionCookieName, refreshedToken.idToken, {
|
|
187
|
-
|
|
188
|
-
// directly (via document.cookie) after `getIdToken(true)`, so it
|
|
189
|
-
// must stay client-writable — a JS cookie write can never carry
|
|
190
|
-
// httpOnly anyway, and two same-name cookies with conflicting
|
|
191
|
-
// flags is what actually caused ambiguity here.
|
|
235
|
+
httpOnly: true,
|
|
192
236
|
secure: request.nextUrl.protocol === 'https',
|
|
193
237
|
sameSite: 'lax',
|
|
194
238
|
path: '/',
|
|
@@ -3,11 +3,16 @@ import type { SerializedAuthUser } from '../types';
|
|
|
3
3
|
* Resolves the signed-in user from the session cookie and performs the
|
|
4
4
|
* authoritative pre-render redirect (guest→`redirectAuthPath`, signed-in→
|
|
5
5
|
* `homePath` on auth pages) — middleware only checks cookie *presence*, not
|
|
6
|
-
* validity
|
|
7
|
-
*
|
|
8
|
-
* (
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
6
|
+
* validity; a forged, expired, or otherwise invalid-but-present cookie
|
|
7
|
+
* sails through it. Only this function's token validation
|
|
8
|
+
* (`getAuthenticatedAppForUser`) catches that, so this redirect must happen
|
|
9
|
+
* here, before any HTML is sent — relying solely on the client
|
|
10
|
+
* `AuthUserProvider` effect to redirect afterwards produces a visible
|
|
11
|
+
* flash (page renders signed-in, then bounces). Plain async function, not
|
|
12
|
+
* a component: callers decide where/how to use the resolved user relative
|
|
13
|
+
* to their own component tree (see `AuthUserServerProvider` below for the
|
|
14
|
+
* simple case, and `IntlProvider`'s auto-wiring for the case where ordering
|
|
15
|
+
* against `LocaleContext` matters).
|
|
11
16
|
*/
|
|
12
17
|
export declare function resolveAuthUserAndRedirect(): Promise<SerializedAuthUser | null>;
|
|
13
18
|
/**
|
|
@@ -10,11 +10,16 @@ const AuthUserProvider = dynamic(() => import('../client/auth_user_provider'));
|
|
|
10
10
|
* Resolves the signed-in user from the session cookie and performs the
|
|
11
11
|
* authoritative pre-render redirect (guest→`redirectAuthPath`, signed-in→
|
|
12
12
|
* `homePath` on auth pages) — middleware only checks cookie *presence*, not
|
|
13
|
-
* validity
|
|
14
|
-
*
|
|
15
|
-
* (
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
13
|
+
* validity; a forged, expired, or otherwise invalid-but-present cookie
|
|
14
|
+
* sails through it. Only this function's token validation
|
|
15
|
+
* (`getAuthenticatedAppForUser`) catches that, so this redirect must happen
|
|
16
|
+
* here, before any HTML is sent — relying solely on the client
|
|
17
|
+
* `AuthUserProvider` effect to redirect afterwards produces a visible
|
|
18
|
+
* flash (page renders signed-in, then bounces). Plain async function, not
|
|
19
|
+
* a component: callers decide where/how to use the resolved user relative
|
|
20
|
+
* to their own component tree (see `AuthUserServerProvider` below for the
|
|
21
|
+
* simple case, and `IntlProvider`'s auto-wiring for the case where ordering
|
|
22
|
+
* against `LocaleContext` matters).
|
|
18
23
|
*/
|
|
19
24
|
export async function resolveAuthUserAndRedirect() {
|
|
20
25
|
const fa = config.firebaseAuth;
|
|
@@ -2,8 +2,10 @@ 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 {
|
|
5
|
+
import { defaultSessionCookieName } from '../middleware/update_session';
|
|
6
6
|
let baseApp;
|
|
7
|
+
let firebaseAppModule;
|
|
8
|
+
let firebaseAuthModule;
|
|
7
9
|
/**
|
|
8
10
|
* Resolves the signed-in user on the server from the session cookie.
|
|
9
11
|
* `initializeServerApp` validates the token with the Auth service, so a
|
|
@@ -16,13 +18,18 @@ let baseApp;
|
|
|
16
18
|
export const getAuthenticatedAppForUser = cache(async function getAuthenticatedAppForUser() {
|
|
17
19
|
const fa = config.firebaseAuth;
|
|
18
20
|
requireFirebaseAuthConfig(fa);
|
|
21
|
+
const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
|
|
19
22
|
const authIdToken = (await cookies()).get(sessionCookieName)?.value;
|
|
20
23
|
if (!authIdToken) {
|
|
21
24
|
return { firebaseServerApp: null, currentUser: null };
|
|
22
25
|
}
|
|
23
26
|
try {
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
if (!firebaseAppModule)
|
|
28
|
+
firebaseAppModule = await import('firebase/app');
|
|
29
|
+
if (!firebaseAuthModule)
|
|
30
|
+
firebaseAuthModule = await import('firebase/auth');
|
|
31
|
+
const { initializeApp, initializeServerApp } = firebaseAppModule;
|
|
32
|
+
const { getAuth } = firebaseAuthModule;
|
|
26
33
|
const firebaseConfig = {
|
|
27
34
|
apiKey: fa.apiKey,
|
|
28
35
|
authDomain: fa.authDomain,
|
|
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
|
|
|
5
5
|
import { localesSet } from "../../config/middleware";
|
|
6
6
|
import config from "../../config/intl_config";
|
|
7
7
|
const LocationzationClientProvider = dynamic(() => import("../../client/components/client_provider"));
|
|
8
|
+
let authUserServerProviderModule;
|
|
8
9
|
/**
|
|
9
10
|
* Server component that provides locale/messages context to the rest of the
|
|
10
11
|
* tree. Exported publicly as `IntlProvider` from `cloudflare-next-intl/serverProvider`.
|
|
@@ -47,9 +48,12 @@ export default async function LocationzationProvider({ language, messages, child
|
|
|
47
48
|
}
|
|
48
49
|
const messagesValue = messages ?? await getMessage(language);
|
|
49
50
|
let initialAuthUser = null;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const autoWireClientProvider = config.firebaseAuth?.autoWireClientProvider !== false;
|
|
52
|
+
if (config.firebaseAuth && autoWireClientProvider) {
|
|
53
|
+
if (!authUserServerProviderModule) {
|
|
54
|
+
authUserServerProviderModule = await import("../../firebase_auth/server/auth_user_server_provider");
|
|
55
|
+
}
|
|
56
|
+
initialAuthUser = await authUserServerProviderModule.resolveAuthUserAndRedirect();
|
|
53
57
|
}
|
|
54
|
-
return _jsx(LocationzationClientProvider, { language: language, messages: messagesValue, initialAuthUser: initialAuthUser, children: children });
|
|
58
|
+
return _jsx(LocationzationClientProvider, { language: language, messages: messagesValue, initialAuthUser: initialAuthUser, skipAuthProvider: !autoWireClientProvider, children: children });
|
|
55
59
|
}
|
|
@@ -5,6 +5,7 @@ import { getLocaleCache, getMessageCache, getTranslationCache, setLocaleCache, s
|
|
|
5
5
|
import { cache } from "react";
|
|
6
6
|
import { localesSet } from "../../config/middleware";
|
|
7
7
|
const isDev = process.env.NODE_ENV === 'development';
|
|
8
|
+
let nextHeadersModule;
|
|
8
9
|
/**
|
|
9
10
|
* Loads and caches messages for a specific locale using dynamic import.
|
|
10
11
|
* Prevents redundant file loads and handles import errors gracefully.
|
|
@@ -104,8 +105,10 @@ async function iGetLocale() {
|
|
|
104
105
|
// Dynamically import "next/headers" only when needed.
|
|
105
106
|
// This ensures it's loaded only on the server where cookies are accessible,
|
|
106
107
|
// preventing client-side import errors and reducing bundle size.
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
if (!nextHeadersModule) {
|
|
109
|
+
nextHeadersModule = await import("next/headers");
|
|
110
|
+
}
|
|
111
|
+
const cookieStore = await nextHeadersModule.cookies();
|
|
109
112
|
const localeCookie = cookieStore.get(localeCookieName);
|
|
110
113
|
// Use the cookie value or fall back to the default locale.
|
|
111
114
|
const localeValue = localeCookie?.value ?? config.defaultLocale;
|
|
@@ -92,6 +92,17 @@ export interface FirebaseAuthRoutingConfig {
|
|
|
92
92
|
* middleware redirect logic yourself instead.
|
|
93
93
|
*/
|
|
94
94
|
middlewareEnabled?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Whether `IntlProvider` should automatically wrap your app in the
|
|
97
|
+
* client `AuthUserProvider` and call `resolveAuthUser` server-side.
|
|
98
|
+
* Defaults to `true`. Set `false` if you drive auth entirely from your
|
|
99
|
+
* own middleware (like `middlewareEnabled: false`'s manual-override
|
|
100
|
+
* case, but for the client/RSC layer) and don't want this package
|
|
101
|
+
* rendering any auth-related React tree on top of it — e.g. if you
|
|
102
|
+
* only use `intlMiddleware`'s built-in session-refresh/redirect logic
|
|
103
|
+
* and have no use for `useAuthUser()`/`AuthUserProvider` at all.
|
|
104
|
+
*/
|
|
105
|
+
autoWireClientProvider?: boolean;
|
|
95
106
|
/** Firebase project's Web API key (`NEXT_PUBLIC_FIREBASE_API_KEY` equivalent). */
|
|
96
107
|
apiKey: string;
|
|
97
108
|
/** Firebase project's auth domain, e.g. "my-app.firebaseapp.com". */
|
|
@@ -120,6 +131,10 @@ export interface FirebaseAuthRoutingConfig {
|
|
|
120
131
|
sessionCookieMaxAge?: number;
|
|
121
132
|
/** Refresh-token cookie max-age in seconds. Defaults to 365 days (31536000). */
|
|
122
133
|
refreshTokenCookieMaxAge?: number;
|
|
134
|
+
/** Session cookie name. Defaults to `'__fa_session__'`. Override this if your app already uses a different name for its Firebase ID-token cookie. */
|
|
135
|
+
sessionCookieName?: string;
|
|
136
|
+
/** 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. */
|
|
137
|
+
refreshTokenCookieName?: string;
|
|
123
138
|
}
|
|
124
139
|
export interface CookieAttributes {
|
|
125
140
|
/**
|