Skip to content

Commit

Permalink
[Issue #3244] auth login home and error redirects (#3470)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
doug-s-nava authored Jan 10, 2025
1 parent 96f6b65 commit 65221c5
Show file tree
Hide file tree
Showing 8 changed files with 103 additions and 63 deletions.
29 changes: 29 additions & 0 deletions frontend/src/app/[locale]/error/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<GridContainer>
<ServerErrorAlert callToAction={t("try_again")} />
</GridContainer>
);
};

export default TopLevelError;
2 changes: 1 addition & 1 deletion frontend/src/app/[locale]/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/app/[locale]/unauthorized/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<GridContainer>
<Alert type="error" heading={t("unauthorized")} headingLevel="h4">
{t("authorization_fail")}
</Alert>
</GridContainer>
);
};

export default Unauthorized;
46 changes: 8 additions & 38 deletions frontend/src/app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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("/");
}
11 changes: 10 additions & 1 deletion frontend/src/i18n/messages/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 21 additions & 1 deletion frontend/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
27 changes: 5 additions & 22 deletions frontend/tests/api/auth/callback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;");
});
});
2 changes: 2 additions & 0 deletions frontend/tests/utils/getRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ describe("getNextRoutes", () => {

expect(result).toEqual([
"/dev/feature-flags",
"/error",
"/health",
"/maintenance",
"/opportunity/1",
Expand All @@ -37,6 +38,7 @@ describe("getNextRoutes", () => {
"/subscribe/confirmation",
"/subscribe",
"/subscribe/unsubscribe",
"/unauthorized",
"/user",
]);
});
Expand Down

0 comments on commit 65221c5

Please sign in to comment.