Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/nextauth/hooks/useNextAuthService.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand All @@ -28,10 +34,10 @@ export const useNextAuthService = (): Service => {
);

const onLogout = useCallback(
(options?: { callbackUrl?: string; redirect?: boolean }) => {
service.logout(options);
(options?: SignOutParams<boolean>) => {
service.logout(resolveLogoutOptions(options, logoutCallbackUrl));
},
[],
[logoutCallbackUrl],
);

return { requestLogin: onLogin, requestLogout: onLogout };
Expand Down
25 changes: 25 additions & 0 deletions src/nextauth/hooks/utils.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> | undefined,
logoutCallbackUrl: string | undefined,
): SignOutParams<boolean> {
// 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 };
}
4 changes: 3 additions & 1 deletion src/nextauth/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,22 @@ 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.
* @returns authentication provider component.
*/
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);
Expand Down
7 changes: 7 additions & 0 deletions src/nextauth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions tests/resolveLogoutOptions.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
55 changes: 55 additions & 0 deletions tests/useNextAuthService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ const MOCK_USE_ROUTE_HISTORY = useRouteHistory as jest.MockedFunction<
typeof useRouteHistory
>;
const MOCK_LOGIN = service.login as jest.MockedFunction<typeof service.login>;
const MOCK_LOGOUT = service.logout as jest.MockedFunction<
typeof service.logout
>;

describe("useNextAuthService", () => {
beforeEach(() => {
Expand Down Expand Up @@ -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,
});
});
});
Loading