cloudflare-next-intl 0.6.7 → 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.
- package/dist/src/firebase_auth/client/auth_user_provider.js +22 -9
- package/dist/src/firebase_auth/middleware/update_session.bench.js +10 -2
- package/dist/src/firebase_auth/middleware/update_session.d.ts +1 -0
- package/dist/src/firebase_auth/middleware/update_session.js +67 -6
- package/dist/src/types/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -10,13 +10,14 @@ const baseFa = {
|
|
|
10
10
|
redirectAuthPath: '/login',
|
|
11
11
|
homePath: '/',
|
|
12
12
|
isAuthPath: (path) => path === '/login',
|
|
13
|
+
verifyEmailPath: '/verify-email',
|
|
13
14
|
};
|
|
14
15
|
vi.mock('@intl-config', () => ({
|
|
15
16
|
default: { locales: ['en'], defaultLocale: 'en', firebaseAuth: baseFa },
|
|
16
17
|
}));
|
|
17
|
-
function makeJwt(exp) {
|
|
18
|
+
function makeJwt(exp, claims = {}) {
|
|
18
19
|
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
|
|
19
|
-
const payload = Buffer.from(JSON.stringify({ exp })).toString('base64url');
|
|
20
|
+
const payload = Buffer.from(JSON.stringify({ exp, ...claims })).toString('base64url');
|
|
20
21
|
return `${header}.${payload}.sig`;
|
|
21
22
|
}
|
|
22
23
|
describe('updateSession', () => {
|
|
@@ -34,4 +35,11 @@ describe('updateSession', () => {
|
|
|
34
35
|
});
|
|
35
36
|
await updateSession(req, NextResponse.next(), 'en');
|
|
36
37
|
});
|
|
38
|
+
bench('valid session, unverified email: decodeJwtPayload + verifyEmailPath redirect', async () => {
|
|
39
|
+
const { default: updateSession } = await import('./update_session');
|
|
40
|
+
const req = makeTestRequest('https://example.com/en/dashboard', {
|
|
41
|
+
cookies: { __fa_session__: makeJwt(Date.now() / 1000 + 3600, { email_verified: false }) },
|
|
42
|
+
});
|
|
43
|
+
await updateSession(req, NextResponse.next(), 'en');
|
|
44
|
+
});
|
|
37
45
|
});
|
|
@@ -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
|
|
@@ -9,16 +16,19 @@ const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
|
|
|
9
16
|
// time can hand a client a token that dies moments after this check, forcing
|
|
10
17
|
// an extra round-trip on the very next request.
|
|
11
18
|
const CLOCK_SKEW_MARGIN_MS = 60 * 1000;
|
|
12
|
-
function
|
|
19
|
+
function decodeJwtPayload(token) {
|
|
13
20
|
try {
|
|
14
21
|
const payload = token.split('.')[1];
|
|
15
|
-
|
|
16
|
-
return !exp || exp * 1000 - CLOCK_SKEW_MARGIN_MS <= Date.now();
|
|
22
|
+
return JSON.parse(atob(payload.replace(/[-_]/g, (c) => c === '-' ? '+' : '/')));
|
|
17
23
|
}
|
|
18
24
|
catch {
|
|
19
|
-
return
|
|
25
|
+
return null;
|
|
20
26
|
}
|
|
21
27
|
}
|
|
28
|
+
function isJwtExpired(token) {
|
|
29
|
+
const decoded = decodeJwtPayload(token);
|
|
30
|
+
return !decoded?.exp || decoded.exp * 1000 - CLOCK_SKEW_MARGIN_MS <= Date.now();
|
|
31
|
+
}
|
|
22
32
|
// Refreshing an ID token is a real network round-trip to Google on the
|
|
23
33
|
// Edge middleware's critical path, on every request where the session
|
|
24
34
|
// cookie has expired — which, for any session older than the ~1hr ID-token
|
|
@@ -161,6 +171,7 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
161
171
|
return baseResponse;
|
|
162
172
|
const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
|
|
163
173
|
const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
|
|
174
|
+
const emailVerifiedHintCookieName = fa.emailVerifiedHintCookieName ?? defaultEmailVerifiedHintCookieName;
|
|
164
175
|
const rawPath = request.nextUrl.pathname;
|
|
165
176
|
const requestPrefix = `/${locale}`;
|
|
166
177
|
const path = rawPath === requestPrefix || rawPath.startsWith(`${requestPrefix}/`)
|
|
@@ -170,7 +181,7 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
170
181
|
if (rawPath.startsWith('/_next') || /\.[a-zA-Z0-9]+$/.test(lastSegment)) {
|
|
171
182
|
return baseResponse;
|
|
172
183
|
}
|
|
173
|
-
const localePrefix = locale === config.
|
|
184
|
+
const localePrefix = locale === config.defaultLocale ? '' : requestPrefix;
|
|
174
185
|
const localeUrl = (target) => new URL(`${localePrefix}${target === '/' ? '' : target}` || '/', request.url);
|
|
175
186
|
const isWhiteListed = fa.whiteListPaths?.includes(path) ?? false;
|
|
176
187
|
if (isWhiteListed)
|
|
@@ -210,6 +221,53 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
210
221
|
}
|
|
211
222
|
const hasSession = !!token;
|
|
212
223
|
let response;
|
|
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
|
+
}
|
|
213
271
|
if (refreshWasTransientFailure) {
|
|
214
272
|
// Couldn't confirm the session either way — pass through without
|
|
215
273
|
// forcing a redirect in either direction. The next request (or the
|
|
@@ -217,12 +275,15 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
217
275
|
// chance to resolve this correctly instead of guessing wrong.
|
|
218
276
|
response = baseResponse;
|
|
219
277
|
}
|
|
220
|
-
else if (!hasSession) {
|
|
278
|
+
else if (!hasSession || clearInvalidSession) {
|
|
221
279
|
response = isAuthPage ? baseResponse : buildRedirect(baseResponse, localeUrl(fa.redirectAuthPath));
|
|
222
280
|
}
|
|
223
281
|
else if (isAuthPage) {
|
|
224
282
|
response = buildRedirect(baseResponse, localeUrl(fa.homePath));
|
|
225
283
|
}
|
|
284
|
+
else if (unverifiedEmail) {
|
|
285
|
+
response = buildRedirect(baseResponse, localeUrl(fa.verifyEmailPath));
|
|
286
|
+
}
|
|
226
287
|
else {
|
|
227
288
|
response = baseResponse;
|
|
228
289
|
}
|
|
@@ -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
|
/**
|