cloudflare-next-intl 0.6.8 → 0.6.9
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,20 @@ function writeRefreshTokenCookie(refreshTokenCookieName, user, maxAge) {
|
|
|
26
26
|
function clearRefreshTokenCookie(refreshTokenCookieName) {
|
|
27
27
|
setCookie({ name: refreshTokenCookieName, value: '', maxAge: 0 });
|
|
28
28
|
}
|
|
29
|
-
|
|
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
|
+
function clearEmailVerifiedHintCookie(emailVerifiedHintCookieName) {
|
|
37
|
+
setCookie({ name: emailVerifiedHintCookieName, value: '', maxAge: 0 });
|
|
38
|
+
}
|
|
39
|
+
async function clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName) {
|
|
30
40
|
clearSessionCookie(sessionCookieName);
|
|
31
41
|
clearRefreshTokenCookie(refreshTokenCookieName);
|
|
42
|
+
clearEmailVerifiedHintCookie(emailVerifiedHintCookieName);
|
|
32
43
|
try {
|
|
33
44
|
await clearSessionAction();
|
|
34
45
|
}
|
|
@@ -36,13 +47,14 @@ async function clearSession(sessionCookieName, refreshTokenCookieName) {
|
|
|
36
47
|
console.error('AuthUserProvider: clearSessionAction failed', e);
|
|
37
48
|
}
|
|
38
49
|
}
|
|
39
|
-
async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge) {
|
|
50
|
+
async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName) {
|
|
40
51
|
try {
|
|
41
52
|
writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
|
|
42
53
|
}
|
|
43
54
|
catch (e) {
|
|
44
55
|
console.error('AuthUserProvider: refresh-token cookie sync failed', e);
|
|
45
56
|
}
|
|
57
|
+
writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, user.emailVerified, refreshTokenMaxAge);
|
|
46
58
|
writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
|
|
47
59
|
}
|
|
48
60
|
/**
|
|
@@ -74,6 +86,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
74
86
|
const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
|
|
75
87
|
const refreshTokenMaxAge = fa.refreshTokenCookieMaxAge ?? 60 * 60 * 24 * 365;
|
|
76
88
|
const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
|
|
89
|
+
const emailVerifiedHintCookieName = fa.emailVerifiedHintCookieName ?? defaultEmailVerifiedHintCookieName;
|
|
77
90
|
const [state, setState] = useState({
|
|
78
91
|
user: initialUser,
|
|
79
92
|
loading: initialUser === null,
|
|
@@ -114,10 +127,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
114
127
|
const previous = syncedSignedIn.current;
|
|
115
128
|
try {
|
|
116
129
|
if (user) {
|
|
117
|
-
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge);
|
|
130
|
+
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
|
|
118
131
|
}
|
|
119
132
|
else {
|
|
120
|
-
await clearSession(sessionCookieName, refreshTokenCookieName);
|
|
133
|
+
await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName);
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
136
|
catch (e) {
|
|
@@ -150,7 +163,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
150
163
|
unsubscribe?.();
|
|
151
164
|
};
|
|
152
165
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
153
|
-
}, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName]);
|
|
166
|
+
}, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName, emailVerifiedHintCookieName]);
|
|
154
167
|
const reloadUser = useCallback(async () => {
|
|
155
168
|
const { auth } = await getFirebaseAuthClient();
|
|
156
169
|
const user = auth.currentUser;
|
|
@@ -159,7 +172,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
159
172
|
try {
|
|
160
173
|
const { reload } = await getFirebaseAuthModule();
|
|
161
174
|
await reload(user);
|
|
162
|
-
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge);
|
|
175
|
+
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
|
|
163
176
|
setAuthUserCache(user);
|
|
164
177
|
setState({ user, loading: false });
|
|
165
178
|
}
|
|
@@ -182,10 +195,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
182
195
|
await signOut(auth);
|
|
183
196
|
}
|
|
184
197
|
finally {
|
|
185
|
-
await clearSession(sessionCookieName, refreshTokenCookieName);
|
|
198
|
+
await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName);
|
|
186
199
|
router.push(fa.redirectAuthPath);
|
|
187
200
|
}
|
|
188
201
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
189
|
-
}, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName]);
|
|
202
|
+
}, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName]);
|
|
190
203
|
return _jsx(AuthUserContext.Provider, { value: { ...state, reloadUser, sendVerificationEmail, logout }, children: children });
|
|
191
204
|
}
|
|
@@ -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
|
+
// If that hint disagrees with the stale claim, something changed since
|
|
232
|
+
// this claim was minted — force one refresh to confirm before
|
|
233
|
+
// redirecting, so this claim is no staler than a single refresh
|
|
234
|
+
// round-trip instead of up to an hour. If the hint AGREES with the
|
|
235
|
+
// claim (or is absent, e.g. first request before the client has run),
|
|
236
|
+
// trust the claim as-is — no extra network call, so a genuinely
|
|
237
|
+
// unverified user doesn't pay a refresh on every single 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 hintDisagrees = hint === 'true';
|
|
242
|
+
if (hintDisagrees && !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 agrees (or is absent) — no reason to distrust 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 (
|
|
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
|
/**
|