Skip to content

Commit 11f38d4

Browse files
committed
Handle targetAppName
1 parent 2545c2a commit 11f38d4

2 files changed

Lines changed: 116 additions & 22 deletions

File tree

src/nextjs/index.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,12 @@ export async function runMiddleware(
311311
name: REFRESH_TOKEN_COOKIE_NAME,
312312
} as const;
313313

314-
// TODO match appName to parameter
315314
if (request.nextUrl.pathname === "/__cookies__") {
315+
const targetAppName = request.nextUrl.searchParams.get("appName") || "[DEFAULT]";
316+
if (targetAppName !== appName) {
317+
return [undefined, (res: NextResponse) => res, undefined];
318+
}
319+
316320
const method = request.method;
317321
if (method === "DELETE") {
318322
const response = new NextResponse("");
@@ -336,17 +340,30 @@ export async function runMiddleware(
336340
.map((header) => [header, request.headers.get(header)!]),
337341
);
338342

339-
const url = new URL(request.nextUrl.searchParams.get("finalTarget")!);
343+
const finalTargetParam = request.nextUrl.searchParams.get("finalTarget");
344+
if (!finalTargetParam) {
345+
return [new NextResponse("Missing finalTarget parameter", { status: 400 })];
346+
}
347+
const url = new URL(finalTargetParam);
340348

341349
let body: ReadableStream<any> | string | null = request.body;
342350

343-
if (options.emulatorHost && url.host !== options.emulatorHost) {
344-
throw new Error(`Emulator mismatch: ${url.host} vs ${options.emulatorHost}`);
351+
if (options.emulatorHost) {
352+
if (url.host !== options.emulatorHost) {
353+
throw new Error(`Emulator mismatch: ${url.host} vs ${options.emulatorHost}`);
354+
}
355+
} else {
356+
if (
357+
url.host !== "securetoken.googleapis.com" &&
358+
url.host !== "identitytoolkit.googleapis.com"
359+
) {
360+
throw new Error(`Unauthorized proxy target host: ${url.host}`);
361+
}
345362
}
346363

347364
const isTokenRequest = !!url.pathname.match(/^(\/securetoken\.googleapis\.com)?\/v1\/token/);
348365
const isSignInRequest = !!url.pathname.match(
349-
/^(\/identitytoolkit\.googleapis\.com)?\/v1\/accounts:/,
366+
/^(\/identitytoolkit\.googleapis\.com)?\/(v1|v2)\/accounts:/,
350367
);
351368

352369
if (!isTokenRequest && !isSignInRequest)
@@ -374,21 +391,17 @@ export async function runMiddleware(
374391
const nextResponse = NextResponse.json(json, { status, statusText });
375392
return [nextResponse];
376393
}
377-
let refreshToken;
378-
let idToken;
379394
// The Firebase JS SDK freaks out if the idToken disappears on it, e.g, the cookie expired
380395
// it manually calls logout... which nukes everything before we have a chance to refresh!
381396
// So set the maxAge to the default (chrome max) of 400 days
382-
if (isSignInRequest) {
383-
const resp = json as SignInResponse;
384-
refreshToken = resp.refreshToken;
385-
idToken = resp.idToken;
386-
resp.refreshToken = "REDACTED";
387-
} else {
388-
const resp = json as TokenResponse;
389-
refreshToken = resp.refresh_token;
390-
idToken = resp.id_token;
391-
resp.refresh_token = "REDACTED";
397+
const idToken = json.idToken || json.id_token;
398+
const refreshToken = json.refreshToken || json.refresh_token;
399+
400+
if ("refreshToken" in json && json.refreshToken) {
401+
json.refreshToken = "REDACTED";
402+
}
403+
if ("refresh_token" in json && json.refresh_token) {
404+
json.refresh_token = "REDACTED";
392405
}
393406

394407
const currentIdToken = request.cookies.get({ ...ID_TOKEN_COOKIE, value: "" })?.value;

src/nextjs/middleware_fetch.node.test.ts

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe("runMiddleware", () => {
2626
fetchMock.mockResolvedValue(mockResponse);
2727

2828
const request = new NextRequest(
29-
"http://localhost:3000/__cookies__?finalTarget=https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword",
29+
"http://localhost:3000/__cookies__?finalTarget=https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword&appName=app",
3030
{
3131
method: "POST",
3232
body: JSON.stringify({ returnSecureToken: true }),
@@ -73,7 +73,7 @@ describe("runMiddleware", () => {
7373
fetchMock.mockResolvedValue(mockResponse);
7474

7575
const request = new NextRequest(
76-
"http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token",
76+
"http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token&appName=app",
7777
{
7878
method: "POST",
7979
body: "grant_type=refresh_token",
@@ -115,7 +115,7 @@ describe("runMiddleware", () => {
115115
});
116116

117117
const request = new NextRequest(
118-
"http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token",
118+
"http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token&appName=app",
119119
{
120120
method: "POST",
121121
body: "grant_type=refresh_token&refresh_token=REDACTED",
@@ -140,7 +140,7 @@ describe("runMiddleware", () => {
140140

141141
it("throws an error when request type to proxy cannot be determined", async () => {
142142
const request = new NextRequest(
143-
"http://localhost:3000/__cookies__?finalTarget=https://example.com/unknown",
143+
"http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/unknown&appName=app",
144144
{ method: "POST" },
145145
);
146146
const options = {
@@ -156,6 +156,38 @@ describe("runMiddleware", () => {
156156
);
157157
});
158158

159+
it("throws an error when proxy target host is unauthorized in production", async () => {
160+
const request = new NextRequest(
161+
"http://localhost:3000/__cookies__?finalTarget=https://evil-attacker.com/v1/token&appName=app",
162+
{ method: "POST" },
163+
);
164+
const options = {
165+
apiKey: "key",
166+
projectId: "proj",
167+
emulatorHost: undefined,
168+
tenantId: undefined,
169+
authDomain: "auth.domain",
170+
};
171+
172+
await expect(runMiddleware("app", options, request)).rejects.toThrow(
173+
"Unauthorized proxy target host: evil-attacker.com",
174+
);
175+
});
176+
177+
it("returns 400 when finalTarget parameter is missing", async () => {
178+
const request = new NextRequest("http://localhost:3000/__cookies__?appName=app", { method: "POST" });
179+
const options = {
180+
apiKey: "key",
181+
projectId: "proj",
182+
emulatorHost: undefined,
183+
tenantId: undefined,
184+
authDomain: "auth.domain",
185+
};
186+
187+
const [response] = await runMiddleware("app", options, request);
188+
expect(response?.status).toBe(400);
189+
});
190+
159191
it("returns proxy response directly without setting cookies if fetch response is not ok", async () => {
160192
fetchMock.mockResolvedValue({
161193
ok: false,
@@ -165,7 +197,7 @@ describe("runMiddleware", () => {
165197
});
166198

167199
const request = new NextRequest(
168-
"http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token",
200+
"http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token&appName=app",
169201
{ method: "POST", body: "grant_type=refresh_token" },
170202
);
171203
const options = {
@@ -179,4 +211,53 @@ describe("runMiddleware", () => {
179211
const [response] = await runMiddleware("app", options, request);
180212
expect(response?.status).toBe(401);
181213
});
214+
215+
it("skips /__cookies__ proxy handling if appName parameter does not match", async () => {
216+
const request = new NextRequest(
217+
"http://localhost:3000/__cookies__?finalTarget=https://securetoken.googleapis.com/v1/token&appName=adminApp",
218+
{ method: "POST", body: "grant_type=refresh_token" },
219+
);
220+
const options = {
221+
apiKey: "key",
222+
projectId: "proj",
223+
emulatorHost: undefined,
224+
tenantId: undefined,
225+
authDomain: "auth.domain",
226+
};
227+
228+
// Running runMiddleware for "[DEFAULT]" when target appName is "adminApp"
229+
const [response] = await runMiddleware("[DEFAULT]", options, request);
230+
expect(response).toBeUndefined(); // Should skip proxying
231+
expect(fetchMock).not.toHaveBeenCalled();
232+
});
233+
234+
it("handles v2/accounts endpoints (e.g. MFA finalize) correctly", async () => {
235+
fetchMock.mockResolvedValue({
236+
ok: true,
237+
status: 200,
238+
statusText: "OK",
239+
json: async () => ({
240+
idToken: "mfa-id-token",
241+
refreshToken: "mfa-refresh-token",
242+
}),
243+
});
244+
245+
const request = new NextRequest(
246+
"http://localhost:3000/__cookies__?finalTarget=https://identitytoolkit.googleapis.com/v2/accounts:mfaSignIn:finalize&appName=[DEFAULT]",
247+
{ method: "POST", body: JSON.stringify({}) },
248+
);
249+
const options = {
250+
apiKey: "key",
251+
projectId: "proj",
252+
emulatorHost: undefined,
253+
tenantId: undefined,
254+
authDomain: "auth.domain",
255+
};
256+
257+
const [response] = await runMiddleware("[DEFAULT]", options, request);
258+
expect(response).toBeDefined();
259+
const cookies = response!.cookies.getAll();
260+
const idCookie = cookies.find((c) => c.name.includes("FIREBASE_[DEFAULT]"));
261+
expect(idCookie?.value).toBe("mfa-id-token");
262+
});
182263
});

0 commit comments

Comments
 (0)