diff --git a/src/client/hooks/use-user.integration.test.tsx b/src/client/hooks/use-user.integration.test.tsx index 9d35594b..c6087f55 100644 --- a/src/client/hooks/use-user.integration.test.tsx +++ b/src/client/hooks/use-user.integration.test.tsx @@ -146,4 +146,28 @@ describe("useUser Integration with SWR Cache", () => { // Verify fetch was called twice expect(fetchSpy).toHaveBeenCalledTimes(2); }); + + it("should handle unauthenticated requests to the profile endpoint", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(null, { + status: 204 + }) + ); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map() }}> + {children} + + ); + + const { result } = renderHook(() => useUser(), { wrapper }); + + // Wait for the initial data to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.user).toEqual(null); + expect(result.current.error).toBe(undefined); + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledWith("/auth/profile"); + }); }); diff --git a/src/client/hooks/use-user.ts b/src/client/hooks/use-user.ts index bfb84454..e43273d2 100644 --- a/src/client/hooks/use-user.ts +++ b/src/client/hooks/use-user.ts @@ -15,6 +15,11 @@ export function useUser() { if (!res.ok) { throw new Error("Unauthorized"); } + + if (res.status === 204) { + return null; + } + return res.json(); }) ); diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index 7d582f94..6d63b905 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -2450,6 +2450,42 @@ ca/T0LLtgmbMmxSv/MmzIg== expect(response.status).toEqual(401); expect(response.body).toBeNull(); }); + + it("should return a 204 if the user is not authenticated and noContentProfileResponseWhenUnauthenticated is enabled", async () => { + const secret = await generateSecret(32); + const transactionStore = new TransactionStore({ + secret + }); + const sessionStore = new StatelessSessionStore({ + secret + }); + const authClient = new AuthClient({ + transactionStore, + sessionStore, + + domain: DEFAULT.domain, + clientId: DEFAULT.clientId, + clientSecret: DEFAULT.clientSecret, + + secret, + appBaseUrl: DEFAULT.appBaseUrl, + + fetch: getMockAuthorizationServer(), + + noContentProfileResponseWhenUnauthenticated: true + }); + + const request = new NextRequest( + new URL("/auth/profile", DEFAULT.appBaseUrl), + { + method: "GET" + } + ); + + const response = await authClient.handleProfile(request); + expect(response.status).toEqual(204); + expect(response.body).toBeNull(); + }); }); describe("handleCallback", async () => { diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 5a396425..7d4da359 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -136,6 +136,7 @@ export interface AuthClientOptions { httpTimeout?: number; enableTelemetry?: boolean; enableAccessTokenEndpoint?: boolean; + noContentProfileResponseWhenUnauthenticated?: boolean; } function createRouteUrl(url: string, base: string) { @@ -173,6 +174,7 @@ export class AuthClient { private authorizationServerMetadata?: oauth.AuthorizationServer; private readonly enableAccessTokenEndpoint: boolean; + private readonly noContentProfileResponseWhenUnauthenticated: boolean; constructor(options: AuthClientOptions) { // dependencies @@ -262,6 +264,8 @@ export class AuthClient { }; this.enableAccessTokenEndpoint = options.enableAccessTokenEndpoint ?? true; + this.noContentProfileResponseWhenUnauthenticated = + options.noContentProfileResponseWhenUnauthenticated ?? false; } async handler(req: NextRequest): Promise { @@ -584,6 +588,12 @@ export class AuthClient { const session = await this.sessionStore.get(req.cookies); if (!session) { + if (this.noContentProfileResponseWhenUnauthenticated) { + return new NextResponse(null, { + status: 204 + }); + } + return new NextResponse(null, { status: 401 }); diff --git a/src/server/client.ts b/src/server/client.ts index 2a692a4b..86654d80 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -168,6 +168,14 @@ export interface Auth0ClientOptions { * See: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-token-mediating-backend */ enableAccessTokenEndpoint?: boolean; + + /** + * If true, the profile endpoint will return a 204 No Content response when the user is not authenticated + * instead of returning a 401 Unauthorized response. + * + * Defaults to `false`. + */ + noContentProfileResponseWhenUnauthenticated?: boolean; } export type PagesRouterRequest = IncomingMessage | NextApiRequest; @@ -270,7 +278,9 @@ export class Auth0Client { allowInsecureRequests: options.allowInsecureRequests, httpTimeout: options.httpTimeout, enableTelemetry: options.enableTelemetry, - enableAccessTokenEndpoint: options.enableAccessTokenEndpoint + enableAccessTokenEndpoint: options.enableAccessTokenEndpoint, + noContentProfileResponseWhenUnauthenticated: + options.noContentProfileResponseWhenUnauthenticated }); }