cloudflare-next-intl 0.6.9 → 0.6.11
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 +41 -10
- 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 +11 -19
- 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
|
}
|
|
@@ -33,13 +38,14 @@ function clearRefreshTokenCookie(refreshTokenCookieName) {
|
|
|
33
38
|
function writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, emailVerified, maxAge) {
|
|
34
39
|
setCookie({ name: emailVerifiedHintCookieName, value: String(emailVerified), maxAge });
|
|
35
40
|
}
|
|
36
|
-
function
|
|
37
|
-
setCookie({ name: emailVerifiedHintCookieName, value: '', maxAge: 0 });
|
|
38
|
-
}
|
|
39
|
-
async function clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName) {
|
|
41
|
+
async function clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge) {
|
|
40
42
|
clearSessionCookie(sessionCookieName);
|
|
41
43
|
clearRefreshTokenCookie(refreshTokenCookieName);
|
|
42
|
-
|
|
44
|
+
// Signed-out is not "unknown" — it's a confirmed non-verified state, so
|
|
45
|
+
// write 'false' explicitly rather than clearing (an absent hint means
|
|
46
|
+
// "no signal yet", which forces the middleware to refresh unnecessarily
|
|
47
|
+
// if a stale session cookie somehow still lingers).
|
|
48
|
+
writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, false, refreshTokenMaxAge);
|
|
43
49
|
try {
|
|
44
50
|
await clearSessionAction();
|
|
45
51
|
}
|
|
@@ -47,7 +53,7 @@ async function clearSession(sessionCookieName, refreshTokenCookieName, emailVeri
|
|
|
47
53
|
console.error('AuthUserProvider: clearSessionAction failed', e);
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
|
-
async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName) {
|
|
56
|
+
async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName, idToken) {
|
|
51
57
|
try {
|
|
52
58
|
writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
|
|
53
59
|
}
|
|
@@ -55,7 +61,7 @@ async function writeSession(user, sessionCookieName, maxAge, refreshTokenCookieN
|
|
|
55
61
|
console.error('AuthUserProvider: refresh-token cookie sync failed', e);
|
|
56
62
|
}
|
|
57
63
|
writeEmailVerifiedHintCookie(emailVerifiedHintCookieName, user.emailVerified, refreshTokenMaxAge);
|
|
58
|
-
writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
|
|
64
|
+
writeSessionCookie(sessionCookieName, idToken ?? await user.getIdToken(true), maxAge);
|
|
59
65
|
}
|
|
60
66
|
/**
|
|
61
67
|
* Client-side auth-state provider for `firebase_auth`. Wrap your root layout
|
|
@@ -130,7 +136,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
130
136
|
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName);
|
|
131
137
|
}
|
|
132
138
|
else {
|
|
133
|
-
await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName);
|
|
139
|
+
await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge);
|
|
134
140
|
}
|
|
135
141
|
}
|
|
136
142
|
catch (e) {
|
|
@@ -172,7 +178,32 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
172
178
|
try {
|
|
173
179
|
const { reload } = await getFirebaseAuthModule();
|
|
174
180
|
await reload(user);
|
|
175
|
-
|
|
181
|
+
// `getIdToken(true)` can occasionally hand back a token that's
|
|
182
|
+
// no newer than the one already in the session cookie — a stale
|
|
183
|
+
// token caught mid-propagation right after `reload()`, rather
|
|
184
|
+
// than the fresh mint the caller asked for. Comparing `iat`
|
|
185
|
+
// (issued-at) against the existing cookie's token is a direct
|
|
186
|
+
// check that the token we're about to write is actually new;
|
|
187
|
+
// retry a few times if it isn't before giving up and writing
|
|
188
|
+
// whatever we got. The confirmed token is threaded into
|
|
189
|
+
// `writeSession` directly — letting it call `getIdToken(true)`
|
|
190
|
+
// again on its own could re-fetch and land back on a stale
|
|
191
|
+
// token, undoing this retry entirely.
|
|
192
|
+
const previousIat = decodeJwtPayload(getCookie(sessionCookieName) ?? '')?.iat;
|
|
193
|
+
let confirmedToken;
|
|
194
|
+
if (previousIat !== undefined) {
|
|
195
|
+
const maxAttempts = 3;
|
|
196
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
197
|
+
const freshToken = await user.getIdToken(true);
|
|
198
|
+
confirmedToken = freshToken;
|
|
199
|
+
const freshIat = decodeJwtPayload(freshToken)?.iat;
|
|
200
|
+
if (freshIat !== undefined && freshIat > previousIat)
|
|
201
|
+
break;
|
|
202
|
+
if (attempt < maxAttempts - 1)
|
|
203
|
+
await sleep(500);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
await writeSession(user, sessionCookieName, maxAge, refreshTokenCookieName, refreshTokenMaxAge, emailVerifiedHintCookieName, confirmedToken);
|
|
176
207
|
setAuthUserCache(user);
|
|
177
208
|
setState({ user, loading: false });
|
|
178
209
|
}
|
|
@@ -195,7 +226,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
195
226
|
await signOut(auth);
|
|
196
227
|
}
|
|
197
228
|
finally {
|
|
198
|
-
await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName);
|
|
229
|
+
await clearSession(sessionCookieName, refreshTokenCookieName, emailVerifiedHintCookieName, refreshTokenMaxAge);
|
|
199
230
|
router.push(fa.redirectAuthPath);
|
|
200
231
|
}
|
|
201
232
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -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();
|
|
@@ -228,18 +220,18 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
228
220
|
// (up to ~1hr later). AuthUserProvider (client) mirrors the live SDK
|
|
229
221
|
// state into `emailVerifiedHintCookieName` on every auth-state change,
|
|
230
222
|
// so it reflects verification status sooner than the session JWT does.
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// claim
|
|
236
|
-
//
|
|
237
|
-
//
|
|
223
|
+
// Force one refresh to confirm before redirecting whenever that hint
|
|
224
|
+
// can't yet vouch for this claim: it disagrees outright, or it's absent
|
|
225
|
+
// (e.g. first request before the client has run at all, or a hint
|
|
226
|
+
// that expired/was never set) — either way there's no positive signal
|
|
227
|
+
// the claim is still accurate. Only when the hint AGREES with the claim
|
|
228
|
+
// is a refresh skipped, so a genuinely unverified user with an
|
|
229
|
+
// established, agreeing hint doesn't pay a refresh on every request.
|
|
238
230
|
let unverifiedEmail = false;
|
|
239
231
|
if (fa.verifyEmailPath && !isVerifyEmailPage && hasSession && decodeJwtPayload(token)?.email_verified === false) {
|
|
240
232
|
const hint = request.cookies.get(emailVerifiedHintCookieName)?.value;
|
|
241
|
-
const
|
|
242
|
-
if (
|
|
233
|
+
const hintConfirms = hint === 'false';
|
|
234
|
+
if (!hintConfirms && !refreshedToken) {
|
|
243
235
|
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
244
236
|
if (refreshToken) {
|
|
245
237
|
const result = await refreshIdToken(fa.apiKey, refreshToken);
|
|
@@ -264,7 +256,7 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
264
256
|
}
|
|
265
257
|
}
|
|
266
258
|
else {
|
|
267
|
-
// Hint
|
|
259
|
+
// Hint confirms unverified — no reason to refresh, trust the claim.
|
|
268
260
|
unverifiedEmail = true;
|
|
269
261
|
}
|
|
270
262
|
}
|