cloudflare-next-intl 0.6.8 → 0.6.10
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,21 @@ 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
|
+
async function clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge) {
|
|
30
37
|
clearSessionCookie(sessionCookieName);
|
|
31
38
|
clearRefreshTokenCookie(refreshTokenCookieName);
|
|
39
|
+
// Signed-out is not "unknown" — it's a confirmed non-verified state, so
|
|
40
|
+
// write 'false' explicitly rather than clearing (an absent hint means
|
|
41
|
+
// "no signal yet", which forces the middleware to refresh unnecessarily
|
|
42
|
+
// if a stale session cookie somehow still lingers).
|
|
43
|
+
writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, false, refreshTokenMaxAge);
|
|
32
44
|
try {
|
|
33
45
|
await clearSessionAction();
|
|
34
46
|
}
|
|
@@ -36,13 +48,14 @@ async function clearSession(sessionCookieName, refreshTokenCookieName) {
|
|
|
36
48
|
console.error('AuthUserProvider: clearSessionAction failed', e);
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
|
-
async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge) {
|
|
51
|
+
async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName) {
|
|
40
52
|
try {
|
|
41
53
|
writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
|
|
42
54
|
}
|
|
43
55
|
catch (e) {
|
|
44
56
|
console.error('AuthUserProvider: refresh-token cookie sync failed', e);
|
|
45
57
|
}
|
|
58
|
+
writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, user.emailVerified, refreshTokenMaxAge);
|
|
46
59
|
writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
|
|
47
60
|
}
|
|
48
61
|
/**
|
|
@@ -74,6 +87,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
74
87
|
const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
|
|
75
88
|
const refreshTokenMaxAge = fa.refreshTokenCookieMaxAge ?? 60 * 60 * 24 * 365;
|
|
76
89
|
const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
|
|
90
|
+
const emailVerifiedHintCookieName = fa.emailVerifiedHintCookieName ?? defaultEmailVerifiedHintCookieName;
|
|
77
91
|
const [state, setState] = useState({
|
|
78
92
|
user: initialUser,
|
|
79
93
|
loading: initialUser === null,
|
|
@@ -114,10 +128,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
114
128
|
const previous = syncedSignedIn.current;
|
|
115
129
|
try {
|
|
116
130
|
if (user) {
|
|
117
|
-
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge);
|
|
131
|
+
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
|
|
118
132
|
}
|
|
119
133
|
else {
|
|
120
|
-
await clearSession(sessionCookieName, refreshTokenCookieName);
|
|
134
|
+
await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge);
|
|
121
135
|
}
|
|
122
136
|
}
|
|
123
137
|
catch (e) {
|
|
@@ -150,7 +164,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
150
164
|
unsubscribe?.();
|
|
151
165
|
};
|
|
152
166
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
153
|
-
}, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName]);
|
|
167
|
+
}, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName, emailVerifiedHintCookieName]);
|
|
154
168
|
const reloadUser = useCallback(async () => {
|
|
155
169
|
const { auth } = await getFirebaseAuthClient();
|
|
156
170
|
const user = auth.currentUser;
|
|
@@ -159,7 +173,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
159
173
|
try {
|
|
160
174
|
const { reload } = await getFirebaseAuthModule();
|
|
161
175
|
await reload(user);
|
|
162
|
-
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge);
|
|
176
|
+
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
|
|
163
177
|
setAuthUserCache(user);
|
|
164
178
|
setState({ user, loading: false });
|
|
165
179
|
}
|
|
@@ -182,10 +196,10 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
182
196
|
await signOut(auth);
|
|
183
197
|
}
|
|
184
198
|
finally {
|
|
185
|
-
await clearSession(sessionCookieName, refreshTokenCookieName);
|
|
199
|
+
await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge);
|
|
186
200
|
router.push(fa.redirectAuthPath);
|
|
187
201
|
}
|
|
188
202
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
189
|
-
}, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName]);
|
|
203
|
+
}, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName]);
|
|
190
204
|
return _jsx(AuthUserContext.Provider, { value: { ...state, reloadUser, sendVerificationEmail, logout }, children: children });
|
|
191
205
|
}
|
|
@@ -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
|
+
// Force one refresh to confirm before redirecting whenever that hint
|
|
232
|
+
// can't yet vouch for this claim: it disagrees outright, or it's absent
|
|
233
|
+
// (e.g. first request before the client has run at all, or a hint
|
|
234
|
+
// that expired/was never set) — either way there's no positive signal
|
|
235
|
+
// the claim is still accurate. Only when the hint AGREES with the claim
|
|
236
|
+
// is a refresh skipped, so a genuinely unverified user with an
|
|
237
|
+
// established, agreeing hint doesn't pay a refresh on every 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 hintConfirms = hint === 'false';
|
|
242
|
+
if (!hintConfirms && !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 confirms unverified — no reason to refresh, trust 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
|
/**
|