cloudflare-next-intl 0.2.2 → 0.3.1
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/README.md +2 -3
- package/dist/src/client/components/client_provider.d.ts +3 -1
- package/dist/src/client/components/client_provider.js +16 -3
- package/dist/src/client/functions/get_cookie.bench.d.ts +1 -0
- package/dist/src/client/functions/get_cookie.bench.js +11 -0
- package/dist/src/client/functions/set_cookie.bench.d.ts +1 -0
- package/dist/src/client/functions/set_cookie.bench.js +10 -0
- package/dist/src/client/functions/set_cookie.d.ts +4 -2
- package/dist/src/client/functions/set_cookie.js +3 -1
- package/dist/src/client/hooks/client_hooks.d.ts +5 -0
- package/dist/src/client/hooks/client_hooks.js +7 -2
- package/dist/src/client/index.js +3 -0
- package/dist/src/config/cookie_key.bench.d.ts +1 -0
- package/dist/src/config/cookie_key.bench.js +9 -0
- package/dist/src/config/init_config.bench.d.ts +1 -0
- package/dist/src/config/init_config.bench.js +7 -0
- package/dist/src/config/intl_config.js +4 -1
- package/dist/src/config/intl_sitemap.bench.d.ts +1 -0
- package/dist/src/config/intl_sitemap.bench.js +18 -0
- package/dist/src/config/intl_sitemap.js +1 -1
- package/dist/src/config/middleware.bench.d.ts +1 -0
- package/dist/src/config/middleware.bench.js +34 -0
- package/dist/src/config/middleware.js +43 -8
- package/dist/src/firebase_auth/client/auth_actions.bench.d.ts +1 -0
- package/dist/src/firebase_auth/client/auth_actions.bench.js +36 -0
- package/dist/src/firebase_auth/client/auth_actions.d.ts +46 -0
- package/dist/src/firebase_auth/client/auth_actions.js +102 -0
- package/dist/src/firebase_auth/client/auth_user_cache.bench.d.ts +1 -0
- package/dist/src/firebase_auth/client/auth_user_cache.bench.js +11 -0
- package/dist/src/firebase_auth/client/auth_user_cache.d.ts +4 -0
- package/dist/src/firebase_auth/client/auth_user_cache.js +14 -0
- package/dist/src/firebase_auth/client/auth_user_provider.d.ts +36 -0
- package/dist/src/firebase_auth/client/auth_user_provider.js +148 -0
- package/dist/src/firebase_auth/client/firebase_client.bench.d.ts +1 -0
- package/dist/src/firebase_auth/client/firebase_client.bench.js +28 -0
- package/dist/src/firebase_auth/client/firebase_client.d.ts +18 -0
- package/dist/src/firebase_auth/client/firebase_client.js +40 -0
- package/dist/src/firebase_auth/client/use_auth_user.d.ts +12 -0
- package/dist/src/firebase_auth/client/use_auth_user.js +20 -0
- package/dist/src/firebase_auth/error_messages/default_messages.en.bench.d.ts +1 -0
- package/dist/src/firebase_auth/error_messages/default_messages.en.bench.js +10 -0
- package/dist/src/firebase_auth/error_messages/default_messages.en.d.ts +1 -0
- package/dist/src/firebase_auth/error_messages/default_messages.en.js +14 -0
- package/dist/src/firebase_auth/error_messages/firebase_auth_error_helper.bench.d.ts +1 -0
- package/dist/src/firebase_auth/error_messages/firebase_auth_error_helper.bench.js +15 -0
- package/dist/src/firebase_auth/error_messages/firebase_auth_error_helper.d.ts +6 -0
- package/dist/src/firebase_auth/error_messages/firebase_auth_error_helper.js +45 -0
- package/dist/src/firebase_auth/index.d.ts +9 -0
- package/dist/src/firebase_auth/index.js +13 -0
- package/dist/src/firebase_auth/middleware/update_session.bench.d.ts +1 -0
- package/dist/src/firebase_auth/middleware/update_session.bench.js +37 -0
- package/dist/src/firebase_auth/middleware/update_session.d.ts +22 -0
- package/dist/src/firebase_auth/middleware/update_session.js +213 -0
- package/dist/src/firebase_auth/require_config.bench.d.ts +1 -0
- package/dist/src/firebase_auth/require_config.bench.js +24 -0
- package/dist/src/firebase_auth/require_config.d.ts +8 -0
- package/dist/src/firebase_auth/require_config.js +13 -0
- package/dist/src/firebase_auth/server/auth_user_server_provider.d.ts +24 -0
- package/dist/src/firebase_auth/server/auth_user_server_provider.js +51 -0
- package/dist/src/firebase_auth/server/firebase_server.bench.d.ts +1 -0
- package/dist/src/firebase_auth/server/firebase_server.bench.js +27 -0
- package/dist/src/firebase_auth/server/firebase_server.d.ts +15 -0
- package/dist/src/firebase_auth/server/firebase_server.js +50 -0
- package/dist/src/firebase_auth/server/use_auth_user_server.bench.d.ts +1 -0
- package/dist/src/firebase_auth/server/use_auth_user_server.bench.js +14 -0
- package/dist/src/firebase_auth/server/use_auth_user_server.d.ts +39 -0
- package/dist/src/firebase_auth/server/use_auth_user_server.js +37 -0
- package/dist/src/firebase_auth/types.d.ts +22 -0
- package/dist/src/firebase_auth/types.js +1 -0
- package/dist/src/general/cache_variables.bench.d.ts +1 -0
- package/dist/src/general/cache_variables.bench.js +19 -0
- package/dist/src/general/cache_variables.d.ts +1 -0
- package/dist/src/general/cache_variables.js +4 -6
- package/dist/src/general/general_functions.bench.d.ts +1 -0
- package/dist/src/general/general_functions.bench.js +18 -0
- package/dist/src/general/general_functions.js +15 -2
- package/dist/src/general/index.js +0 -1
- package/dist/src/general/metadata.bench.d.ts +1 -0
- package/dist/src/general/metadata.bench.js +15 -0
- package/dist/src/general/metadata.js +1 -1
- package/dist/src/server/components/server_provider.js +7 -1
- package/dist/src/server/functions/get_user_locale.bench.d.ts +1 -0
- package/dist/src/server/functions/get_user_locale.bench.js +13 -0
- package/dist/src/server/functions/get_user_locale.js +7 -4
- package/dist/src/server/functions/server.bench.d.ts +1 -0
- package/dist/src/server/functions/server.bench.js +39 -0
- package/dist/src/server/functions/server.js +9 -5
- package/dist/src/server/functions/use_functions.bench.d.ts +1 -0
- package/dist/src/server/functions/use_functions.bench.js +23 -0
- package/dist/src/server/functions/use_functions.d.ts +5 -0
- package/dist/src/server/functions/use_functions.js +19 -4
- package/dist/src/server/index.js +4 -1
- package/dist/src/test_utils/mock_next_server.d.ts +5 -0
- package/dist/src/test_utils/mock_next_server.js +10 -0
- package/dist/src/types/types.d.ts +48 -1
- package/llms.txt +48 -0
- package/package.json +52 -10
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import config from '@intl-config';
|
|
3
|
+
export const sessionCookieName = '__fa_session__';
|
|
4
|
+
export const refreshTokenCookieName = '__fa_refresh_token__';
|
|
5
|
+
const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
|
|
6
|
+
const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
|
|
7
|
+
// Refresh slightly before the real expiry — treating a token as expired
|
|
8
|
+
// right up to its last second means normal clock skew or in-flight request
|
|
9
|
+
// time can hand a client a token that dies moments after this check, forcing
|
|
10
|
+
// an extra round-trip on the very next request.
|
|
11
|
+
const CLOCK_SKEW_MARGIN_MS = 60 * 1000;
|
|
12
|
+
function isJwtExpired(token) {
|
|
13
|
+
try {
|
|
14
|
+
const payload = token.split('.')[1];
|
|
15
|
+
const { exp } = JSON.parse(atob(payload.replace(/[-_]/g, (c) => c === '-' ? '+' : '/')));
|
|
16
|
+
return !exp || exp * 1000 - CLOCK_SKEW_MARGIN_MS <= Date.now();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// Refreshing an ID token is a real network round-trip to Google on the
|
|
23
|
+
// Edge middleware's critical path, on every request where the session
|
|
24
|
+
// cookie has expired — which, for any session older than the ~1hr ID-token
|
|
25
|
+
// lifetime, is every request until the client re-syncs. Firebase refresh
|
|
26
|
+
// tokens don't rotate on use (the API returns the same refresh_token back)
|
|
27
|
+
// — THIS is the precondition that makes caching by refresh-token identity
|
|
28
|
+
// safe: if Firebase ever changed that behavior, this cache would hand out
|
|
29
|
+
// a refresh token the client no longer holds. Given that precondition,
|
|
30
|
+
// caching a successful refresh result is a pure memoization: a cache hit
|
|
31
|
+
// skips the round-trip entirely, a miss falls through to the exact same
|
|
32
|
+
// fetch this function always made. Only available on Cloudflare Workers
|
|
33
|
+
// (`caches.default`); everywhere else this is a no-op and every request
|
|
34
|
+
// pays the full round-trip, same as before this change. The synthetic
|
|
35
|
+
// origin below is safe as a Cache API key specifically because
|
|
36
|
+
// `.internal` is a non-resolvable TLD reserved by convention — no real
|
|
37
|
+
// `fetch()` in this Worker could ever have populated (or could ever
|
|
38
|
+
// collide with) an entry under it.
|
|
39
|
+
const REFRESH_CACHE_TTL_SECONDS = 50 * 60;
|
|
40
|
+
const REFRESH_CACHE_KEY_ORIGIN = 'https://firebase-auth-refresh-cache.internal';
|
|
41
|
+
function getEdgeCache() {
|
|
42
|
+
const cachesApi = globalThis.caches;
|
|
43
|
+
return cachesApi?.default;
|
|
44
|
+
}
|
|
45
|
+
async function hashRefreshToken(refreshToken) {
|
|
46
|
+
const data = new TextEncoder().encode(refreshToken);
|
|
47
|
+
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
48
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
49
|
+
}
|
|
50
|
+
async function getCachedRefresh(refreshToken) {
|
|
51
|
+
const cache = getEdgeCache();
|
|
52
|
+
if (!cache)
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
const key = `${REFRESH_CACHE_KEY_ORIGIN}/${await hashRefreshToken(refreshToken)}`;
|
|
56
|
+
const cached = await cache.match(key);
|
|
57
|
+
if (!cached)
|
|
58
|
+
return null;
|
|
59
|
+
return await cached.json();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function setCachedRefresh(refreshToken, refreshed) {
|
|
66
|
+
const cache = getEdgeCache();
|
|
67
|
+
if (!cache)
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
const key = `${REFRESH_CACHE_KEY_ORIGIN}/${await hashRefreshToken(refreshToken)}`;
|
|
71
|
+
await cache.put(key, new Response(JSON.stringify(refreshed), {
|
|
72
|
+
headers: { 'Cache-Control': `max-age=${REFRESH_CACHE_TTL_SECONDS}` },
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Best-effort: a caching failure must never affect the actual
|
|
77
|
+
// refresh result already returned to the caller.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Mints a fresh ID token from a stored refresh token via Google's Secure
|
|
82
|
+
* Token API. No `firebase/auth` import: this runs in the Edge middleware
|
|
83
|
+
* runtime, and `firebase/auth` pulls in Node-only APIs that break Edge
|
|
84
|
+
* bundles even though this function never touches that module.
|
|
85
|
+
*/
|
|
86
|
+
async function refreshIdToken(apiKey, refreshToken) {
|
|
87
|
+
const cached = await getCachedRefresh(refreshToken);
|
|
88
|
+
if (cached)
|
|
89
|
+
return cached;
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${apiKey}`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
94
|
+
body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`,
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
return null;
|
|
98
|
+
const data = await res.json();
|
|
99
|
+
const refreshed = { idToken: data.id_token, refreshToken: data.refresh_token };
|
|
100
|
+
// Not awaited: the cache write is a pure optimization for FUTURE
|
|
101
|
+
// requests, not this one — blocking this response on it would
|
|
102
|
+
// reintroduce the exact latency this fix exists to remove.
|
|
103
|
+
// setCachedRefresh already swallows its own errors, so a rejected
|
|
104
|
+
// write here would otherwise surface as an unhandled rejection.
|
|
105
|
+
void setCachedRefresh(refreshToken, refreshed);
|
|
106
|
+
return refreshed;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Layers Firebase session-cookie validation/refresh and auth redirects onto
|
|
114
|
+
* an already-built middleware response. Called internally by `intlMiddleware`
|
|
115
|
+
* (via dynamic import) when `config.firebaseAuth` is set and
|
|
116
|
+
* `middlewareEnabled !== false` — not intended to be called directly unless
|
|
117
|
+
* that auto-wiring is opted out of.
|
|
118
|
+
*
|
|
119
|
+
* @param baseResponse The response `intlMiddleware` already produced for
|
|
120
|
+
* locale routing (its own next()/rewrite()/redirect(), with the locale
|
|
121
|
+
* cookie / bot cookie / `Content-Language` header already set). On the
|
|
122
|
+
* pass-through path this function returns `baseResponse` itself (with
|
|
123
|
+
* Firebase cookies layered on) so none of that is dropped; on the
|
|
124
|
+
* guest/auth-page redirect paths it returns a NEW response instead, since
|
|
125
|
+
* a redirect response can't also carry forward a rewrite/next decision —
|
|
126
|
+
* its cookies/headers are copied across from `baseResponse` so locale
|
|
127
|
+
* state still survives the redirect.
|
|
128
|
+
* @param locale The effective locale `intlMiddleware` resolved for this request.
|
|
129
|
+
*/
|
|
130
|
+
export default async function updateSession(request, baseResponse, locale) {
|
|
131
|
+
const fa = config.firebaseAuth;
|
|
132
|
+
if (!fa || fa.middlewareEnabled === false)
|
|
133
|
+
return baseResponse;
|
|
134
|
+
const rawPath = request.nextUrl.pathname;
|
|
135
|
+
const requestPrefix = `/${locale}`;
|
|
136
|
+
const path = rawPath === requestPrefix || rawPath.startsWith(`${requestPrefix}/`)
|
|
137
|
+
? rawPath.slice(requestPrefix.length) || '/'
|
|
138
|
+
: rawPath;
|
|
139
|
+
const lastSegment = rawPath.slice(rawPath.lastIndexOf('/') + 1);
|
|
140
|
+
if (rawPath.startsWith('/_next') || /\.[a-zA-Z0-9]+$/.test(lastSegment)) {
|
|
141
|
+
return baseResponse;
|
|
142
|
+
}
|
|
143
|
+
const localePrefix = locale === config.locales[0] ? '' : requestPrefix;
|
|
144
|
+
const localeUrl = (target) => new URL(`${localePrefix}${target === '/' ? '' : target}` || '/', request.url);
|
|
145
|
+
const isWhiteListed = fa.whiteListPaths?.includes(path) ?? false;
|
|
146
|
+
if (isWhiteListed)
|
|
147
|
+
return baseResponse;
|
|
148
|
+
const isAuthPage = fa.isAuthPath(path);
|
|
149
|
+
let token = request.cookies.get(sessionCookieName)?.value;
|
|
150
|
+
let refreshedToken = null;
|
|
151
|
+
let clearInvalidSession = false;
|
|
152
|
+
if (token && isJwtExpired(token)) {
|
|
153
|
+
token = undefined;
|
|
154
|
+
}
|
|
155
|
+
if (!token) {
|
|
156
|
+
const refreshToken = request.cookies.get(refreshTokenCookieName)?.value;
|
|
157
|
+
if (refreshToken) {
|
|
158
|
+
refreshedToken = await refreshIdToken(fa.apiKey, refreshToken);
|
|
159
|
+
if (refreshedToken) {
|
|
160
|
+
token = refreshedToken.idToken;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
clearInvalidSession = true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
else if (request.cookies.get(sessionCookieName)) {
|
|
167
|
+
clearInvalidSession = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const hasSession = !!token;
|
|
171
|
+
let response;
|
|
172
|
+
if (!hasSession) {
|
|
173
|
+
response = isAuthPage ? baseResponse : buildRedirect(baseResponse, localeUrl(fa.redirectAuthPath));
|
|
174
|
+
}
|
|
175
|
+
else if (isAuthPage) {
|
|
176
|
+
response = buildRedirect(baseResponse, localeUrl(fa.homePath));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
response = baseResponse;
|
|
180
|
+
}
|
|
181
|
+
if (clearInvalidSession) {
|
|
182
|
+
response.cookies.delete(sessionCookieName);
|
|
183
|
+
response.cookies.delete(refreshTokenCookieName);
|
|
184
|
+
}
|
|
185
|
+
if (refreshedToken) {
|
|
186
|
+
response.cookies.set(sessionCookieName, refreshedToken.idToken, {
|
|
187
|
+
// Not httpOnly: the client provider also writes this cookie
|
|
188
|
+
// directly (via document.cookie) after `getIdToken(true)`, so it
|
|
189
|
+
// must stay client-writable — a JS cookie write can never carry
|
|
190
|
+
// httpOnly anyway, and two same-name cookies with conflicting
|
|
191
|
+
// flags is what actually caused ambiguity here.
|
|
192
|
+
secure: request.nextUrl.protocol === 'https',
|
|
193
|
+
sameSite: 'lax',
|
|
194
|
+
path: '/',
|
|
195
|
+
maxAge: fa.sessionCookieMaxAge ?? DEFAULT_SESSION_MAX_AGE,
|
|
196
|
+
});
|
|
197
|
+
response.cookies.set(refreshTokenCookieName, refreshedToken.refreshToken, {
|
|
198
|
+
httpOnly: true,
|
|
199
|
+
secure: request.nextUrl.protocol === 'https',
|
|
200
|
+
sameSite: 'lax',
|
|
201
|
+
path: '/',
|
|
202
|
+
maxAge: fa.refreshTokenCookieMaxAge ?? DEFAULT_REFRESH_MAX_AGE,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
return response;
|
|
206
|
+
}
|
|
207
|
+
/** A redirect response can't carry forward `baseResponse`'s rewrite/next decision, so this copies its cookies/headers across instead of dropping them. */
|
|
208
|
+
function buildRedirect(baseResponse, url) {
|
|
209
|
+
const redirectResponse = NextResponse.redirect(url);
|
|
210
|
+
baseResponse.cookies.getAll().forEach((cookie) => redirectResponse.cookies.set(cookie));
|
|
211
|
+
baseResponse.headers.forEach((value, key) => redirectResponse.headers.set(key, value));
|
|
212
|
+
return redirectResponse;
|
|
213
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { bench, describe } from 'vitest';
|
|
2
|
+
import requireFirebaseAuthConfig from './require_config';
|
|
3
|
+
const fa = {
|
|
4
|
+
apiKey: 'key',
|
|
5
|
+
authDomain: 'domain',
|
|
6
|
+
projectId: 'proj',
|
|
7
|
+
appId: 'app',
|
|
8
|
+
redirectAuthPath: '/login',
|
|
9
|
+
homePath: '/',
|
|
10
|
+
isAuthPath: () => false,
|
|
11
|
+
};
|
|
12
|
+
describe('requireFirebaseAuthConfig', () => {
|
|
13
|
+
bench('valid config: passes the assertion', () => {
|
|
14
|
+
requireFirebaseAuthConfig(fa);
|
|
15
|
+
});
|
|
16
|
+
bench('missing config: throws', () => {
|
|
17
|
+
try {
|
|
18
|
+
requireFirebaseAuthConfig(undefined);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// expected
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { FirebaseAuthRoutingConfig } from '../types/types';
|
|
2
|
+
/**
|
|
3
|
+
* Every firebase_auth export calls this before touching `firebase/*`. Throws
|
|
4
|
+
* instead of silently no-op'ing so a consumer who calls e.g. `useAuthUser()`
|
|
5
|
+
* without setting `firebaseAuth` on their `RoutingConfig` gets an immediate,
|
|
6
|
+
* actionable error rather than a silent null/undefined.
|
|
7
|
+
*/
|
|
8
|
+
export default function requireFirebaseAuthConfig(fa: FirebaseAuthRoutingConfig | undefined): asserts fa is FirebaseAuthRoutingConfig;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every firebase_auth export calls this before touching `firebase/*`. Throws
|
|
3
|
+
* instead of silently no-op'ing so a consumer who calls e.g. `useAuthUser()`
|
|
4
|
+
* without setting `firebaseAuth` on their `RoutingConfig` gets an immediate,
|
|
5
|
+
* actionable error rather than a silent null/undefined.
|
|
6
|
+
*/
|
|
7
|
+
export default function requireFirebaseAuthConfig(fa) {
|
|
8
|
+
if (!fa) {
|
|
9
|
+
throw new Error('firebase_auth: `firebaseAuth` is not set on your RoutingConfig. ' +
|
|
10
|
+
'Add a `firebaseAuth` object (Firebase project config + route config) ' +
|
|
11
|
+
'to the config passed to `setIntlConfig` before using any firebase_auth export.');
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SerializedAuthUser } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves the signed-in user from the session cookie and performs the
|
|
4
|
+
* authoritative pre-render redirect (guest→`redirectAuthPath`, signed-in→
|
|
5
|
+
* `homePath` on auth pages) — middleware only checks cookie *presence*, not
|
|
6
|
+
* validity. Plain async function, not a component: callers decide
|
|
7
|
+
* where/how to use the resolved user relative to their own component tree
|
|
8
|
+
* (see `AuthUserServerProvider` below for the simple case, and
|
|
9
|
+
* `IntlProvider`'s auto-wiring for the case where ordering against
|
|
10
|
+
* `LocaleContext` matters).
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveAuthUserAndRedirect(): Promise<SerializedAuthUser | null>;
|
|
13
|
+
/**
|
|
14
|
+
* Convenience component for the manual-override path
|
|
15
|
+
* (`firebaseAuth.middlewareEnabled: false`-style manual wiring): resolves +
|
|
16
|
+
* redirects, then wraps `children` in the client `AuthUserProvider`
|
|
17
|
+
* directly. NOT used by the default auto-wiring path — `IntlProvider`/
|
|
18
|
+
* `LocationzationClientProvider` call `resolveAuthUserAndRedirect` and the
|
|
19
|
+
* client `AuthUserProvider` separately instead, so the client provider can
|
|
20
|
+
* render inside `LocaleContext.Provider` rather than outside it.
|
|
21
|
+
*/
|
|
22
|
+
export default function AuthUserServerProvider({ children }: {
|
|
23
|
+
children: React.ReactNode;
|
|
24
|
+
}): Promise<import("react").JSX.Element>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import dynamic from 'next/dynamic';
|
|
3
|
+
import { headers } from 'next/headers';
|
|
4
|
+
import { redirect } from 'next/navigation';
|
|
5
|
+
import config from '@intl-config';
|
|
6
|
+
import requireFirebaseAuthConfig from '../require_config';
|
|
7
|
+
import { getAuthenticatedAppForUser } from './firebase_server';
|
|
8
|
+
const AuthUserProvider = dynamic(() => import('../client/auth_user_provider'));
|
|
9
|
+
/**
|
|
10
|
+
* Resolves the signed-in user from the session cookie and performs the
|
|
11
|
+
* authoritative pre-render redirect (guest→`redirectAuthPath`, signed-in→
|
|
12
|
+
* `homePath` on auth pages) — middleware only checks cookie *presence*, not
|
|
13
|
+
* validity. Plain async function, not a component: callers decide
|
|
14
|
+
* where/how to use the resolved user relative to their own component tree
|
|
15
|
+
* (see `AuthUserServerProvider` below for the simple case, and
|
|
16
|
+
* `IntlProvider`'s auto-wiring for the case where ordering against
|
|
17
|
+
* `LocaleContext` matters).
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveAuthUserAndRedirect() {
|
|
20
|
+
const fa = config.firebaseAuth;
|
|
21
|
+
requireFirebaseAuthConfig(fa);
|
|
22
|
+
const { currentUser } = await getAuthenticatedAppForUser();
|
|
23
|
+
const path = (await headers()).get('x-pathname') ?? '/';
|
|
24
|
+
const isAuthPage = fa.isAuthPath(path);
|
|
25
|
+
const isWhiteListed = fa.whiteListPaths?.includes(path) ?? false;
|
|
26
|
+
if (!isWhiteListed) {
|
|
27
|
+
if (!currentUser && !isAuthPage)
|
|
28
|
+
redirect(fa.redirectAuthPath);
|
|
29
|
+
if (currentUser && isAuthPage)
|
|
30
|
+
redirect(fa.homePath);
|
|
31
|
+
}
|
|
32
|
+
return currentUser && {
|
|
33
|
+
uid: currentUser.uid,
|
|
34
|
+
email: currentUser.email,
|
|
35
|
+
emailVerified: currentUser.emailVerified,
|
|
36
|
+
displayName: currentUser.displayName,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Convenience component for the manual-override path
|
|
41
|
+
* (`firebaseAuth.middlewareEnabled: false`-style manual wiring): resolves +
|
|
42
|
+
* redirects, then wraps `children` in the client `AuthUserProvider`
|
|
43
|
+
* directly. NOT used by the default auto-wiring path — `IntlProvider`/
|
|
44
|
+
* `LocationzationClientProvider` call `resolveAuthUserAndRedirect` and the
|
|
45
|
+
* client `AuthUserProvider` separately instead, so the client provider can
|
|
46
|
+
* render inside `LocaleContext.Provider` rather than outside it.
|
|
47
|
+
*/
|
|
48
|
+
export default async function AuthUserServerProvider({ children }) {
|
|
49
|
+
const initialUser = await resolveAuthUserAndRedirect();
|
|
50
|
+
return _jsx(AuthUserProvider, { initialUser: initialUser, children: children });
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { bench, describe, vi } from 'vitest';
|
|
2
|
+
const fa = {
|
|
3
|
+
apiKey: 'bench-key',
|
|
4
|
+
authDomain: 'domain',
|
|
5
|
+
projectId: 'proj',
|
|
6
|
+
appId: 'app',
|
|
7
|
+
redirectAuthPath: '/login',
|
|
8
|
+
homePath: '/',
|
|
9
|
+
isAuthPath: () => false,
|
|
10
|
+
};
|
|
11
|
+
vi.mock('@intl-config', () => ({ default: { firebaseAuth: fa } }));
|
|
12
|
+
vi.mock('next/headers', () => ({
|
|
13
|
+
cookies: vi.fn(async () => ({ get: () => ({ value: 'bench-token-123456' }) })),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('firebase/app', () => ({
|
|
16
|
+
initializeApp: vi.fn(() => ({ name: 'bench-app' })),
|
|
17
|
+
initializeServerApp: vi.fn(() => ({ name: 'bench-server-app' })),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock('firebase/auth', () => ({
|
|
20
|
+
getAuth: vi.fn(() => ({ currentUser: { uid: 'bench-user' }, authStateReady: vi.fn(async () => { }) })),
|
|
21
|
+
}));
|
|
22
|
+
describe('getAuthenticatedAppForUser', () => {
|
|
23
|
+
bench('resolves a validated session (mocked Firebase)', async () => {
|
|
24
|
+
const { getAuthenticatedAppForUser } = await import('./firebase_server');
|
|
25
|
+
await getAuthenticatedAppForUser();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FirebaseApp } from 'firebase/app';
|
|
2
|
+
import type { User } from 'firebase/auth';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the signed-in user on the server from the session cookie.
|
|
5
|
+
* `initializeServerApp` validates the token with the Auth service, so a
|
|
6
|
+
* missing, expired, or forged token yields `currentUser === null`.
|
|
7
|
+
* Wrapped in React's `cache()` so multiple server components in one request
|
|
8
|
+
* share a single Auth service lookup. Lazily imports `firebase/app`/
|
|
9
|
+
* `firebase/auth` — never touched unless this is actually called, and
|
|
10
|
+
* throws if `firebaseAuth` is missing from `RoutingConfig`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const getAuthenticatedAppForUser: () => Promise<{
|
|
13
|
+
firebaseServerApp: FirebaseApp | null;
|
|
14
|
+
currentUser: User | null;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { cookies } from 'next/headers';
|
|
2
|
+
import { cache } from 'react';
|
|
3
|
+
import config from '@intl-config';
|
|
4
|
+
import requireFirebaseAuthConfig from '../require_config';
|
|
5
|
+
import { sessionCookieName } from '../middleware/update_session';
|
|
6
|
+
let baseApp;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves the signed-in user on the server from the session cookie.
|
|
9
|
+
* `initializeServerApp` validates the token with the Auth service, so a
|
|
10
|
+
* missing, expired, or forged token yields `currentUser === null`.
|
|
11
|
+
* Wrapped in React's `cache()` so multiple server components in one request
|
|
12
|
+
* share a single Auth service lookup. Lazily imports `firebase/app`/
|
|
13
|
+
* `firebase/auth` — never touched unless this is actually called, and
|
|
14
|
+
* throws if `firebaseAuth` is missing from `RoutingConfig`.
|
|
15
|
+
*/
|
|
16
|
+
export const getAuthenticatedAppForUser = cache(async function getAuthenticatedAppForUser() {
|
|
17
|
+
const fa = config.firebaseAuth;
|
|
18
|
+
requireFirebaseAuthConfig(fa);
|
|
19
|
+
const authIdToken = (await cookies()).get(sessionCookieName)?.value;
|
|
20
|
+
if (!authIdToken) {
|
|
21
|
+
return { firebaseServerApp: null, currentUser: null };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const { initializeApp, initializeServerApp } = await import('firebase/app');
|
|
25
|
+
const { getAuth } = await import('firebase/auth');
|
|
26
|
+
const firebaseConfig = {
|
|
27
|
+
apiKey: fa.apiKey,
|
|
28
|
+
authDomain: fa.authDomain,
|
|
29
|
+
projectId: fa.projectId,
|
|
30
|
+
storageBucket: fa.storageBucket,
|
|
31
|
+
messagingSenderId: fa.messagingSenderId,
|
|
32
|
+
appId: fa.appId,
|
|
33
|
+
measurementId: fa.measurementId,
|
|
34
|
+
};
|
|
35
|
+
// One shared base app per process (keyed by nothing but existence —
|
|
36
|
+
// a single RoutingConfig means a single Firebase project), not one
|
|
37
|
+
// named app per token: `initializeServerApp` derives a distinct,
|
|
38
|
+
// token-scoped auth context from this same base app without
|
|
39
|
+
// registering a new named app in Firebase's global app registry.
|
|
40
|
+
if (!baseApp)
|
|
41
|
+
baseApp = initializeApp(firebaseConfig, 'firebase-auth-server-base');
|
|
42
|
+
const firebaseServerApp = initializeServerApp(baseApp, { authIdToken });
|
|
43
|
+
const auth = getAuth(firebaseServerApp);
|
|
44
|
+
await auth.authStateReady();
|
|
45
|
+
return { firebaseServerApp, currentUser: auth.currentUser };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { firebaseServerApp: null, currentUser: null };
|
|
49
|
+
}
|
|
50
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { bench, describe, vi } from 'vitest';
|
|
3
|
+
vi.mock('./firebase_server', () => ({
|
|
4
|
+
getAuthenticatedAppForUser: vi.fn(async () => ({
|
|
5
|
+
firebaseServerApp: null,
|
|
6
|
+
currentUser: { uid: 'bench-user' },
|
|
7
|
+
})),
|
|
8
|
+
}));
|
|
9
|
+
describe('useAuthUser (server)', () => {
|
|
10
|
+
bench('resolves the authenticated user', async () => {
|
|
11
|
+
const { default: useAuthUser } = await import('./use_auth_user_server');
|
|
12
|
+
await useAuthUser();
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves the current request's authenticated Firebase user.
|
|
4
|
+
* @returns `{ user, loading: false }` — `loading` is always `false` here;
|
|
5
|
+
* server resolution is synchronous with respect to the awaited call.
|
|
6
|
+
*/
|
|
7
|
+
declare function iGetAuthUser(): Promise<{
|
|
8
|
+
user: User | null;
|
|
9
|
+
loading: false;
|
|
10
|
+
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Server Component/Action only: resolves the current request's
|
|
13
|
+
* authenticated Firebase user, same style as {@link getLocale}/
|
|
14
|
+
* {@link getTranslations} — an unconditional, always-`async` export
|
|
15
|
+
* (`cloudflare-next-intl/getFirebaseAuthUser`), so the `await` requirement
|
|
16
|
+
* is visible from the type itself in every editor.
|
|
17
|
+
*
|
|
18
|
+
* Returns the same `{ user, loading }` shape the client `useAuthUser()`
|
|
19
|
+
* hook's context exposes, so `const { user } = await getAuthUser()`
|
|
20
|
+
* generalizes correctly from `const { user } = useAuthUser()` on the client.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const { user } = await getAuthUser();
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare const getAuthUser: typeof iGetAuthUser;
|
|
28
|
+
/**
|
|
29
|
+
* Server Component counterpart of the client `useAuthUser()` hook, reached
|
|
30
|
+
* via `cloudflare-next-intl/useFirebaseAuthUser`'s `react-server` condition
|
|
31
|
+
* (resolved automatically — not meant to be imported directly by name).
|
|
32
|
+
* Prefer {@link getAuthUser} for an unconditional, editor-typed-as-async
|
|
33
|
+
* equivalent.
|
|
34
|
+
*/
|
|
35
|
+
export default function useAuthUser(): Promise<{
|
|
36
|
+
user: User | null;
|
|
37
|
+
loading: false;
|
|
38
|
+
}>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getAuthenticatedAppForUser } from './firebase_server';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves the current request's authenticated Firebase user.
|
|
4
|
+
* @returns `{ user, loading: false }` — `loading` is always `false` here;
|
|
5
|
+
* server resolution is synchronous with respect to the awaited call.
|
|
6
|
+
*/
|
|
7
|
+
async function iGetAuthUser() {
|
|
8
|
+
const { currentUser } = await getAuthenticatedAppForUser();
|
|
9
|
+
return { user: currentUser, loading: false };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Server Component/Action only: resolves the current request's
|
|
13
|
+
* authenticated Firebase user, same style as {@link getLocale}/
|
|
14
|
+
* {@link getTranslations} — an unconditional, always-`async` export
|
|
15
|
+
* (`cloudflare-next-intl/getFirebaseAuthUser`), so the `await` requirement
|
|
16
|
+
* is visible from the type itself in every editor.
|
|
17
|
+
*
|
|
18
|
+
* Returns the same `{ user, loading }` shape the client `useAuthUser()`
|
|
19
|
+
* hook's context exposes, so `const { user } = await getAuthUser()`
|
|
20
|
+
* generalizes correctly from `const { user } = useAuthUser()` on the client.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```tsx
|
|
24
|
+
* const { user } = await getAuthUser();
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export const getAuthUser = iGetAuthUser;
|
|
28
|
+
/**
|
|
29
|
+
* Server Component counterpart of the client `useAuthUser()` hook, reached
|
|
30
|
+
* via `cloudflare-next-intl/useFirebaseAuthUser`'s `react-server` condition
|
|
31
|
+
* (resolved automatically — not meant to be imported directly by name).
|
|
32
|
+
* Prefer {@link getAuthUser} for an unconditional, editor-typed-as-async
|
|
33
|
+
* equivalent.
|
|
34
|
+
*/
|
|
35
|
+
export default async function useAuthUser() {
|
|
36
|
+
return getAuthUser();
|
|
37
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { User } from 'firebase/auth';
|
|
2
|
+
/**
|
|
3
|
+
* Plain, RSC-serializable projection of `firebase/auth`'s `User` — enough
|
|
4
|
+
* for first paint on the server; superseded by the real `User` once the
|
|
5
|
+
* client's `onIdTokenChanged` listener fires.
|
|
6
|
+
*/
|
|
7
|
+
export interface SerializedAuthUser {
|
|
8
|
+
uid: string;
|
|
9
|
+
email: string | null;
|
|
10
|
+
emailVerified: boolean;
|
|
11
|
+
displayName: string | null;
|
|
12
|
+
}
|
|
13
|
+
export type AuthFormState = {
|
|
14
|
+
error?: string;
|
|
15
|
+
success?: boolean;
|
|
16
|
+
};
|
|
17
|
+
/** Overrides for the default English auth error/status messages. */
|
|
18
|
+
export interface AuthActionMessages {
|
|
19
|
+
success?: string;
|
|
20
|
+
mismatch?: string;
|
|
21
|
+
}
|
|
22
|
+
export type AuthUser = User | SerializedAuthUser;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { bench, describe } from 'vitest';
|
|
2
|
+
import { setLocaleCache, getLocaleCache, setMessageForLocaleCache, getMessageCache, setTranslationCache, getTranslationCache, } from './cache_variables';
|
|
3
|
+
describe('cache_variables', () => {
|
|
4
|
+
bench('setLocaleCache + getLocaleCache round-trip', () => {
|
|
5
|
+
setLocaleCache('en');
|
|
6
|
+
getLocaleCache();
|
|
7
|
+
});
|
|
8
|
+
bench('setMessageForLocaleCache + getMessageCache hit', () => {
|
|
9
|
+
setMessageForLocaleCache('en', { common: { hello: 'Hello' } });
|
|
10
|
+
getMessageCache('en');
|
|
11
|
+
});
|
|
12
|
+
bench('getMessageCache miss (no locale provided)', () => {
|
|
13
|
+
getMessageCache(undefined);
|
|
14
|
+
});
|
|
15
|
+
bench('setTranslationCache + getTranslationCache hit', () => {
|
|
16
|
+
setTranslationCache('en-common', (k) => k);
|
|
17
|
+
getTranslationCache('en-common');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -11,3 +11,4 @@ export declare function getLocaleCache(): string | undefined;
|
|
|
11
11
|
export declare function setMessageForLocaleCache(locale: string, message: TranslationObject): void;
|
|
12
12
|
export declare function getMessageCache(locale?: string): TranslationObject | undefined;
|
|
13
13
|
export declare function setTranslationCache(cache: string, value: TranslatorReturnType): void;
|
|
14
|
+
export declare function getTranslationCache(cacheKey: string): TranslatorReturnType | undefined;
|
|
@@ -20,13 +20,11 @@ export function setMessageForLocaleCache(locale, message) {
|
|
|
20
20
|
loadedTranslations.set(locale, message);
|
|
21
21
|
}
|
|
22
22
|
export function getMessageCache(locale) {
|
|
23
|
-
|
|
24
|
-
return loadedTranslations.get(locale);
|
|
25
|
-
}
|
|
26
|
-
else {
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
23
|
+
return locale ? loadedTranslations.get(locale) : undefined;
|
|
29
24
|
}
|
|
30
25
|
export function setTranslationCache(cache, value) {
|
|
31
26
|
translationFunctionsCache.set(cache, value);
|
|
32
27
|
}
|
|
28
|
+
export function getTranslationCache(cacheKey) {
|
|
29
|
+
return translationFunctionsCache.get(cacheKey);
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { bench, describe } from 'vitest';
|
|
2
|
+
import { getTranslationsImpl } from './general_functions';
|
|
3
|
+
const shallowMessages = { common: { hello: 'Hello' } };
|
|
4
|
+
const deepMessages = { a: { b: { c: { d: { e: { hello: 'Deeply nested hello' } } } } } };
|
|
5
|
+
describe('getTranslationsImpl', () => {
|
|
6
|
+
bench('shallow namespace, cold (new cacheKey each call)', () => {
|
|
7
|
+
getTranslationsImpl('en', shallowMessages, 'common', `cold-${Math.random()}`);
|
|
8
|
+
});
|
|
9
|
+
bench('shallow namespace, warm (same cacheKey, cache hit)', () => {
|
|
10
|
+
getTranslationsImpl('en', shallowMessages, 'common', 'warm-shallow');
|
|
11
|
+
});
|
|
12
|
+
bench('deep namespace (5 levels), cold', () => {
|
|
13
|
+
getTranslationsImpl('en', deepMessages, 'a.b.c.d.e', `cold-deep-${Math.random()}`);
|
|
14
|
+
});
|
|
15
|
+
bench('deep namespace (5 levels), warm', () => {
|
|
16
|
+
getTranslationsImpl('en', deepMessages, 'a.b.c.d.e', 'warm-deep');
|
|
17
|
+
});
|
|
18
|
+
});
|