Skip to content

Commit 525eca5

Browse files
frano-mclaude
andcommitted
feat: loginview should honor nextauth callbackurl from the query string (middleware-friendly) (#955)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 88d1009 commit 525eca5

5 files changed

Lines changed: 222 additions & 5 deletions

File tree

src/hooks/authentication/session/useSessionActive.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Router from "next/router";
1+
import Router, { useRouter } from "next/router";
22
import { useEffect } from "react";
33
import { AUTH_STATUS, AuthState } from "../../../auth/types/auth";
44
import {
@@ -16,17 +16,43 @@ export const useSessionActive = (
1616
): void => {
1717
const { status: authStatus } = authState;
1818
const { status: authenticationStatus } = authenticationState;
19+
const { query } = useRouter();
1920
const { callbackUrl } = useRouteHistory(2);
2021
const isReady =
2122
authenticationStatus === AUTHENTICATION_STATUS.SETTLED &&
2223
authStatus === AUTH_STATUS.SETTLED;
24+
const queryCallbackUrl = getQueryCallbackUrl(query.callbackUrl);
2325

2426
useEffect(() => {
2527
if (!isReady) return;
26-
Router.push(callbackUrl(transformRoute));
28+
Router.push(queryCallbackUrl ?? callbackUrl(transformRoute));
29+
// eslint-disable-next-line react-hooks/exhaustive-deps -- queryCallbackUrl is intentionally omitted: this effect is a one-shot post-settle redirect, capturing the URL at first settle rather than re-firing each time the query changes.
2730
}, [callbackUrl, isReady]);
2831
};
2932

33+
/**
34+
* Extracts a same-origin `callbackUrl` path from a Next.js router query value.
35+
* Next middleware (`withAuth`) appends `?callbackUrl=<original-path>` when it
36+
* redirects an unauthenticated request to the sign-in page; honoring this
37+
* keeps the consumer aligned with the standard NextAuth + middleware contract.
38+
*
39+
* Only accepts same-origin paths (must start with `/` and not `//`). Rejects
40+
* absolute URLs (`https://...`) and protocol-relative URLs (`//evil.com/x`,
41+
* which browsers treat as absolute) so the value can be safely passed to
42+
* `Router.push`, which does not perform its own same-origin filtering.
43+
*
44+
* @param value - Raw `callbackUrl` query value (string, string[], or undefined).
45+
* @returns The same-origin path, or `undefined` when missing or unsafe.
46+
*/
47+
export function getQueryCallbackUrl(
48+
value: string | string[] | undefined,
49+
): string | undefined {
50+
const raw = Array.isArray(value) ? value[0] : value;
51+
if (typeof raw !== "string" || raw.length === 0) return undefined;
52+
if (!raw.startsWith("/") || raw.startsWith("//")) return undefined;
53+
return raw;
54+
}
55+
3056
/**
3157
* Finds the most recent route in the history that is not the login route and removes the inactivity timeout query parameter.
3258
* The inactivity timeout query parameter is appended to a URL to indicate that a session has expired. This function iterates

src/nextauth/hooks/useNextAuthService.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import { useRouter } from "next/router";
12
import { useCallback } from "react";
23
import { Service } from "../../auth/types/auth";
34
import { ProviderId } from "../../auth/types/common";
4-
import { transformRoute } from "../../hooks/authentication/session/useSessionActive";
5+
import {
6+
getQueryCallbackUrl,
7+
transformRoute,
8+
} from "../../hooks/authentication/session/useSessionActive";
59
import { useRouteHistory } from "../../hooks/useRouteHistory";
610
import { service } from "../service";
711

@@ -10,13 +14,17 @@ import { service } from "../service";
1014
* @returns auth service.
1115
*/
1216
export const useNextAuthService = (): Service => {
17+
const { query } = useRouter();
1318
const { callbackUrl } = useRouteHistory(2);
19+
const queryCallbackUrl = getQueryCallbackUrl(query.callbackUrl);
1420

1521
const onLogin = useCallback(
1622
(providerId: ProviderId) => {
17-
service.login(providerId, { callbackUrl: callbackUrl(transformRoute) });
23+
service.login(providerId, {
24+
callbackUrl: queryCallbackUrl ?? callbackUrl(transformRoute),
25+
});
1826
},
19-
[callbackUrl],
27+
[callbackUrl, queryCallbackUrl],
2028
);
2129

2230
const onLogout = useCallback(

tests/getQueryCallbackUrl.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { getQueryCallbackUrl } from "../src/hooks/authentication/session/useSessionActive";
2+
3+
describe("getQueryCallbackUrl", () => {
4+
it("returns the string when query is a non-empty same-origin path", () => {
5+
expect(getQueryCallbackUrl("/atlases")).toBe("/atlases");
6+
});
7+
8+
it("preserves query string and fragment in the returned path", () => {
9+
expect(getQueryCallbackUrl("/atlases?tab=metadata#row-1")).toBe(
10+
"/atlases?tab=metadata#row-1",
11+
);
12+
});
13+
14+
it("returns the first element when query is a string array", () => {
15+
expect(getQueryCallbackUrl(["/atlases", "/other"])).toBe("/atlases");
16+
});
17+
18+
it("returns undefined when query is undefined", () => {
19+
expect(getQueryCallbackUrl(undefined)).toBeUndefined();
20+
});
21+
22+
it("returns undefined when query is an empty string", () => {
23+
expect(getQueryCallbackUrl("")).toBeUndefined();
24+
});
25+
26+
it("returns undefined when query is an empty array", () => {
27+
expect(getQueryCallbackUrl([])).toBeUndefined();
28+
});
29+
30+
it("returns undefined when the first array element is empty", () => {
31+
expect(getQueryCallbackUrl(["", "/other"])).toBeUndefined();
32+
});
33+
34+
it("rejects absolute http URLs (open-redirect guard)", () => {
35+
expect(getQueryCallbackUrl("https://evil.example/phish")).toBeUndefined();
36+
expect(getQueryCallbackUrl("http://evil.example/phish")).toBeUndefined();
37+
});
38+
39+
it("rejects protocol-relative URLs (browsers treat as absolute)", () => {
40+
expect(getQueryCallbackUrl("//evil.example/phish")).toBeUndefined();
41+
});
42+
43+
it("rejects values that do not start with '/'", () => {
44+
expect(getQueryCallbackUrl("atlases")).toBeUndefined();
45+
expect(getQueryCallbackUrl("javascript:alert(1)")).toBeUndefined();
46+
});
47+
});

tests/useNextAuthService.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { jest } from "@jest/globals";
2+
import { act, renderHook } from "@testing-library/react";
3+
import { TransformRouteFn } from "../src/hooks/useRouteHistory";
4+
5+
const ROOT_PATH = "/";
6+
const ROUTES = ["/login", "/route1", "/route2"];
7+
const PROVIDER_ID = "google";
8+
9+
let mockRouterQuery: Record<string, string | string[] | undefined> = {};
10+
11+
jest.unstable_mockModule("next/router", () => ({
12+
...jest.requireActual<typeof import("next/router")>("next/router"),
13+
useRouter: jest.fn(() => ({ query: mockRouterQuery })),
14+
}));
15+
jest.unstable_mockModule("../src/hooks/useRouteHistory", () => ({
16+
useRouteHistory: jest.fn(),
17+
}));
18+
jest.unstable_mockModule("../src/nextauth/service", () => ({
19+
service: { login: jest.fn(), logout: jest.fn() },
20+
}));
21+
22+
const { useRouteHistory } = await import("../src/hooks/useRouteHistory");
23+
const { service } = await import("../src/nextauth/service");
24+
const { useNextAuthService } =
25+
await import("../src/nextauth/hooks/useNextAuthService");
26+
27+
const MOCK_USE_ROUTE_HISTORY = useRouteHistory as jest.MockedFunction<
28+
typeof useRouteHistory
29+
>;
30+
const MOCK_LOGIN = service.login as jest.MockedFunction<typeof service.login>;
31+
32+
describe("useNextAuthService", () => {
33+
beforeEach(() => {
34+
mockRouterQuery = {};
35+
MOCK_LOGIN.mockReset();
36+
MOCK_USE_ROUTE_HISTORY.mockReset();
37+
MOCK_USE_ROUTE_HISTORY.mockReturnValue({
38+
callbackUrl: jest.fn(
39+
(transformFn?: TransformRouteFn | undefined) =>
40+
transformFn?.(ROUTES) ?? ROOT_PATH,
41+
),
42+
});
43+
});
44+
45+
test("requestLogin uses route-history fallback when query.callbackUrl is absent", () => {
46+
const { result } = renderHook(() => useNextAuthService());
47+
act(() => result.current.requestLogin(PROVIDER_ID));
48+
expect(MOCK_LOGIN).toHaveBeenCalledWith(PROVIDER_ID, {
49+
callbackUrl: ROUTES[1],
50+
});
51+
});
52+
53+
test("requestLogin prefers a same-origin query.callbackUrl over route history", () => {
54+
mockRouterQuery = { callbackUrl: "/from-query" };
55+
const { result } = renderHook(() => useNextAuthService());
56+
act(() => result.current.requestLogin(PROVIDER_ID));
57+
expect(MOCK_LOGIN).toHaveBeenCalledWith(PROVIDER_ID, {
58+
callbackUrl: "/from-query",
59+
});
60+
});
61+
62+
test("requestLogin falls back to route history when query.callbackUrl is empty", () => {
63+
mockRouterQuery = { callbackUrl: "" };
64+
const { result } = renderHook(() => useNextAuthService());
65+
act(() => result.current.requestLogin(PROVIDER_ID));
66+
expect(MOCK_LOGIN).toHaveBeenCalledWith(PROVIDER_ID, {
67+
callbackUrl: ROUTES[1],
68+
});
69+
});
70+
71+
test("requestLogin rejects an absolute URL in query.callbackUrl (open-redirect guard)", () => {
72+
mockRouterQuery = { callbackUrl: "https://evil.example/phish" };
73+
const { result } = renderHook(() => useNextAuthService());
74+
act(() => result.current.requestLogin(PROVIDER_ID));
75+
expect(MOCK_LOGIN).toHaveBeenCalledWith(PROVIDER_ID, {
76+
callbackUrl: ROUTES[1],
77+
});
78+
});
79+
80+
test("requestLogin rejects a protocol-relative URL in query.callbackUrl", () => {
81+
mockRouterQuery = { callbackUrl: "//evil.example/phish" };
82+
const { result } = renderHook(() => useNextAuthService());
83+
act(() => result.current.requestLogin(PROVIDER_ID));
84+
expect(MOCK_LOGIN).toHaveBeenCalledWith(PROVIDER_ID, {
85+
callbackUrl: ROUTES[1],
86+
});
87+
});
88+
});

tests/useSessionActive.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ const AUTHENTICATION_STATE_SETTLED: AuthenticationState = {
3535
const ROOT_PATH = "/";
3636
const ROUTES = ["/login", "/route1", "/route2"];
3737

38+
let mockRouterQuery: Record<string, string | string[] | undefined> = {};
39+
3840
jest.unstable_mockModule("next/router", () => {
3941
return {
4042
...jest.requireActual<typeof import("next/router")>("next/router"),
4143
default: {
4244
push: jest.fn(),
4345
},
46+
useRouter: jest.fn(() => ({ query: mockRouterQuery })),
4447
};
4548
});
4649
jest.unstable_mockModule("../src/hooks/useRouteHistory", () => ({
@@ -58,6 +61,7 @@ const MOCK_USE_ROUTE_HISTORY = useRouteHistory as jest.MockedFunction<
5861

5962
describe("useSessionActive", () => {
6063
beforeEach(() => {
64+
mockRouterQuery = {};
6165
MOCK_USE_ROUTE_HISTORY.mockReset();
6266
MOCK_USE_ROUTE_HISTORY.mockReturnValue({
6367
callbackUrl: jest.fn(
@@ -100,4 +104,48 @@ describe("useSessionActive", () => {
100104
);
101105
expect(Router.push).toHaveBeenCalledWith(ROUTES[1]);
102106
});
107+
108+
test("prefers router.query.callbackUrl over route-history fallback", () => {
109+
mockRouterQuery = { callbackUrl: "/from-query" };
110+
renderHook(() =>
111+
useSessionActive(
112+
AUTH_STATE_AUTHENTICATED_SETTLED,
113+
AUTHENTICATION_STATE_SETTLED,
114+
),
115+
);
116+
expect(Router.push).toHaveBeenCalledWith("/from-query");
117+
});
118+
119+
test("falls back to route history when router.query.callbackUrl is an empty string", () => {
120+
mockRouterQuery = { callbackUrl: "" };
121+
renderHook(() =>
122+
useSessionActive(
123+
AUTH_STATE_AUTHENTICATED_SETTLED,
124+
AUTHENTICATION_STATE_SETTLED,
125+
),
126+
);
127+
expect(Router.push).toHaveBeenCalledWith(ROUTES[1]);
128+
});
129+
130+
test("falls back to route history when router.query.callbackUrl is an absolute URL (open-redirect guard)", () => {
131+
mockRouterQuery = { callbackUrl: "https://evil.example/phish" };
132+
renderHook(() =>
133+
useSessionActive(
134+
AUTH_STATE_AUTHENTICATED_SETTLED,
135+
AUTHENTICATION_STATE_SETTLED,
136+
),
137+
);
138+
expect(Router.push).toHaveBeenCalledWith(ROUTES[1]);
139+
});
140+
141+
test("falls back to route history when router.query.callbackUrl is protocol-relative", () => {
142+
mockRouterQuery = { callbackUrl: "//evil.example/phish" };
143+
renderHook(() =>
144+
useSessionActive(
145+
AUTH_STATE_AUTHENTICATED_SETTLED,
146+
AUTHENTICATION_STATE_SETTLED,
147+
),
148+
);
149+
expect(Router.push).toHaveBeenCalledWith(ROUTES[1]);
150+
});
103151
});

0 commit comments

Comments
 (0)