From 65221c5c8ed10c78a0e1fb748ed9c6871647e625 Mon Sep 17 00:00:00 2001 From: doug-s-nava <92806979+doug-s-nava@users.noreply.github.com> Date: Fri, 10 Jan 2025 14:05:06 -0500 Subject: [PATCH] [Issue #3244] auth login home and error redirects (#3470) * the callback route redirects to the home page on success, or an unauthorized page if no token is present or error page in error cases * creates error and unauthorized pages * adds middleware to implement the correct status codes on these redirects --- frontend/src/app/[locale]/error/page.tsx | 29 ++++++++++++ frontend/src/app/[locale]/not-found.tsx | 2 +- .../src/app/[locale]/unauthorized/page.tsx | 27 +++++++++++ frontend/src/app/api/auth/callback/route.ts | 46 ++++--------------- frontend/src/i18n/messages/en/index.ts | 11 ++++- frontend/src/middleware.ts | 22 ++++++++- .../tests/api/auth/callback/route.test.ts | 27 ++--------- frontend/tests/utils/getRoutes.test.ts | 2 + 8 files changed, 103 insertions(+), 63 deletions(-) create mode 100644 frontend/src/app/[locale]/error/page.tsx create mode 100644 frontend/src/app/[locale]/unauthorized/page.tsx diff --git a/frontend/src/app/[locale]/error/page.tsx b/frontend/src/app/[locale]/error/page.tsx new file mode 100644 index 000000000..925b16626 --- /dev/null +++ b/frontend/src/app/[locale]/error/page.tsx @@ -0,0 +1,29 @@ +import { Metadata } from "next"; + +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { GridContainer } from "@trussworks/react-uswds"; + +import ServerErrorAlert from "src/components/ServerErrorAlert"; + +export async function generateMetadata() { + const t = await getTranslations(); + const meta: Metadata = { + title: t("ErrorPages.generic_error.page_title"), + description: t("Index.meta_description"), + }; + return meta; +} + +// not a NextJS error page - this is here to be redirected to manually in cases +// where Next's error handling situation doesn't quite do what we need. +const TopLevelError = () => { + const t = useTranslations("Errors"); + return ( + + + + ); +}; + +export default TopLevelError; diff --git a/frontend/src/app/[locale]/not-found.tsx b/frontend/src/app/[locale]/not-found.tsx index ce0e2e360..d280024aa 100644 --- a/frontend/src/app/[locale]/not-found.tsx +++ b/frontend/src/app/[locale]/not-found.tsx @@ -10,7 +10,7 @@ import BetaAlert from "src/components/BetaAlert"; export async function generateMetadata() { const t = await getTranslations(); const meta: Metadata = { - title: t("ErrorPages.page_not_found.title"), + title: t("ErrorPages.page_not_found.page_title"), description: t("Index.meta_description"), }; return meta; diff --git a/frontend/src/app/[locale]/unauthorized/page.tsx b/frontend/src/app/[locale]/unauthorized/page.tsx new file mode 100644 index 000000000..e0813b2c4 --- /dev/null +++ b/frontend/src/app/[locale]/unauthorized/page.tsx @@ -0,0 +1,27 @@ +import { Metadata } from "next"; + +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { Alert, GridContainer } from "@trussworks/react-uswds"; + +export async function generateMetadata() { + const t = await getTranslations(); + const meta: Metadata = { + title: t("ErrorPages.unauthorized.page_title"), + description: t("Index.meta_description"), + }; + return meta; +} + +const Unauthorized = () => { + const t = useTranslations("Errors"); + return ( + + + {t("authorization_fail")} + + + ); +}; + +export default Unauthorized; diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index d874ed17d..f8b4c1f2b 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -1,47 +1,17 @@ -import { createSession, getSession } from "src/services/auth/session"; +import { createSession } from "src/services/auth/session"; import { redirect } from "next/navigation"; import { NextRequest } from "next/server"; -const createSessionAndSetStatus = async ( - token: string, - successStatus: string, -): Promise => { - try { - await createSession(token); - return successStatus; - } catch (error) { - console.error("error in creating session", error); - return "error!"; - } -}; - -/* - For now, we'll send them to generic success and error pages with cookie set on success - - message: str ("success" or "error") - token: str | None - is_user_new: bool | None - error_description: str | None - - TODOS: - - - translating messages? - - ... -*/ export async function GET(request: NextRequest) { - const currentSession = await getSession(); - if (currentSession && currentSession.token) { - const status = await createSessionAndSetStatus( - currentSession.token, - "already logged in", - ); - return redirect(`/user?message=${status}`); - } const token = request.nextUrl.searchParams.get("token"); if (!token) { - return redirect("/user?message=no token provided"); + return redirect("/unauthorized"); + } + try { + await createSession(token); + } catch (_e) { + return redirect("/error"); } - const status = await createSessionAndSetStatus(token, "created session"); - return redirect(`/user?message=${status}`); + return redirect("/"); } diff --git a/frontend/src/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts index d06c45fac..b98fac57c 100644 --- a/frontend/src/i18n/messages/en/index.ts +++ b/frontend/src/i18n/messages/en/index.ts @@ -463,8 +463,14 @@ export const messages = { "The Simpler.Grants.gov email subscriptions are powered by the Sendy data service. Personal information is not stored within Simpler.Grants.gov.", }, ErrorPages: { - page_title: "Page Not Found | Simpler.Grants.gov", + generic_error: { + page_title: "Error | Simpler.Grants.gov", + }, + unauthorized: { + page_title: "Unauthorized | Simpler.Grants.gov", + }, page_not_found: { + page_title: "Page Not Found | Simpler.Grants.gov", title: "Oops! Page Not Found", message_content_1: "The page you have requested cannot be displayed because it does not exist, has been moved, or the server has been instructed not to let you view it. There is nothing to see here.", @@ -530,6 +536,9 @@ export const messages = { Errors: { heading: "We're sorry.", generic_message: "There seems to have been an error.", + try_again: "Please try again.", + unauthorized: "Unauthorized", + authorization_fail: "Login or user authorization failed. Please try again.", }, Search: { title: "Search Funding Opportunities | Simpler.Grants.gov", diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index b14cb2b53..039e8cac6 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -40,5 +40,25 @@ const i18nMiddleware = createIntlMiddleware({ }); export default function middleware(request: NextRequest): NextResponse { - return featureFlagsManager.middleware(request, i18nMiddleware(request)); + const response = featureFlagsManager.middleware( + request, + i18nMiddleware(request), + ); + // in Next 15 there is an experimental `unauthorized` function that will send a 401 + // code to the client and display an unauthorized page + // see https://nextjs.org/docs/app/api-reference/functions/unauthorized + // For now we can set status codes on auth redirect errors here + if (request.url.includes("/error")) { + return new NextResponse(response.body, { + status: 500, + headers: response.headers, + }); + } + if (request.url.includes("/unauthorized")) { + return new NextResponse(response.body, { + status: 401, + headers: response.headers, + }); + } + return response; } diff --git a/frontend/tests/api/auth/callback/route.test.ts b/frontend/tests/api/auth/callback/route.test.ts index 3beeac414..68d1da171 100644 --- a/frontend/tests/api/auth/callback/route.test.ts +++ b/frontend/tests/api/auth/callback/route.test.ts @@ -8,47 +8,30 @@ import { wrapForExpectedError } from "src/utils/testing/commonTestUtils"; import { NextRequest } from "next/server"; const createSessionMock = jest.fn(); -const getSessionMock = jest.fn(); jest.mock("src/services/auth/session", () => ({ createSession: (token: string): unknown => createSessionMock(token), - getSession: (): unknown => getSessionMock(), })); // note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect // is to throw an error describe("/api/auth/callback GET handler", () => { afterEach(() => jest.clearAllMocks()); - it("calls createSession with token set in header", async () => { - getSessionMock.mockImplementation(() => ({ - token: "fakeToken", - })); + it("calls createSession on request with token in query params", async () => { const redirectError = await wrapForExpectedError<{ digest: string }>(() => - GET(new NextRequest("https://simpler.grants.gov")), + GET(new NextRequest("https://simpler.grants.gov/?token=fakeToken")), ); expect(createSessionMock).toHaveBeenCalledTimes(1); expect(createSessionMock).toHaveBeenCalledWith("fakeToken"); - expect(redirectError.digest).toContain("message=already logged in"); - }); - - it("if no token exists on current session, calls createSession with token set in query param", async () => { - getSessionMock.mockImplementation(() => ({})); - const redirectError = await wrapForExpectedError<{ digest: string }>(() => - GET(new NextRequest("https://simpler.grants.gov?token=queryFakeToken")), - ); - - expect(createSessionMock).toHaveBeenCalledTimes(1); - expect(createSessionMock).toHaveBeenCalledWith("queryFakeToken"); - expect(redirectError.digest).toContain("message=created session"); + expect(redirectError.digest).toContain(";/;"); }); - it("if no token exists on current session or query param, does not call createSession", async () => { - getSessionMock.mockImplementation(() => ({})); + it("if no token exists on query param, does not call createSession and redirects to unauthorized page", async () => { const redirectError = await wrapForExpectedError<{ digest: string }>(() => GET(new NextRequest("https://simpler.grants.gov")), ); expect(createSessionMock).toHaveBeenCalledTimes(0); - expect(redirectError.digest).toContain("message=no token provided"); + expect(redirectError.digest).toContain(";/unauthorized;"); }); }); diff --git a/frontend/tests/utils/getRoutes.test.ts b/frontend/tests/utils/getRoutes.test.ts index 34b128c33..71fd2b6c1 100644 --- a/frontend/tests/utils/getRoutes.test.ts +++ b/frontend/tests/utils/getRoutes.test.ts @@ -27,6 +27,7 @@ describe("getNextRoutes", () => { expect(result).toEqual([ "/dev/feature-flags", + "/error", "/health", "/maintenance", "/opportunity/1", @@ -37,6 +38,7 @@ describe("getNextRoutes", () => { "/subscribe/confirmation", "/subscribe", "/subscribe/unsubscribe", + "/unauthorized", "/user", ]); });