cloudflare-next-intl 0.6.30 → 0.6.32
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 +1 -0
- 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 +30 -21
- 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.d.ts +4 -3
- package/dist/src/firebase_auth/server/firebase_server.js +68 -6
- 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/*)
|
|
@@ -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]);
|
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import config from '@intl-config';
|
|
2
3
|
export declare const defaultSessionCookieName = "__fa_session__";
|
|
3
4
|
export declare const defaultRefreshTokenCookieName = "__fa_refresh_token__";
|
|
4
5
|
export declare const defaultEmailVerifiedHintCookieName = "__fa_email_verified_hint__";
|
|
5
6
|
export declare const defaultAppCheckTokenCookieName = "__fa_app_check_token__";
|
|
6
7
|
export declare const defaultResetPasswordPath = "/reset-password";
|
|
8
|
+
export declare const DEFAULT_SESSION_MAX_AGE: number;
|
|
9
|
+
export declare const DEFAULT_REFRESH_MAX_AGE: number;
|
|
10
|
+
/**
|
|
11
|
+
* The session/refresh cookie attributes, shared by every writer so the
|
|
12
|
+
* middleware and the RSC-side refresh can't drift into writing the same
|
|
13
|
+
* cookie pair with different flags or lifetimes.
|
|
14
|
+
*
|
|
15
|
+
* @param secure `false` only for a plain-http local dev origin — a `secure`
|
|
16
|
+
* cookie is silently dropped there.
|
|
17
|
+
*/
|
|
18
|
+
export declare function sessionCookieOptions(fa: NonNullable<typeof config.firebaseAuth>, secure: boolean): {
|
|
19
|
+
session: Record<string, unknown>;
|
|
20
|
+
refresh: Record<string, unknown>;
|
|
21
|
+
};
|
|
7
22
|
export declare function isIdTokenExpired(token: string): boolean;
|
|
8
23
|
export type RefreshResult = {
|
|
9
24
|
status: 'refreshed';
|
|
@@ -20,7 +35,9 @@ export type RefreshResult = {
|
|
|
20
35
|
* runtime, and `firebase/auth` pulls in Node-only APIs that break Edge
|
|
21
36
|
* bundles even though this function never touches that module.
|
|
22
37
|
*/
|
|
23
|
-
export declare function refreshIdToken(apiKey: string, refreshToken: string
|
|
38
|
+
export declare function refreshIdToken(apiKey: string, refreshToken: string, options?: {
|
|
39
|
+
skipCache?: boolean;
|
|
40
|
+
}): Promise<RefreshResult>;
|
|
24
41
|
/**
|
|
25
42
|
* Layers Firebase session-cookie validation/refresh and auth redirects onto
|
|
26
43
|
* an already-built middleware response. Called internally by `intlMiddleware`
|
|
@@ -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
|
|
@@ -36,8 +37,23 @@ function resolveActionModePaths(fa) {
|
|
|
36
37
|
paths.recoverEmail = fa.recoverEmailPath;
|
|
37
38
|
return { ...paths, ...fa.actionModePaths };
|
|
38
39
|
}
|
|
39
|
-
const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
|
|
40
|
-
const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
|
|
40
|
+
export const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
|
|
41
|
+
export const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
|
|
42
|
+
/**
|
|
43
|
+
* The session/refresh cookie attributes, shared by every writer so the
|
|
44
|
+
* middleware and the RSC-side refresh can't drift into writing the same
|
|
45
|
+
* cookie pair with different flags or lifetimes.
|
|
46
|
+
*
|
|
47
|
+
* @param secure `false` only for a plain-http local dev origin — a `secure`
|
|
48
|
+
* cookie is silently dropped there.
|
|
49
|
+
*/
|
|
50
|
+
export function sessionCookieOptions(fa, secure) {
|
|
51
|
+
const shared = { httpOnly: true, secure, sameSite: 'lax', path: '/' };
|
|
52
|
+
return {
|
|
53
|
+
session: { ...shared, maxAge: fa.sessionCookieMaxAge ?? DEFAULT_SESSION_MAX_AGE },
|
|
54
|
+
refresh: { ...shared, maxAge: fa.refreshTokenCookieMaxAge ?? DEFAULT_REFRESH_MAX_AGE },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
41
57
|
// Refresh slightly before the real expiry — treating a token as expired
|
|
42
58
|
// right up to its last second means normal clock skew or in-flight request
|
|
43
59
|
// time can hand a client a token that dies moments after this check, forcing
|
|
@@ -67,7 +83,7 @@ function isJwtExpired(token) {
|
|
|
67
83
|
// `.internal` is a non-resolvable TLD reserved by convention — no real
|
|
68
84
|
// `fetch()` in this Worker could ever have populated (or could ever
|
|
69
85
|
// collide with) an entry under it.
|
|
70
|
-
const REFRESH_CACHE_TTL_SECONDS =
|
|
86
|
+
const REFRESH_CACHE_TTL_SECONDS = 30 * 60;
|
|
71
87
|
const REFRESH_CACHE_KEY_ORIGIN = 'https://firebase-auth-refresh-cache.internal';
|
|
72
88
|
function getEdgeCache() {
|
|
73
89
|
const cachesApi = globalThis.caches;
|
|
@@ -129,9 +145,13 @@ const INVALID_REFRESH_TOKEN_ERRORS = new Set([
|
|
|
129
145
|
* runtime, and `firebase/auth` pulls in Node-only APIs that break Edge
|
|
130
146
|
* bundles even though this function never touches that module.
|
|
131
147
|
*/
|
|
132
|
-
export async function refreshIdToken(apiKey, refreshToken) {
|
|
133
|
-
|
|
134
|
-
|
|
148
|
+
export async function refreshIdToken(apiKey, refreshToken, options) {
|
|
149
|
+
// `skipCache` exists for the caller that already HAS a token the Auth
|
|
150
|
+
// service rejected: the cache entry is what produced that token, so a
|
|
151
|
+
// normal cache hit would hand back the exact same rejected token and the
|
|
152
|
+
// retry could never recover.
|
|
153
|
+
const cached = options?.skipCache ? null : await getCachedRefresh(refreshToken);
|
|
154
|
+
if (cached && !isJwtExpired(cached.idToken))
|
|
135
155
|
return { status: 'refreshed', ...cached };
|
|
136
156
|
try {
|
|
137
157
|
const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${apiKey}`, {
|
|
@@ -203,7 +223,7 @@ export default async function updateSession(request, baseResponse, locale, rebui
|
|
|
203
223
|
return baseResponse;
|
|
204
224
|
}
|
|
205
225
|
const localePrefix = locale === config.defaultLocale ? '' : requestPrefix;
|
|
206
|
-
const localeUrl = (target) => new URL(`${localePrefix}${target === '/' ? '' : target}` || '/', request.url);
|
|
226
|
+
const localeUrl = (target) => new URL(withRedirectQuery(`${localePrefix}${target === '/' ? '' : target}` || '/', request.nextUrl.search), request.url);
|
|
207
227
|
// Emailed Firebase action links all arrive on the single project-wide
|
|
208
228
|
// action URL carrying `?mode=<action>&oobCode=...`. Forward them to the
|
|
209
229
|
// page for that mode BEFORE any auth/whitelist check below: these links
|
|
@@ -371,20 +391,9 @@ export default async function updateSession(request, baseResponse, locale, rebui
|
|
|
371
391
|
baseResponse.headers.forEach((value, key) => rebuilt.headers.set(key, value));
|
|
372
392
|
response = rebuilt;
|
|
373
393
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
sameSite: 'lax',
|
|
378
|
-
path: '/',
|
|
379
|
-
maxAge: fa.sessionCookieMaxAge ?? DEFAULT_SESSION_MAX_AGE,
|
|
380
|
-
});
|
|
381
|
-
response.cookies.set(refreshTokenCookieName, refreshedToken.refreshToken, {
|
|
382
|
-
httpOnly: true,
|
|
383
|
-
secure: request.nextUrl.protocol === 'https',
|
|
384
|
-
sameSite: 'lax',
|
|
385
|
-
path: '/',
|
|
386
|
-
maxAge: fa.refreshTokenCookieMaxAge ?? DEFAULT_REFRESH_MAX_AGE,
|
|
387
|
-
});
|
|
394
|
+
const cookieOptions = sessionCookieOptions(fa, request.nextUrl.protocol === 'https');
|
|
395
|
+
response.cookies.set(sessionCookieName, refreshedToken.idToken, cookieOptions.session);
|
|
396
|
+
response.cookies.set(refreshTokenCookieName, refreshedToken.refreshToken, cookieOptions.refresh);
|
|
388
397
|
}
|
|
389
398
|
return response;
|
|
390
399
|
}
|
|
@@ -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,
|
|
@@ -3,9 +3,10 @@ import type { User } from 'firebase/auth';
|
|
|
3
3
|
/**
|
|
4
4
|
* Resolves the signed-in user on the server from the session cookie.
|
|
5
5
|
* `initializeServerApp` validates the token with the Auth service, so a
|
|
6
|
-
* missing, expired, or forged token yields `currentUser === null
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* missing, expired, or forged token yields `currentUser === null` — in which
|
|
7
|
+
* case one refresh from the refresh-token cookie is attempted before giving
|
|
8
|
+
* up. Wrapped in React's `cache()` so multiple server components in one
|
|
9
|
+
* request share a single Auth service lookup. Lazily imports `firebase/app`/
|
|
9
10
|
* `firebase/auth` — never touched unless this is actually called, and
|
|
10
11
|
* throws if `firebaseAuth` is missing from `RoutingConfig`.
|
|
11
12
|
*/
|
|
@@ -2,18 +2,39 @@ 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, defaultRefreshTokenCookieName, defaultSessionCookieName, isIdTokenExpired, refreshIdToken } from '../middleware/update_session';
|
|
5
|
+
import { defaultAppCheckTokenCookieName, defaultRefreshTokenCookieName, defaultSessionCookieName, isIdTokenExpired, refreshIdToken, sessionCookieOptions } 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;
|
|
9
9
|
let firebaseAppModule;
|
|
10
10
|
let firebaseAuthModule;
|
|
11
|
+
/**
|
|
12
|
+
* Writes a freshly-minted session/refresh pair back to the cookie jar so the
|
|
13
|
+
* NEXT request doesn't repeat the rejected-token round-trip. Next only allows
|
|
14
|
+
* cookie writes from Server Actions and Route Handlers — during an RSC render
|
|
15
|
+
* this throws, which is expected and harmless: the middleware persists the
|
|
16
|
+
* same pair on the following request, so this is a best-effort shortcut only.
|
|
17
|
+
*/
|
|
18
|
+
function persistRefreshedSession(cookieStore, sessionCookieName, refreshTokenCookieName, idToken, refreshToken) {
|
|
19
|
+
// No request URL here (unlike the middleware), so `secure` can't be
|
|
20
|
+
// derived from the protocol — always secure, which is correct for every
|
|
21
|
+
// origin a session cookie should be sent to anyway.
|
|
22
|
+
const options = sessionCookieOptions(config.firebaseAuth, true);
|
|
23
|
+
try {
|
|
24
|
+
cookieStore.set(sessionCookieName, idToken, options.session);
|
|
25
|
+
cookieStore.set(refreshTokenCookieName, refreshToken, options.refresh);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Read-only cookie jar (RSC render) — middleware handles it next request.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
11
31
|
/**
|
|
12
32
|
* Resolves the signed-in user on the server from the session cookie.
|
|
13
33
|
* `initializeServerApp` validates the token with the Auth service, so a
|
|
14
|
-
* missing, expired, or forged token yields `currentUser === null
|
|
15
|
-
*
|
|
16
|
-
*
|
|
34
|
+
* missing, expired, or forged token yields `currentUser === null` — in which
|
|
35
|
+
* case one refresh from the refresh-token cookie is attempted before giving
|
|
36
|
+
* up. Wrapped in React's `cache()` so multiple server components in one
|
|
37
|
+
* request share a single Auth service lookup. Lazily imports `firebase/app`/
|
|
17
38
|
* `firebase/auth` — never touched unless this is actually called, and
|
|
18
39
|
* throws if `firebaseAuth` is missing from `RoutingConfig`.
|
|
19
40
|
*/
|
|
@@ -60,7 +81,14 @@ export const getAuthenticatedAppForUser = cache(async function getAuthenticatedA
|
|
|
60
81
|
// behavior.
|
|
61
82
|
const appCheckToken = cookieStore.get(appCheckTokenCookieName)?.value
|
|
62
83
|
?? await mintServerAppCheckToken(fa.projectId, fa.apiKey, fa.appCheck);
|
|
63
|
-
|
|
84
|
+
// A token that isn't merely expired-by-clock (revoked session, password
|
|
85
|
+
// change, a token minted for a different project) is rejected with
|
|
86
|
+
// `auth/invalid-user-token`. `initializeServerApp` does NOT surface that
|
|
87
|
+
// as a rejection though — it logs "FirebaseServerApp could not login user
|
|
88
|
+
// with provided authIdToken" itself and simply resolves `authStateReady()`
|
|
89
|
+
// with `currentUser === null`. So a null user (not a throw) is the signal
|
|
90
|
+
// to drop the bad token and mint a replacement from the refresh cookie.
|
|
91
|
+
const attempt = async (idToken) => {
|
|
64
92
|
if (!firebaseAppModule)
|
|
65
93
|
firebaseAppModule = await import('firebase/app');
|
|
66
94
|
if (!firebaseAuthModule)
|
|
@@ -83,12 +111,46 @@ export const getAuthenticatedAppForUser = cache(async function getAuthenticatedA
|
|
|
83
111
|
// registering a new named app in Firebase's global app registry.
|
|
84
112
|
if (!baseApp)
|
|
85
113
|
baseApp = initializeApp(firebaseConfig, 'firebase-auth-server-base');
|
|
86
|
-
const firebaseServerApp = initializeServerApp(baseApp, { authIdToken, appCheckToken });
|
|
114
|
+
const firebaseServerApp = initializeServerApp(baseApp, { authIdToken: idToken, appCheckToken });
|
|
87
115
|
const auth = getAuth(firebaseServerApp);
|
|
88
116
|
await auth.authStateReady();
|
|
89
117
|
return { firebaseServerApp, currentUser: auth.currentUser };
|
|
118
|
+
};
|
|
119
|
+
const retryWithFreshToken = async (rejectedToken) => {
|
|
120
|
+
const refreshToken = cookieStore.get(refreshTokenCookieName)?.value;
|
|
121
|
+
if (!refreshToken)
|
|
122
|
+
return { firebaseServerApp: null, currentUser: null };
|
|
123
|
+
// The cached entry is what produced `rejectedToken` in the first
|
|
124
|
+
// place, so a plain refresh would hand back the same rejected token.
|
|
125
|
+
const result = await refreshIdToken(fa.apiKey, refreshToken, { skipCache: true });
|
|
126
|
+
if (result.status !== 'refreshed' || result.idToken === rejectedToken) {
|
|
127
|
+
return { firebaseServerApp: null, currentUser: null };
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const retried = await attempt(result.idToken);
|
|
131
|
+
if (retried.currentUser) {
|
|
132
|
+
persistRefreshedSession(cookieStore, sessionCookieName, refreshTokenCookieName, result.idToken, result.refreshToken);
|
|
133
|
+
}
|
|
134
|
+
return retried;
|
|
135
|
+
}
|
|
136
|
+
catch (retryError) {
|
|
137
|
+
await reportError(config, { error: retryError, classOrMethodName: 'getAuthenticatedAppForUser' });
|
|
138
|
+
return { firebaseServerApp: null, currentUser: null };
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
const first = await attempt(authIdToken);
|
|
143
|
+
// `initializeServerApp` reports an invalid/revoked token by resolving
|
|
144
|
+
// with a null user rather than throwing — retry rather than render
|
|
145
|
+
// this request as signed-out.
|
|
146
|
+
if (!first.currentUser)
|
|
147
|
+
return await retryWithFreshToken(authIdToken);
|
|
148
|
+
return first;
|
|
90
149
|
}
|
|
91
150
|
catch (error) {
|
|
151
|
+
if (error?.code === 'auth/invalid-user-token') {
|
|
152
|
+
return await retryWithFreshToken(authIdToken);
|
|
153
|
+
}
|
|
92
154
|
await reportError(config, { error, classOrMethodName: 'getAuthenticatedAppForUser' });
|
|
93
155
|
return { firebaseServerApp: null, currentUser: null };
|
|
94
156
|
}
|
|
@@ -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). */
|