cloudflare-next-intl 0.6.10 → 0.6.12
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 +52 -5
- package/dist/src/firebase_auth/decode_jwt_payload.bench.d.ts +1 -0
- package/dist/src/firebase_auth/decode_jwt_payload.bench.js +35 -0
- package/dist/src/firebase_auth/decode_jwt_payload.d.ts +25 -0
- package/dist/src/firebase_auth/decode_jwt_payload.js +40 -0
- package/dist/src/firebase_auth/middleware/update_session.js +16 -12
- package/package.json +1 -1
|
@@ -8,7 +8,9 @@ import requireFirebaseAuthConfig from '../require_config';
|
|
|
8
8
|
import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client';
|
|
9
9
|
import { setAuthUserCache } from './auth_user_cache';
|
|
10
10
|
import { defaultEmailVerifiedHintCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
|
|
11
|
+
import decodeJwtPayload from '../decode_jwt_payload';
|
|
11
12
|
import setCookie from '../../client/functions/set_cookie';
|
|
13
|
+
import getCookie from '../../client/functions/get_cookie';
|
|
12
14
|
import clearSessionAction from '../server/clear_session_action';
|
|
13
15
|
// `null` default (instead of a `{ loading: true, ... }` stand-in) lets
|
|
14
16
|
// `useAuthUser` distinguish "not wrapped in AuthUserProvider" (throw) from
|
|
@@ -17,6 +19,9 @@ export const AuthUserContext = createContext(null);
|
|
|
17
19
|
function writeSessionCookie(sessionCookieName, idToken, maxAge) {
|
|
18
20
|
setCookie({ name: sessionCookieName, value: idToken, maxAge });
|
|
19
21
|
}
|
|
22
|
+
function sleep(ms) {
|
|
23
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
}
|
|
20
25
|
function clearSessionCookie(sessionCookieName) {
|
|
21
26
|
setCookie({ name: sessionCookieName, value: '', maxAge: 0 });
|
|
22
27
|
}
|
|
@@ -48,7 +53,7 @@ async function clearSession(sessionCookieName, refreshTokenCookieName, emailVeri
|
|
|
48
53
|
console.error('AuthUserProvider: clearSessionAction failed', e);
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
|
-
async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName) {
|
|
56
|
+
async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName, idToken) {
|
|
52
57
|
try {
|
|
53
58
|
writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
|
|
54
59
|
}
|
|
@@ -56,7 +61,7 @@ async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieN
|
|
|
56
61
|
console.error('AuthUserProvider: refresh-token cookie sync failed', e);
|
|
57
62
|
}
|
|
58
63
|
writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, user.emailVerified, refreshTokenMaxAge);
|
|
59
|
-
writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
|
|
64
|
+
writeSessionCookie(sessionCookieName, idToken ?? await user.getIdToken(true), maxAge);
|
|
60
65
|
}
|
|
61
66
|
/**
|
|
62
67
|
* Client-side auth-state provider for `firebase_auth`. Wrap your root layout
|
|
@@ -105,15 +110,32 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
105
110
|
const [confirmedSignedOut, setConfirmedSignedOut] = useState(initialUser === null);
|
|
106
111
|
useEffect(() => {
|
|
107
112
|
const { user, loading } = state;
|
|
108
|
-
if (loading ||
|
|
113
|
+
if (loading || isWhiteListed)
|
|
109
114
|
return;
|
|
110
115
|
if (!user) {
|
|
111
|
-
|
|
116
|
+
// Signed-out on an auth page is where they're supposed to be —
|
|
117
|
+
// only bounce a signed-out user away from a NON-auth page.
|
|
118
|
+
if (!isAuthPage && confirmedSignedOut)
|
|
112
119
|
router.replace(fa.redirectAuthPath);
|
|
113
120
|
}
|
|
114
121
|
else if (fa.verifyEmailPath && !user.emailVerified && pathname !== fa.verifyEmailPath) {
|
|
122
|
+
// Checked before the auth-page redirect below (mirrors
|
|
123
|
+
// `update_session.ts`'s same ordering): an unverified signed-in
|
|
124
|
+
// user must land on verifyEmailPath even if they navigated to
|
|
125
|
+
// an auth page like /login — homePath isn't reachable yet either.
|
|
115
126
|
router.replace(fa.verifyEmailPath);
|
|
116
127
|
}
|
|
128
|
+
else if (isAuthPage || (fa.verifyEmailPath && user.emailVerified && pathname === fa.verifyEmailPath)) {
|
|
129
|
+
// Mirrors the middleware's own signed-in-on-auth-page redirect
|
|
130
|
+
// (`update_session.ts`'s `isAuthPage` branch) — needed here too
|
|
131
|
+
// because a client-side navigation (e.g. a `<Link>`) to an auth
|
|
132
|
+
// page never re-runs the middleware, so without this the user
|
|
133
|
+
// would land on e.g. `/login` while already signed in and stay
|
|
134
|
+
// there until a hard refresh. A verified user reaching
|
|
135
|
+
// verifyEmailPath itself gets the same "go home" treatment —
|
|
136
|
+
// they're done here, same as the auth-page case.
|
|
137
|
+
router.replace(fa.homePath);
|
|
138
|
+
}
|
|
117
139
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
118
140
|
}, [state, pathname, isAuthPage, isWhiteListed, confirmedSignedOut]);
|
|
119
141
|
useEffect(() => {
|
|
@@ -173,7 +195,32 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
173
195
|
try {
|
|
174
196
|
const { reload } = await getFirebaseAuthModule();
|
|
175
197
|
await reload(user);
|
|
176
|
-
|
|
198
|
+
// `getIdToken(true)` can occasionally hand back a token that's
|
|
199
|
+
// no newer than the one already in the session cookie — a stale
|
|
200
|
+
// token caught mid-propagation right after `reload()`, rather
|
|
201
|
+
// than the fresh mint the caller asked for. Comparing `iat`
|
|
202
|
+
// (issued-at) against the existing cookie's token is a direct
|
|
203
|
+
// check that the token we're about to write is actually new;
|
|
204
|
+
// retry a few times if it isn't before giving up and writing
|
|
205
|
+
// whatever we got. The confirmed token is threaded into
|
|
206
|
+
// `writeSession` directly — letting it call `getIdToken(true)`
|
|
207
|
+
// again on its own could re-fetch and land back on a stale
|
|
208
|
+
// token, undoing this retry entirely.
|
|
209
|
+
const previousIat = decodeJwtPayload(getCookie(sessionCookieName) ?? '')?.iat;
|
|
210
|
+
let confirmedToken;
|
|
211
|
+
if (previousIat !== undefined) {
|
|
212
|
+
const maxAttempts = 3;
|
|
213
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
214
|
+
const freshToken = await user.getIdToken(true);
|
|
215
|
+
confirmedToken = freshToken;
|
|
216
|
+
const freshIat = decodeJwtPayload(freshToken)?.iat;
|
|
217
|
+
if (freshIat !== undefined && freshIat > previousIat)
|
|
218
|
+
break;
|
|
219
|
+
if (attempt < maxAttempts - 1)
|
|
220
|
+
await sleep(500);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName, confirmedToken);
|
|
177
224
|
setAuthUserCache(user);
|
|
178
225
|
setState({ user, loading: false });
|
|
179
226
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { bench, describe } from 'vitest';
|
|
2
|
+
import decodeJwtPayload from './decode_jwt_payload';
|
|
3
|
+
function makeJwt(payload) {
|
|
4
|
+
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
|
|
5
|
+
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
6
|
+
return `${header}.${encodedPayload}.sig`;
|
|
7
|
+
}
|
|
8
|
+
// A realistic Firebase ID token payload has more claims than the ones this
|
|
9
|
+
// function reads — decoding cost scales with the JSON blob's size, not just
|
|
10
|
+
// the fields we destructure, so the bench payload mirrors that shape rather
|
|
11
|
+
// than a minimal `{ exp, iat }` object.
|
|
12
|
+
const realisticToken = makeJwt({
|
|
13
|
+
iss: 'https://securetoken.google.com/demo-project',
|
|
14
|
+
aud: 'demo-project',
|
|
15
|
+
auth_time: 1735689600,
|
|
16
|
+
user_id: 'abcDEF123456ghijKLMNOP789',
|
|
17
|
+
sub: 'abcDEF123456ghijKLMNOP789',
|
|
18
|
+
iat: 1735689600,
|
|
19
|
+
exp: 1735693200,
|
|
20
|
+
email: 'user@example.com',
|
|
21
|
+
email_verified: true,
|
|
22
|
+
firebase: {
|
|
23
|
+
identities: { email: ['user@example.com'] },
|
|
24
|
+
sign_in_provider: 'password',
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const malformedToken = 'not-a-jwt';
|
|
28
|
+
describe('decodeJwtPayload', () => {
|
|
29
|
+
bench('valid token, realistic payload size', () => {
|
|
30
|
+
decodeJwtPayload(realisticToken);
|
|
31
|
+
});
|
|
32
|
+
bench('malformed token (parse failure path)', () => {
|
|
33
|
+
decodeJwtPayload(malformedToken);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decodes a JWT's payload without verifying its signature — callers only
|
|
3
|
+
* ever read claims from a token they already trust (their own session
|
|
4
|
+
* cookie, or one just minted by the Firebase SDK/Secure Token API), never
|
|
5
|
+
* one supplied by an untrusted party. Isomorphic (no `next/server` or
|
|
6
|
+
* `next/client`-only APIs) so both `middleware/update_session.ts` (Edge)
|
|
7
|
+
* and `client/auth_user_provider.tsx` (browser) can share one
|
|
8
|
+
* implementation instead of drifting apart.
|
|
9
|
+
*
|
|
10
|
+
* Extracts `exp`/`iat`/`email_verified` via regex over the decoded JSON
|
|
11
|
+
* text instead of a full `JSON.parse` — ~2.6x faster (benchmarked in
|
|
12
|
+
* `decode_jwt_payload.bench.ts`), which matters since this runs on every
|
|
13
|
+
* Edge middleware invocation. This is safe ONLY because these three claims
|
|
14
|
+
* are top-level, standard JWT/Firebase registered claims that Firebase's ID
|
|
15
|
+
* tokens never nest inside another object — a regex doing first-match
|
|
16
|
+
* search would return a wrong value for a claim nested under a
|
|
17
|
+
* same-named key. Do NOT extend this function to read additional claims
|
|
18
|
+
* without confirming they're similarly guaranteed top-level, or switch back
|
|
19
|
+
* to full `JSON.parse` for those calls instead.
|
|
20
|
+
*/
|
|
21
|
+
export default function decodeJwtPayload(token: string): {
|
|
22
|
+
exp?: number;
|
|
23
|
+
iat?: number;
|
|
24
|
+
email_verified?: boolean;
|
|
25
|
+
} | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const EXP_RE = /"exp":(-?\d+)/;
|
|
2
|
+
const IAT_RE = /"iat":(-?\d+)/;
|
|
3
|
+
const EMAIL_VERIFIED_RE = /"email_verified":(true|false)/;
|
|
4
|
+
/**
|
|
5
|
+
* Decodes a JWT's payload without verifying its signature — callers only
|
|
6
|
+
* ever read claims from a token they already trust (their own session
|
|
7
|
+
* cookie, or one just minted by the Firebase SDK/Secure Token API), never
|
|
8
|
+
* one supplied by an untrusted party. Isomorphic (no `next/server` or
|
|
9
|
+
* `next/client`-only APIs) so both `middleware/update_session.ts` (Edge)
|
|
10
|
+
* and `client/auth_user_provider.tsx` (browser) can share one
|
|
11
|
+
* implementation instead of drifting apart.
|
|
12
|
+
*
|
|
13
|
+
* Extracts `exp`/`iat`/`email_verified` via regex over the decoded JSON
|
|
14
|
+
* text instead of a full `JSON.parse` — ~2.6x faster (benchmarked in
|
|
15
|
+
* `decode_jwt_payload.bench.ts`), which matters since this runs on every
|
|
16
|
+
* Edge middleware invocation. This is safe ONLY because these three claims
|
|
17
|
+
* are top-level, standard JWT/Firebase registered claims that Firebase's ID
|
|
18
|
+
* tokens never nest inside another object — a regex doing first-match
|
|
19
|
+
* search would return a wrong value for a claim nested under a
|
|
20
|
+
* same-named key. Do NOT extend this function to read additional claims
|
|
21
|
+
* without confirming they're similarly guaranteed top-level, or switch back
|
|
22
|
+
* to full `JSON.parse` for those calls instead.
|
|
23
|
+
*/
|
|
24
|
+
export default function decodeJwtPayload(token) {
|
|
25
|
+
try {
|
|
26
|
+
const payload = token.split('.')[1];
|
|
27
|
+
const json = atob(payload.replace(/[-_]/g, (c) => c === '-' ? '+' : '/'));
|
|
28
|
+
const exp = EXP_RE.exec(json);
|
|
29
|
+
const iat = IAT_RE.exec(json);
|
|
30
|
+
const emailVerified = EMAIL_VERIFIED_RE.exec(json);
|
|
31
|
+
return {
|
|
32
|
+
exp: exp ? Number(exp[1]) : undefined,
|
|
33
|
+
iat: iat ? Number(iat[1]) : undefined,
|
|
34
|
+
email_verified: emailVerified ? emailVerified[1] === 'true' : undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import config from '@intl-config';
|
|
3
|
+
import decodeJwtPayload from '../decode_jwt_payload';
|
|
3
4
|
export const defaultSessionCookieName = '__fa_session__';
|
|
4
5
|
export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
|
|
5
6
|
// Non-httpOnly: written by AuthUserProvider (client) every time it observes
|
|
@@ -16,15 +17,6 @@ const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
|
|
|
16
17
|
// time can hand a client a token that dies moments after this check, forcing
|
|
17
18
|
// an extra round-trip on the very next request.
|
|
18
19
|
const CLOCK_SKEW_MARGIN_MS = 60 * 1000;
|
|
19
|
-
function decodeJwtPayload(token) {
|
|
20
|
-
try {
|
|
21
|
-
const payload = token.split('.')[1];
|
|
22
|
-
return JSON.parse(atob(payload.replace(/[-_]/g, (c) => c === '-' ? '+' : '/')));
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
20
|
function isJwtExpired(token) {
|
|
29
21
|
const decoded = decodeJwtPayload(token);
|
|
30
22
|
return !decoded?.exp || decoded.exp * 1000 - CLOCK_SKEW_MARGIN_MS <= Date.now();
|
|
@@ -278,12 +270,24 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
278
270
|
else if (!hasSession || clearInvalidSession) {
|
|
279
271
|
response = isAuthPage ? baseResponse : buildRedirect(baseResponse, localeUrl(fa.redirectAuthPath));
|
|
280
272
|
}
|
|
281
|
-
else if (isAuthPage) {
|
|
282
|
-
response = buildRedirect(baseResponse, localeUrl(fa.homePath));
|
|
283
|
-
}
|
|
284
273
|
else if (unverifiedEmail) {
|
|
274
|
+
// Checked before the auth-page redirect: an unverified signed-in
|
|
275
|
+
// user must land on verifyEmailPath even if they navigated to an
|
|
276
|
+
// auth page like /login — homePath is not a state they're allowed
|
|
277
|
+
// to reach yet either.
|
|
285
278
|
response = buildRedirect(baseResponse, localeUrl(fa.verifyEmailPath));
|
|
286
279
|
}
|
|
280
|
+
else if (isAuthPage || (isVerifyEmailPage && decodeJwtPayload(token)?.email_verified !== false)) {
|
|
281
|
+
// A verified user has no reason to be on verifyEmailPath either —
|
|
282
|
+
// same "you're done here, go home" treatment as an auth page.
|
|
283
|
+
// `unverifiedEmail` can't be reused here: its own computation
|
|
284
|
+
// deliberately skips verifyEmailPath (so it never redirects AWAY
|
|
285
|
+
// from that page for an unverified user), so it's always `false`
|
|
286
|
+
// while already on it regardless of actual verification status —
|
|
287
|
+
// checking the claim again directly is what tells verified and
|
|
288
|
+
// unverified apart on this specific page.
|
|
289
|
+
response = buildRedirect(baseResponse, localeUrl(fa.homePath));
|
|
290
|
+
}
|
|
287
291
|
else {
|
|
288
292
|
response = baseResponse;
|
|
289
293
|
}
|