cloudflare-next-intl 0.6.29 → 0.6.31
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/config/middleware.js +4 -1
- package/dist/src/firebase_auth/client/auth_user_provider.js +4 -3
- package/dist/src/firebase_auth/middleware/update_session.d.ts +18 -1
- package/dist/src/firebase_auth/middleware/update_session.js +21 -3
- package/dist/src/firebase_auth/preserve_redirect_query.d.ts +19 -0
- package/dist/src/firebase_auth/preserve_redirect_query.js +26 -0
- package/dist/src/firebase_auth/server/auth_user_server_provider.js +9 -3
- package/dist/src/firebase_auth/server/firebase_server.js +16 -2
- package/dist/src/types/types.d.ts +10 -0
- package/package.json +1 -1
|
@@ -128,6 +128,7 @@ export default async function intlMiddleware(request, options) {
|
|
|
128
128
|
}
|
|
129
129
|
response.headers.set('Content-Language', effectiveLocaleForRequest);
|
|
130
130
|
response.headers.set('x-pathname', pathWithoutLocale);
|
|
131
|
+
response.headers.set('x-search', search);
|
|
131
132
|
// Auto-wires the firebase_auth submodule's redirect/session-refresh
|
|
132
133
|
// logic when `firebaseAuth` is configured — dynamic import so this
|
|
133
134
|
// file never pulls in firebase_auth/** (and transitively firebase/*)
|
|
@@ -141,7 +142,9 @@ export default async function intlMiddleware(request, options) {
|
|
|
141
142
|
if (!updateSessionModule) {
|
|
142
143
|
updateSessionModule = await import('../firebase_auth/middleware/update_session');
|
|
143
144
|
}
|
|
144
|
-
response = await updateSessionModule.default(request, response, effectiveLocaleForRequest)
|
|
145
|
+
response = await updateSessionModule.default(request, response, effectiveLocaleForRequest, (refreshedRequest) => rewriteUrl
|
|
146
|
+
? NextResponse.rewrite(rewriteUrl, { request: refreshedRequest })
|
|
147
|
+
: NextResponse.next({ request: refreshedRequest }));
|
|
145
148
|
}
|
|
146
149
|
return response;
|
|
147
150
|
}
|
|
@@ -10,6 +10,7 @@ import { setAuthUserCache } from './auth_user_cache';
|
|
|
10
10
|
import { defaultAppCheckTokenCookieName, defaultEmailVerifiedHintCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
|
|
11
11
|
import decodeJwtPayload from '../decode_jwt_payload';
|
|
12
12
|
import isWhitelisted from '../is_whitelisted';
|
|
13
|
+
import withRedirectQuery from '../preserve_redirect_query';
|
|
13
14
|
import setCookie from '../../client/functions/set_cookie';
|
|
14
15
|
import getCookie from '../../client/functions/get_cookie';
|
|
15
16
|
import clearSessionAction from '../server/clear_session_action';
|
|
@@ -155,14 +156,14 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
155
156
|
// Signed-out on an auth page is where they're supposed to be —
|
|
156
157
|
// only bounce a signed-out user away from a NON-auth page.
|
|
157
158
|
if (!isAuthPage && confirmedSignedOut)
|
|
158
|
-
router.replace(fa.redirectAuthPath);
|
|
159
|
+
router.replace(withRedirectQuery(fa.redirectAuthPath, window.location.search));
|
|
159
160
|
}
|
|
160
161
|
else if (fa.verifyEmailPath && !user.emailVerified && pathname !== fa.verifyEmailPath) {
|
|
161
162
|
// Checked before the auth-page redirect below (mirrors
|
|
162
163
|
// `update_session.ts`'s same ordering): an unverified signed-in
|
|
163
164
|
// user must land on verifyEmailPath even if they navigated to
|
|
164
165
|
// an auth page like /login — homePath isn't reachable yet either.
|
|
165
|
-
router.replace(fa.verifyEmailPath);
|
|
166
|
+
router.replace(withRedirectQuery(fa.verifyEmailPath, window.location.search));
|
|
166
167
|
}
|
|
167
168
|
else if (isAuthPage || (fa.verifyEmailPath && user.emailVerified && pathname === fa.verifyEmailPath)) {
|
|
168
169
|
// Mirrors the middleware's own signed-in-on-auth-page redirect
|
|
@@ -173,7 +174,7 @@ export default function AuthUserProvider({ initialUser = null, children }) {
|
|
|
173
174
|
// there until a hard refresh. A verified user reaching
|
|
174
175
|
// verifyEmailPath itself gets the same "go home" treatment —
|
|
175
176
|
// they're done here, same as the auth-page case.
|
|
176
|
-
router.replace(fa.homePath);
|
|
177
|
+
router.replace(withRedirectQuery(fa.homePath, window.location.search));
|
|
177
178
|
}
|
|
178
179
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
179
180
|
}, [state, pathname, isAuthPage, isWhiteListed, confirmedSignedOut]);
|
|
@@ -4,6 +4,23 @@ export declare const defaultRefreshTokenCookieName = "__fa_refresh_token__";
|
|
|
4
4
|
export declare const defaultEmailVerifiedHintCookieName = "__fa_email_verified_hint__";
|
|
5
5
|
export declare const defaultAppCheckTokenCookieName = "__fa_app_check_token__";
|
|
6
6
|
export declare const defaultResetPasswordPath = "/reset-password";
|
|
7
|
+
export declare function isIdTokenExpired(token: string): boolean;
|
|
8
|
+
export type RefreshResult = {
|
|
9
|
+
status: 'refreshed';
|
|
10
|
+
idToken: string;
|
|
11
|
+
refreshToken: string;
|
|
12
|
+
} | {
|
|
13
|
+
status: 'invalid';
|
|
14
|
+
} | {
|
|
15
|
+
status: 'transient-failure';
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Mints a fresh ID token from a stored refresh token via Google's Secure
|
|
19
|
+
* Token API. No `firebase/auth` import: this runs in the Edge middleware
|
|
20
|
+
* runtime, and `firebase/auth` pulls in Node-only APIs that break Edge
|
|
21
|
+
* bundles even though this function never touches that module.
|
|
22
|
+
*/
|
|
23
|
+
export declare function refreshIdToken(apiKey: string, refreshToken: string): Promise<RefreshResult>;
|
|
7
24
|
/**
|
|
8
25
|
* Layers Firebase session-cookie validation/refresh and auth redirects onto
|
|
9
26
|
* an already-built middleware response. Called internally by `intlMiddleware`
|
|
@@ -22,4 +39,4 @@ export declare const defaultResetPasswordPath = "/reset-password";
|
|
|
22
39
|
* state still survives the redirect.
|
|
23
40
|
* @param locale The effective locale `intlMiddleware` resolved for this request.
|
|
24
41
|
*/
|
|
25
|
-
export default function updateSession(request: NextRequest, baseResponse: NextResponse, locale: string): Promise<NextResponse>;
|
|
42
|
+
export default function updateSession(request: NextRequest, baseResponse: NextResponse, locale: string, rebuildResponse?: (request: NextRequest) => NextResponse): Promise<NextResponse>;
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|
|
2
2
|
import config from '@intl-config';
|
|
3
3
|
import decodeJwtPayload from '../decode_jwt_payload';
|
|
4
4
|
import isWhitelisted from '../is_whitelisted';
|
|
5
|
+
import withRedirectQuery from '../preserve_redirect_query';
|
|
5
6
|
export const defaultSessionCookieName = '__fa_session__';
|
|
6
7
|
export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
|
|
7
8
|
// Non-httpOnly: written by AuthUserProvider (client) every time it observes
|
|
@@ -43,6 +44,9 @@ const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
|
|
|
43
44
|
// time can hand a client a token that dies moments after this check, forcing
|
|
44
45
|
// an extra round-trip on the very next request.
|
|
45
46
|
const CLOCK_SKEW_MARGIN_MS = 60 * 1000;
|
|
47
|
+
export function isIdTokenExpired(token) {
|
|
48
|
+
return isJwtExpired(token);
|
|
49
|
+
}
|
|
46
50
|
function isJwtExpired(token) {
|
|
47
51
|
const decoded = decodeJwtPayload(token);
|
|
48
52
|
return !decoded?.exp || decoded.exp * 1000 - CLOCK_SKEW_MARGIN_MS <= Date.now();
|
|
@@ -126,7 +130,7 @@ const INVALID_REFRESH_TOKEN_ERRORS = new Set([
|
|
|
126
130
|
* runtime, and `firebase/auth` pulls in Node-only APIs that break Edge
|
|
127
131
|
* bundles even though this function never touches that module.
|
|
128
132
|
*/
|
|
129
|
-
async function refreshIdToken(apiKey, refreshToken) {
|
|
133
|
+
export async function refreshIdToken(apiKey, refreshToken) {
|
|
130
134
|
const cached = await getCachedRefresh(refreshToken);
|
|
131
135
|
if (cached)
|
|
132
136
|
return { status: 'refreshed', ...cached };
|
|
@@ -183,7 +187,7 @@ async function refreshIdToken(apiKey, refreshToken) {
|
|
|
183
187
|
* state still survives the redirect.
|
|
184
188
|
* @param locale The effective locale `intlMiddleware` resolved for this request.
|
|
185
189
|
*/
|
|
186
|
-
export default async function updateSession(request, baseResponse, locale) {
|
|
190
|
+
export default async function updateSession(request, baseResponse, locale, rebuildResponse) {
|
|
187
191
|
const fa = config.firebaseAuth;
|
|
188
192
|
if (!fa || fa.middlewareEnabled === false)
|
|
189
193
|
return baseResponse;
|
|
@@ -200,7 +204,7 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
200
204
|
return baseResponse;
|
|
201
205
|
}
|
|
202
206
|
const localePrefix = locale === config.defaultLocale ? '' : requestPrefix;
|
|
203
|
-
const localeUrl = (target) => new URL(`${localePrefix}${target === '/' ? '' : target}` || '/', request.url);
|
|
207
|
+
const localeUrl = (target) => new URL(withRedirectQuery(`${localePrefix}${target === '/' ? '' : target}` || '/', request.nextUrl.search), request.url);
|
|
204
208
|
// Emailed Firebase action links all arrive on the single project-wide
|
|
205
209
|
// action URL carrying `?mode=<action>&oobCode=...`. Forward them to the
|
|
206
210
|
// page for that mode BEFORE any auth/whitelist check below: these links
|
|
@@ -354,6 +358,20 @@ export default async function updateSession(request, baseResponse, locale) {
|
|
|
354
358
|
response.cookies.delete(refreshTokenCookieName);
|
|
355
359
|
}
|
|
356
360
|
if (refreshedToken) {
|
|
361
|
+
// `response.cookies.set` only reaches the BROWSER — the current
|
|
362
|
+
// render still reads the old, expired token from `cookies()`, so
|
|
363
|
+
// `initializeServerApp` rejects it with `auth/invalid-user-token`.
|
|
364
|
+
// Writing to `request.cookies` and rebuilding the pass-through
|
|
365
|
+
// response from that request makes the fresh token visible to this
|
|
366
|
+
// render too.
|
|
367
|
+
request.cookies.set(sessionCookieName, refreshedToken.idToken);
|
|
368
|
+
request.cookies.set(refreshTokenCookieName, refreshedToken.refreshToken);
|
|
369
|
+
if (rebuildResponse && response === baseResponse) {
|
|
370
|
+
const rebuilt = rebuildResponse(request);
|
|
371
|
+
baseResponse.cookies.getAll().forEach((cookie) => rebuilt.cookies.set(cookie));
|
|
372
|
+
baseResponse.headers.forEach((value, key) => rebuilt.headers.set(key, value));
|
|
373
|
+
response = rebuilt;
|
|
374
|
+
}
|
|
357
375
|
response.cookies.set(sessionCookieName, refreshedToken.idToken, {
|
|
358
376
|
httpOnly: true,
|
|
359
377
|
secure: request.nextUrl.protocol === 'https',
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the firebase_auth redirects should carry the request's query
|
|
3
|
+
* string over to their target. Defaults to `true` — see
|
|
4
|
+
* `FirebaseAuthRoutingConfig.preserveRedirectQuery`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function preserveRedirectQueryEnabled(): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Appends `search` to a redirect target path, honoring the
|
|
9
|
+
* `preserveRedirectQuery` setting. Shared by all three places that redirect
|
|
10
|
+
* to `redirectAuthPath`/`homePath`/`verifyEmailPath` — the middleware
|
|
11
|
+
* (`update_session`), the RSC pre-render redirect
|
|
12
|
+
* (`resolveAuthUserAndRedirect`), and the client `AuthUserProvider` effect —
|
|
13
|
+
* so they can't drift apart on whether a query string survives.
|
|
14
|
+
*
|
|
15
|
+
* @param search Leading-`?` query string (`''` when there is none), from
|
|
16
|
+
* `request.nextUrl.search`, the `x-search` header, or
|
|
17
|
+
* `window.location.search` depending on the caller's runtime.
|
|
18
|
+
*/
|
|
19
|
+
export default function withRedirectQuery(target: string, search: string): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import config from '@intl-config';
|
|
2
|
+
/**
|
|
3
|
+
* Whether the firebase_auth redirects should carry the request's query
|
|
4
|
+
* string over to their target. Defaults to `true` — see
|
|
5
|
+
* `FirebaseAuthRoutingConfig.preserveRedirectQuery`.
|
|
6
|
+
*/
|
|
7
|
+
export function preserveRedirectQueryEnabled() {
|
|
8
|
+
return config.firebaseAuth?.preserveRedirectQuery !== false;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Appends `search` to a redirect target path, honoring the
|
|
12
|
+
* `preserveRedirectQuery` setting. Shared by all three places that redirect
|
|
13
|
+
* to `redirectAuthPath`/`homePath`/`verifyEmailPath` — the middleware
|
|
14
|
+
* (`update_session`), the RSC pre-render redirect
|
|
15
|
+
* (`resolveAuthUserAndRedirect`), and the client `AuthUserProvider` effect —
|
|
16
|
+
* so they can't drift apart on whether a query string survives.
|
|
17
|
+
*
|
|
18
|
+
* @param search Leading-`?` query string (`''` when there is none), from
|
|
19
|
+
* `request.nextUrl.search`, the `x-search` header, or
|
|
20
|
+
* `window.location.search` depending on the caller's runtime.
|
|
21
|
+
*/
|
|
22
|
+
export default function withRedirectQuery(target, search) {
|
|
23
|
+
if (!search || !preserveRedirectQueryEnabled())
|
|
24
|
+
return target;
|
|
25
|
+
return `${target}${search}`;
|
|
26
|
+
}
|
|
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
|
|
5
5
|
import config from '@intl-config';
|
|
6
6
|
import requireFirebaseAuthConfig from '../require_config';
|
|
7
7
|
import { getAuthenticatedAppForUser } from './firebase_server';
|
|
8
|
+
import withRedirectQuery from '../preserve_redirect_query';
|
|
8
9
|
const AuthUserProvider = dynamic(() => import('../client/auth_user_provider'));
|
|
9
10
|
/**
|
|
10
11
|
* Resolves the signed-in user from the session cookie and performs the
|
|
@@ -25,14 +26,19 @@ export async function resolveAuthUserAndRedirect() {
|
|
|
25
26
|
const fa = config.firebaseAuth;
|
|
26
27
|
requireFirebaseAuthConfig(fa);
|
|
27
28
|
const { currentUser } = await getAuthenticatedAppForUser();
|
|
28
|
-
const
|
|
29
|
+
const requestHeaders = await headers();
|
|
30
|
+
const path = requestHeaders.get('x-pathname') ?? '/';
|
|
29
31
|
const isAuthPage = fa.isAuthPath(path);
|
|
30
32
|
const isWhiteListed = fa.whiteListPaths?.includes(path) ?? false;
|
|
33
|
+
// `x-pathname` is path-only, so the query string comes from `x-search`
|
|
34
|
+
// (set alongside it by `intlMiddleware`) — `redirect()` takes a plain
|
|
35
|
+
// string, not a URL.
|
|
36
|
+
const search = requestHeaders.get('x-search') ?? '';
|
|
31
37
|
if (!isWhiteListed) {
|
|
32
38
|
if (!currentUser && !isAuthPage)
|
|
33
|
-
redirect(fa.redirectAuthPath);
|
|
39
|
+
redirect(withRedirectQuery(fa.redirectAuthPath, search));
|
|
34
40
|
if (currentUser && isAuthPage)
|
|
35
|
-
redirect(fa.homePath);
|
|
41
|
+
redirect(withRedirectQuery(fa.homePath, search));
|
|
36
42
|
}
|
|
37
43
|
return currentUser && {
|
|
38
44
|
uid: currentUser.uid,
|
|
@@ -2,7 +2,7 @@ import { cookies } from 'next/headers';
|
|
|
2
2
|
import { cache } from 'react';
|
|
3
3
|
import config from '@intl-config';
|
|
4
4
|
import requireFirebaseAuthConfig from '../require_config';
|
|
5
|
-
import { defaultAppCheckTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
|
|
5
|
+
import { defaultAppCheckTokenCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName, isIdTokenExpired, refreshIdToken } from '../middleware/update_session';
|
|
6
6
|
import reportError from '../../error_handling/report_error';
|
|
7
7
|
import mintServerAppCheckToken from './mint_server_app_check_token';
|
|
8
8
|
let baseApp;
|
|
@@ -22,8 +22,22 @@ export const getAuthenticatedAppForUser = cache(async function getAuthenticatedA
|
|
|
22
22
|
requireFirebaseAuthConfig(fa);
|
|
23
23
|
const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
|
|
24
24
|
const appCheckTokenCookieName = fa.appCheckTokenCookieName ?? defaultAppCheckTokenCookieName;
|
|
25
|
+
const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
|
|
25
26
|
const cookieStore = await cookies();
|
|
26
|
-
|
|
27
|
+
let authIdToken = cookieStore.get(sessionCookieName)?.value;
|
|
28
|
+
// Safety net for the paths the middleware can't cover (prefetch requests,
|
|
29
|
+
// excluded matcher routes, server actions): an expired token would be
|
|
30
|
+
// rejected by `initializeServerApp` with `auth/invalid-user-token`, so
|
|
31
|
+
// mint a fresh one from the refresh-token cookie first. The refreshed
|
|
32
|
+
// token can't be written back to the cookie from here (RSC render), but
|
|
33
|
+
// the middleware persists it on the next request.
|
|
34
|
+
if (authIdToken && isIdTokenExpired(authIdToken)) {
|
|
35
|
+
const refreshToken = cookieStore.get(refreshTokenCookieName)?.value;
|
|
36
|
+
const result = refreshToken
|
|
37
|
+
? await refreshIdToken(fa.apiKey, refreshToken)
|
|
38
|
+
: undefined;
|
|
39
|
+
authIdToken = result?.status === 'refreshed' ? result.idToken : undefined;
|
|
40
|
+
}
|
|
27
41
|
if (!authIdToken) {
|
|
28
42
|
return { firebaseServerApp: null, currentUser: null };
|
|
29
43
|
}
|
|
@@ -495,6 +495,16 @@ export interface FirebaseAuthRoutingConfig {
|
|
|
495
495
|
* auto-corrects a missing leading slash with a warning.
|
|
496
496
|
*/
|
|
497
497
|
actionLinkPath?: string;
|
|
498
|
+
/**
|
|
499
|
+
* Whether the middleware's own redirects (`redirectAuthPath`, `homePath`,
|
|
500
|
+
* `verifyEmailPath`) carry over the original request's query string —
|
|
501
|
+
* e.g. `/login?ref=abc` stays `/login?ref=abc` after redirecting to
|
|
502
|
+
* `homePath` for a signed-in user, instead of dropping to `/`. Defaults
|
|
503
|
+
* to `true`. The emailed-action-link forward (see
|
|
504
|
+
* {@link resetPasswordPath}) always preserves its query string
|
|
505
|
+
* regardless of this setting, since `oobCode` must survive that hop.
|
|
506
|
+
*/
|
|
507
|
+
preserveRedirectQuery?: boolean;
|
|
498
508
|
/** Returns true if the given (locale-stripped) path is an auth page (login/signup/etc). */
|
|
499
509
|
isAuthPath: (path: string) => boolean;
|
|
500
510
|
/** Locale-stripped paths exempt from all auth redirects (e.g. public marketing pages). */
|