diff --git a/src/nextauth/hooks/useNextAuthService.ts b/src/nextauth/hooks/useNextAuthService.ts index 432ea4b4..145bfa34 100644 --- a/src/nextauth/hooks/useNextAuthService.ts +++ b/src/nextauth/hooks/useNextAuthService.ts @@ -1,3 +1,4 @@ +import { SignOutParams } from "next-auth/react"; import { useRouter } from "next/router"; import { useCallback } from "react"; import { Service } from "../../auth/types/auth"; @@ -8,12 +9,17 @@ import { } from "../../hooks/authentication/session/useSessionActive"; import { useRouteHistory } from "../../hooks/useRouteHistory"; import { service } from "../service"; +import { resolveLogoutOptions } from "./utils"; /** * NextAuth service hook. + * @param logoutCallbackUrl - When set, `requestLogout()` defaults to a + * navigation-driven sign-out (`signOut({ redirect: true, callbackUrl })`) + * so Next middleware re-runs. Callers can still override per-call via + * `options`. * @returns auth service. */ -export const useNextAuthService = (): Service => { +export const useNextAuthService = (logoutCallbackUrl?: string): Service => { const { query } = useRouter(); const { callbackUrl } = useRouteHistory(2); const queryCallbackUrl = getQueryCallbackUrl(query.callbackUrl); @@ -28,10 +34,10 @@ export const useNextAuthService = (): Service => { ); const onLogout = useCallback( - (options?: { callbackUrl?: string; redirect?: boolean }) => { - service.logout(options); + (options?: SignOutParams) => { + service.logout(resolveLogoutOptions(options, logoutCallbackUrl)); }, - [], + [logoutCallbackUrl], ); return { requestLogin: onLogin, requestLogout: onLogout }; diff --git a/src/nextauth/hooks/utils.ts b/src/nextauth/hooks/utils.ts new file mode 100644 index 00000000..3b03a9b5 --- /dev/null +++ b/src/nextauth/hooks/utils.ts @@ -0,0 +1,25 @@ +import { SignOutParams } from "next-auth/react"; + +/** + * Resolve the options to pass to `service.logout` (NextAuth `signOut`). + * + * Precedence: caller-supplied options always win; the provider-level + * `logoutCallbackUrl` only fills in defaults. `redirect` defaults to `true` + * whenever a callbackUrl is resolved (caller or provider), so logout actually + * navigates and Next middleware can re-run on the next render. + * + * @param options - Per-call options forwarded from `requestLogout(options)`. + * @param logoutCallbackUrl - Provider-level default callbackUrl. + * @returns Resolved `{ callbackUrl, redirect }` for `signOut`. + */ +export function resolveLogoutOptions( + options: SignOutParams | undefined, + logoutCallbackUrl: string | undefined, +): SignOutParams { + // Caller wins; provider-level value fills in. Normalize an empty string to + // `undefined` so we don't default `redirect` to `true` with no target. + const resolved = options?.callbackUrl ?? logoutCallbackUrl; + const callbackUrl = resolved || undefined; + const redirect = options?.redirect ?? Boolean(callbackUrl); + return { callbackUrl, redirect }; +} diff --git a/src/nextauth/provider.tsx b/src/nextauth/provider.tsx index b031ca62..2079d667 100644 --- a/src/nextauth/provider.tsx +++ b/src/nextauth/provider.tsx @@ -15,6 +15,7 @@ import { NextAuthAuthenticationProviderProps } from "./types"; * NextAuth authentication provider. * @param props - Provider props. * @param props.children - Children components. + * @param props.logoutCallbackUrl - When set, the Logout menu action navigates here (so middleware re-runs). * @param props.refetchInterval - Session refetch interval in milliseconds. * @param props.session - Initial session data. * @param props.timeout - Session timeout in milliseconds. @@ -22,13 +23,14 @@ import { NextAuthAuthenticationProviderProps } from "./types"; */ export function NextAuthAuthenticationProvider({ children, + logoutCallbackUrl, refetchInterval = 0, session, timeout, }: NextAuthAuthenticationProviderProps): JSX.Element { const authReducer = useAuthReducer(); const authenticationReducer = useAuthenticationReducer(); - const service = useNextAuthService(); + const service = useNextAuthService(logoutCallbackUrl); const { authDispatch, authState } = authReducer; const { isAuthenticated } = authState; useLoginTracking(isAuthenticated, authState.status); diff --git a/src/nextauth/types.ts b/src/nextauth/types.ts index 2969043d..61f2b1e1 100644 --- a/src/nextauth/types.ts +++ b/src/nextauth/types.ts @@ -6,6 +6,13 @@ import { ReactNode } from "react"; */ export interface NextAuthAuthenticationProviderProps { children: ReactNode | ReactNode[]; + /** + * When set, the Logout menu action becomes navigation-driven + * (`signOut({ redirect: true, callbackUrl })`) so Next middleware can + * re-run and enforce auth on the next render. Callers of + * `requestLogout(options)` can still override per-call. + */ + logoutCallbackUrl?: string; refetchInterval?: number; session?: Session | null; timeout?: number; diff --git a/tests/resolveLogoutOptions.test.ts b/tests/resolveLogoutOptions.test.ts new file mode 100644 index 00000000..3937a279 --- /dev/null +++ b/tests/resolveLogoutOptions.test.ts @@ -0,0 +1,66 @@ +import { resolveLogoutOptions } from "../src/nextauth/hooks/utils"; + +describe("resolveLogoutOptions", () => { + test("defaults to no-callbackUrl + redirect:false when neither side supplies one", () => { + expect(resolveLogoutOptions(undefined, undefined)).toEqual({ + callbackUrl: undefined, + redirect: false, + }); + }); + + test("uses provider logoutCallbackUrl with redirect:true when caller omits options", () => { + expect(resolveLogoutOptions(undefined, "/")).toEqual({ + callbackUrl: "/", + redirect: true, + }); + }); + + test("caller-supplied callbackUrl overrides provider logoutCallbackUrl", () => { + expect( + resolveLogoutOptions({ callbackUrl: "/account-disabled" }, "/"), + ).toEqual({ + callbackUrl: "/account-disabled", + redirect: true, + }); + }); + + test("caller-supplied redirect:false wins even when a callbackUrl is resolved", () => { + expect(resolveLogoutOptions({ redirect: false }, "/")).toEqual({ + callbackUrl: "/", + redirect: false, + }); + }); + + test("caller-supplied full options pass through unchanged", () => { + expect( + resolveLogoutOptions( + { callbackUrl: "/account-disabled", redirect: true }, + undefined, + ), + ).toEqual({ + callbackUrl: "/account-disabled", + redirect: true, + }); + }); + + test("caller-supplied callbackUrl alone (no redirect) implies redirect:true", () => { + expect(resolveLogoutOptions({ callbackUrl: "/x" }, undefined)).toEqual({ + callbackUrl: "/x", + redirect: true, + }); + }); + + test("normalizes a caller-supplied empty-string callbackUrl to undefined + redirect:false", () => { + expect(resolveLogoutOptions({ callbackUrl: "" }, undefined)).toEqual({ + callbackUrl: undefined, + redirect: false, + }); + }); + + test("normalizes a provider-supplied empty-string logoutCallbackUrl to undefined + redirect:false", () => { + expect(resolveLogoutOptions(undefined, "")).toEqual({ + callbackUrl: undefined, + redirect: false, + }); + }); +}); diff --git a/tests/useNextAuthService.test.ts b/tests/useNextAuthService.test.ts index 4e22fe4d..9b2cf39d 100644 --- a/tests/useNextAuthService.test.ts +++ b/tests/useNextAuthService.test.ts @@ -28,6 +28,9 @@ const MOCK_USE_ROUTE_HISTORY = useRouteHistory as jest.MockedFunction< typeof useRouteHistory >; const MOCK_LOGIN = service.login as jest.MockedFunction; +const MOCK_LOGOUT = service.logout as jest.MockedFunction< + typeof service.logout +>; describe("useNextAuthService", () => { beforeEach(() => { @@ -86,4 +89,56 @@ describe("useNextAuthService", () => { callbackUrl: ROUTES[1], }); }); + + test("requestLogout defaults to non-redirecting logout when no logoutCallbackUrl is provided", () => { + const { result } = renderHook(() => useNextAuthService()); + act(() => result.current.requestLogout()); + expect(MOCK_LOGOUT).toHaveBeenCalledWith({ + callbackUrl: undefined, + redirect: false, + }); + }); + + test("requestLogout uses provider-supplied logoutCallbackUrl with redirect:true", () => { + const { result } = renderHook(() => useNextAuthService(ROOT_PATH)); + act(() => result.current.requestLogout()); + expect(MOCK_LOGOUT).toHaveBeenCalledWith({ + callbackUrl: ROOT_PATH, + redirect: true, + }); + }); + + test("requestLogout caller-provided callbackUrl overrides logoutCallbackUrl", () => { + const { result } = renderHook(() => useNextAuthService(ROOT_PATH)); + act(() => + result.current.requestLogout({ callbackUrl: "/account-disabled" }), + ); + expect(MOCK_LOGOUT).toHaveBeenCalledWith({ + callbackUrl: "/account-disabled", + redirect: true, + }); + }); + + test("requestLogout caller-provided redirect:false is respected even when logoutCallbackUrl is set", () => { + const { result } = renderHook(() => useNextAuthService(ROOT_PATH)); + act(() => result.current.requestLogout({ redirect: false })); + expect(MOCK_LOGOUT).toHaveBeenCalledWith({ + callbackUrl: ROOT_PATH, + redirect: false, + }); + }); + + test("requestLogout caller-provided full options pass through unchanged", () => { + const { result } = renderHook(() => useNextAuthService()); + act(() => + result.current.requestLogout({ + callbackUrl: "/account-disabled", + redirect: true, + }), + ); + expect(MOCK_LOGOUT).toHaveBeenCalledWith({ + callbackUrl: "/account-disabled", + redirect: true, + }); + }); });