diff --git a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx index c800fe9f1..eef88cbdc 100644 --- a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx +++ b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx @@ -1,39 +1,18 @@ "use client"; import { useFeatureFlags } from "src/hooks/useFeatureFlags"; -import { useUser } from "src/services/auth/useUser"; import React from "react"; import { Button, Table } from "@trussworks/react-uswds"; -import Loading from "src/components/Loading"; - /** * View for managing feature flags */ export default function FeatureFlagsTable() { const { setFeatureFlag, featureFlags } = useFeatureFlags(); - const { user, isLoading, error } = useUser(); - - if (isLoading) { - return ; - } - - if (error) { - // there's no error page within this tree, should we make a top level error? - return ( - <> -

Error

- {error.message} - - ); - } return ( <> -

- {user?.token ? `Logged in with token: ${user.token}` : "Not logged in"} -

diff --git a/frontend/src/app/[locale]/dev/layout.tsx b/frontend/src/app/[locale]/dev/layout.tsx deleted file mode 100644 index 05c95d2fc..000000000 --- a/frontend/src/app/[locale]/dev/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import UserProvider from "src/services/auth/UserProvider"; - -import React from "react"; - -export default function Layout({ children }: { children: React.ReactNode }) { - return {children}; -} diff --git a/frontend/src/app/[locale]/process/ProcessNext.tsx b/frontend/src/app/[locale]/process/ProcessNext.tsx index d24a09ea5..68f6b6a17 100644 --- a/frontend/src/app/[locale]/process/ProcessNext.tsx +++ b/frontend/src/app/[locale]/process/ProcessNext.tsx @@ -80,7 +80,7 @@ const ProcessNext = () => { > diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index 34863b885..d874ed17d 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -17,8 +17,7 @@ const createSessionAndSetStatus = async ( }; /* - currently it looks like the API will send us a request with the params below, and we will be responsible - for directing the user accordingly. For now, we'll send them to generic success and error pages with cookie set on success + 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 diff --git a/frontend/src/app/api/auth/logout/route.ts b/frontend/src/app/api/auth/logout/route.ts index 9ba2136df..ec6247aa3 100644 --- a/frontend/src/app/api/auth/logout/route.ts +++ b/frontend/src/app/api/auth/logout/route.ts @@ -1,13 +1,14 @@ -import { deleteSession, getSession } from "src/services/auth/session"; +import { getSession } from "src/services/auth/session"; +import { deleteSession } from "src/services/auth/sessionUtils"; import { postLogout } from "src/services/fetch/fetchers/userFetcher"; export async function POST() { try { - // logout on API via /v1/users/token/logout const session = await getSession(); if (!session || !session.token) { throw new Error("No active session to logout"); } + // logout on API via /v1/users/token/logout const response = await postLogout(session.token); if (!response) { throw new Error("No logout response from API"); diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts index 668eae8bb..85235315c 100644 --- a/frontend/src/app/api/auth/session/route.ts +++ b/frontend/src/app/api/auth/session/route.ts @@ -2,12 +2,12 @@ import { getSession } from "src/services/auth/session"; import { NextResponse } from "next/server"; +export const revalidate = 0; + export async function GET() { const currentSession = await getSession(); if (currentSession) { - return NextResponse.json({ - token: currentSession.token, - }); + return NextResponse.json(currentSession); } else { return NextResponse.json({ token: "" }); } diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 3d78d2cd7..8087aff20 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -24,7 +24,7 @@ type SocialLinkProps = { const SocialLink = ({ href, name, icon }: SocialLinkProps) => ( { - const [authLoginUrl, setAuthLoginUrl] = useState(null); - - useEffect(() => { - async function fetchEnv() { - const res = await fetch("/api/env"); - const data = (await res.json()) as { auth_login_url: string }; - data.auth_login_url - ? setAuthLoginUrl(data.auth_login_url) - : console.error("could not access auth_login_url"); - } - fetchEnv().catch((error) => console.warn("error fetching api/env", error)); - }, []); - - return ( - - - {navLoginLinkText} - - ); -}; - const Header = ({ logoPath, locale }: Props) => { logoPath = "./img/grants-logo.svg"; const t = useTranslations("Header"); @@ -173,7 +143,6 @@ const Header = ({ logoPath, locale }: Props) => { const { checkFeatureFlag } = useFeatureFlags(); const showLoginLink = checkFeatureFlag("authOn"); - const language = locale && locale.match("/^es/") ? "spanish" : "english"; const handleMobileNavToggle = () => { @@ -223,10 +192,8 @@ const Header = ({ logoPath, locale }: Props) => { /> {!!showLoginLink && ( -
-
- -
+
+
)} - - {t("Layout.skip_to_main")} - -
-
- {children} -
-
- -
+ +
+ + {t("Layout.skip_to_main")} + +
+
+ {children} +
+
+ +
+
); } diff --git a/frontend/src/components/USWDSIcon.tsx b/frontend/src/components/USWDSIcon.tsx index 7414d8978..3785e112a 100644 --- a/frontend/src/components/USWDSIcon.tsx +++ b/frontend/src/components/USWDSIcon.tsx @@ -1,8 +1,10 @@ +import clsx from "clsx"; + import SpriteSVG from "public/img/uswds-sprite.svg"; interface IconProps { name: string; - className: string; + className?: string; height?: string; } @@ -12,7 +14,7 @@ const sprite_uri = SpriteSVG.src as string; export function USWDSIcon(props: IconProps) { return ( {t("goal.cta")} diff --git a/frontend/src/components/content/ProcessAndResearchContent.tsx b/frontend/src/components/content/ProcessAndResearchContent.tsx index ef59ee7a2..0f467f1de 100644 --- a/frontend/src/components/content/ProcessAndResearchContent.tsx +++ b/frontend/src/components/content/ProcessAndResearchContent.tsx @@ -27,7 +27,7 @@ const ProcessAndResearchContent = () => { @@ -47,7 +47,7 @@ const ProcessAndResearchContent = () => { diff --git a/frontend/src/components/opportunity/OpportunityCTA.tsx b/frontend/src/components/opportunity/OpportunityCTA.tsx index ad4486493..d7a2173b5 100644 --- a/frontend/src/components/opportunity/OpportunityCTA.tsx +++ b/frontend/src/components/opportunity/OpportunityCTA.tsx @@ -36,10 +36,7 @@ const OpportunityCTA = ({ id }: { id: number }) => { > diff --git a/frontend/src/components/opportunity/OpportunityDownload.tsx b/frontend/src/components/opportunity/OpportunityDownload.tsx index b9dbb82b4..3984c9569 100644 --- a/frontend/src/components/opportunity/OpportunityDownload.tsx +++ b/frontend/src/components/opportunity/OpportunityDownload.tsx @@ -22,10 +22,7 @@ const OpportunityDownload = ({ nofoPath }: Props) => {
{ switch (status) { case "archived": return ( -
+

{t("archived")} {formatDate(archiveDate) || "--"} @@ -71,7 +71,7 @@ const OpportunityStatusWidget = ({ opportunityData }: Props) => { ); case "closed": return ( -

+

{t("closed")} {formatDate(closeDate) || "--"} @@ -81,7 +81,7 @@ const OpportunityStatusWidget = ({ opportunityData }: Props) => { case "posted": return ( <> -

+

{t("closing")} {formatDate(closeDate) || "--"} @@ -96,7 +96,7 @@ const OpportunityStatusWidget = ({ opportunityData }: Props) => { ); case "forecasted": return ( -

+

{t("forecasted")}

diff --git a/frontend/src/components/user/UserControl.tsx b/frontend/src/components/user/UserControl.tsx new file mode 100644 index 000000000..51dc3af9f --- /dev/null +++ b/frontend/src/components/user/UserControl.tsx @@ -0,0 +1,171 @@ +import clsx from "clsx"; +import { UserProfile } from "src/services/auth/types"; +import { useUser } from "src/services/auth/useUser"; + +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useState } from "react"; +import { + IconListContent, + Menu, + NavDropDownButton, +} from "@trussworks/react-uswds"; + +import { USWDSIcon } from "src/components/USWDSIcon"; + +const LoginLink = ({ + navLoginLinkText, + loginUrl, +}: { + navLoginLinkText: string; + loginUrl: string; +}) => { + return ( + + ); +}; + +// used in three different places +// 1. on desktop - nav item drop down button content +// 2. on mobile - nav item drop down button content, without email text +// 3. on mobile - nav sub item content +const UserEmailItem = ({ + email, + isSubnav, +}: { + email?: string; + isSubnav: boolean; +}) => { + return ( + + +
+ {email} +
+
+ ); +}; + +const UserDropdown = ({ + user, + navLogoutLinkText, + logout, +}: { + user: UserProfile; + navLogoutLinkText: string; + logout: () => Promise; +}) => { + const [userProfileMenuOpen, setUserProfileMenuOpen] = useState(false); + + const logoutNavItem = ( + logout()} + > + + + {navLogoutLinkText} + + + ); + + return ( +
+ } + isOpen={userProfileMenuOpen} + onToggle={() => setUserProfileMenuOpen(!userProfileMenuOpen)} + isCurrent={false} + menuId="user-control" + /> + , + logoutNavItem, + ]} + type="subnav" + isOpen={userProfileMenuOpen} + /> +
+ ); +}; + +export const UserControl = () => { + const t = useTranslations("Header"); + + const { user, refreshUser } = useUser(); + + const logout = useCallback(async (): Promise => { + await fetch("/api/auth/logout", { + method: "POST", + }); + await refreshUser(); + }, [refreshUser]); + + const [authLoginUrl, setAuthLoginUrl] = useState(null); + + useEffect(() => { + async function fetchEnv() { + const res = await fetch("/api/env"); + const data = (await res.json()) as { auth_login_url: string }; + data.auth_login_url + ? setAuthLoginUrl(data.auth_login_url) + : console.error("could not access auth_login_url"); + } + fetchEnv().catch((error) => console.warn("error fetching api/env", error)); + }, []); + + return ( + <> + {!user?.token && ( + + )} + {!!user?.token && ( + + )} + + ); +}; diff --git a/frontend/src/constants/environments.ts b/frontend/src/constants/environments.ts index a005297a6..99e62de54 100644 --- a/frontend/src/constants/environments.ts +++ b/frontend/src/constants/environments.ts @@ -16,6 +16,7 @@ const { FEATURE_OPPORTUNITY_OFF, FEATURE_AUTH_ON, AUTH_LOGIN_URL, + API_JWT_PUBLIC_KEY, } = process.env; export const featureFlags = { @@ -41,4 +42,5 @@ export const environment: { [key: string]: string } = { NEXT_BUILD: NEXT_BUILD || "false", SESSION_SECRET: SESSION_SECRET || "", NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL || "http://localhost:3000", + API_JWT_PUBLIC_KEY: API_JWT_PUBLIC_KEY || "", }; diff --git a/frontend/src/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts index 1a1cb567c..bcdfc5441 100644 --- a/frontend/src/i18n/messages/en/index.ts +++ b/frontend/src/i18n/messages/en/index.ts @@ -479,6 +479,7 @@ export const messages = { nav_menu_toggle: "Menu", nav_link_search: "Search", nav_link_login: "Sign in", + nav_link_logout: "Sign out", title: "Simpler.Grants.gov", }, Hero: { diff --git a/frontend/src/services/auth/UserProvider.tsx b/frontend/src/services/auth/UserProvider.tsx index eb55878e7..c812f3784 100644 --- a/frontend/src/services/auth/UserProvider.tsx +++ b/frontend/src/services/auth/UserProvider.tsx @@ -3,7 +3,7 @@ // note that importing these individually allows us to mock them, otherwise mocks don't work :shrug: import debounce from "lodash/debounce"; import noop from "lodash/noop"; -import { UserSession } from "src/services/auth/types"; +import { UserProfile } from "src/services/auth/types"; import { UserContext } from "src/services/auth/useUser"; import { userFetcher } from "src/services/fetch/fetchers/clientUserFetcher"; import { isSessionExpired } from "src/utils/authUtil"; @@ -25,7 +25,7 @@ export default function UserProvider({ }: { children: React.ReactNode; }) { - const [localUser, setLocalUser] = useState(null); + const [localUser, setLocalUser] = useState(); const [isLoading, setIsLoading] = useState(false); const [userFetchError, setUserFetchError] = useState(); @@ -52,8 +52,13 @@ export default function UserProvider({ }, [localUser, getUserSession]); const value = useMemo( - () => ({ user: localUser, error: userFetchError, isLoading }), - [localUser, userFetchError, isLoading], + () => ({ + user: localUser, + error: userFetchError, + isLoading, + refreshUser: getUserSession, + }), + [localUser, userFetchError, isLoading, getUserSession], ); return {children}; diff --git a/frontend/src/services/auth/session.ts b/frontend/src/services/auth/session.ts index ba57cddce..8ad47be57 100644 --- a/frontend/src/services/auth/session.ts +++ b/frontend/src/services/auth/session.ts @@ -1,68 +1,66 @@ import "server-only"; -import { JWTPayload, jwtVerify, SignJWT } from "jose"; +import { createPublicKey, KeyObject } from "crypto"; import { environment } from "src/constants/environments"; -import { SessionPayload, UserSession } from "src/services/auth/types"; +import { + API_JWT_ENCRYPTION_ALGORITHM, + CLIENT_JWT_ENCRYPTION_ALGORITHM, + decrypt, + encrypt, + newExpirationDate, +} from "src/services/auth/sessionUtils"; +import { SimplerJwtPayload, UserSession } from "src/services/auth/types"; import { encodeText } from "src/utils/generalUtils"; // note that cookies will be async in Next 15 import { cookies } from "next/headers"; -const encodedKey = encodeText(environment.SESSION_SECRET); +let clientJwtKey: Uint8Array; +let loginGovJwtKey: KeyObject; -// returns a new date 1 week from time of function call -export const newExpirationDate = () => - new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); - -export async function encrypt({ - token, - expiresAt, -}: SessionPayload): Promise { - const jwt = await new SignJWT({ token }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt() - .setExpirationTime(expiresAt || "") - .sign(encodedKey); - return jwt; -} - -export async function decrypt( - sessionCookie: string | undefined = "", -): Promise { - try { - const { payload } = await jwtVerify(sessionCookie, encodedKey, { - algorithms: ["HS256"], - }); - return payload; - } catch (error) { - console.error("Failed to decrypt session cookie", error); - return null; +// isolate encoding behavior from file execution +const initializeSessionSecrets = () => { + if (!environment.SESSION_SECRET || !environment.API_JWT_PUBLIC_KEY) { + // eslint-disable-next-line + console.debug("Session keys not present"); + return; } -} + // eslint-disable-next-line + console.debug("Initializing Session Secrets"); + clientJwtKey = encodeText(environment.SESSION_SECRET); + loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); +}; -// could try memoizing this function if it is a performance risk -export const getTokenFromCookie = async ( - cookie: string, -): Promise => { - const decryptedSession = await decrypt(cookie); - if (!decryptedSession) return null; - const token = (decryptedSession.token as string) ?? null; - if (!token) return null; - return { - token, - }; +const decryptClientToken = async ( + jwt: string, +): Promise => { + const payload = await decrypt( + jwt, + clientJwtKey, + CLIENT_JWT_ENCRYPTION_ALGORITHM, + ); + if (!payload || !payload.token) return null; + return payload as SimplerJwtPayload; }; -// returns token decrypted from session cookie or null -export const getSession = async (): Promise => { - const cookie = cookies().get("session")?.value; - if (!cookie) return null; - return getTokenFromCookie(cookie); +const decryptLoginGovToken = async ( + jwt: string, +): Promise => { + const payload = await decrypt( + jwt, + loginGovJwtKey, + API_JWT_ENCRYPTION_ALGORITHM, + ); + return (payload as UserSession) ?? null; }; -export async function createSession(token: string) { +// sets client token on cookie +export const createSession = async (token: string) => { + if (!clientJwtKey) { + initializeSessionSecrets(); + } const expiresAt = newExpirationDate(); - const session = await encrypt({ token, expiresAt }); + const session = await encrypt(token, expiresAt, clientJwtKey); cookies().set("session", session, { httpOnly: true, secure: true, @@ -70,9 +68,27 @@ export async function createSession(token: string) { sameSite: "lax", path: "/", }); -} +}; -// currently unused, will be used in the future for logout -export function deleteSession() { - cookies().delete("session"); -} +// returns the necessary user info from decrypted login gov token +// plus client token and expiration +export const getSession = async (): Promise => { + if (!clientJwtKey || !loginGovJwtKey) { + initializeSessionSecrets(); + } + const cookie = cookies().get("session")?.value; + if (!cookie) return null; + const payload = await decryptClientToken(cookie); + if (!payload) { + return null; + } + const { token, exp } = payload; + const session = await decryptLoginGovToken(token); + return session + ? { + ...session, + token, + exp, + } + : null; +}; diff --git a/frontend/src/services/auth/sessionUtils.ts b/frontend/src/services/auth/sessionUtils.ts new file mode 100644 index 000000000..d075dd55c --- /dev/null +++ b/frontend/src/services/auth/sessionUtils.ts @@ -0,0 +1,46 @@ +import { KeyObject } from "crypto"; +import { JWTPayload, jwtVerify, SignJWT } from "jose"; + +import { cookies } from "next/headers"; + +export const CLIENT_JWT_ENCRYPTION_ALGORITHM = "HS256"; +export const API_JWT_ENCRYPTION_ALGORITHM = "RS256"; + +// returns a new date 1 week from time of function call +export const newExpirationDate = () => + new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + +// extracts payload object from jwt string using passed encrytion key and algo +export const decrypt = async ( + jwt = "", + encryptionKey: KeyObject | Uint8Array, + algorithm: string, +): Promise => { + try { + const { payload } = await jwtVerify(jwt, encryptionKey, { + algorithms: [algorithm], + }); + return payload; + } catch (error) { + console.error("Failed to decrypt session cookie", error); + return null; + } +}; + +// we only encrypt using the client key +export const encrypt = async ( + token: string, + expiresAt: Date, + clientJwtKey: Uint8Array, +): Promise => { + const jwt = await new SignJWT({ token }) + .setProtectedHeader({ alg: CLIENT_JWT_ENCRYPTION_ALGORITHM }) + .setIssuedAt() + .setExpirationTime(expiresAt || "") + .sign(clientJwtKey); + return jwt; +}; + +export function deleteSession() { + cookies().delete("session"); +} diff --git a/frontend/src/services/auth/types.tsx b/frontend/src/services/auth/types.tsx index 56eb3db76..3e5666740 100644 --- a/frontend/src/services/auth/types.tsx +++ b/frontend/src/services/auth/types.tsx @@ -1,3 +1,5 @@ +import { JWTPayload } from "jose"; + /** * Configure the UserProvider component. * @@ -33,36 +35,32 @@ * @category Client */ -/** - * The user claims returned from the useUser hook. - * - * @category Client - */ +// represents relevant client side data from API JWT export interface UserProfile { - name?: string | null; -} - -export type UserSession = { + email?: string; token: string; - expiresAt?: Date; -} | null; + expiresAt: Date; +} -export type SessionPayload = { +// represents client JWT payload +export interface SimplerJwtPayload extends JWTPayload { token: string; - expiresAt: Date; -}; +} +// represents API JWT payload +export type UserSession = UserProfile & SimplerJwtPayload; /** * Fetches the user from the profile API route to fill the useUser hook with the * UserProfile object. */ -export type UserFetcher = (url: string) => Promise; +export type UserFetcher = (url: string) => Promise; /** * @ignore */ export type UserProviderState = { - user?: UserSession; + user?: UserProfile; error?: Error; isLoading: boolean; + refreshUser: () => Promise; }; diff --git a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts index 2093f4e06..649ba7d99 100644 --- a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts +++ b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts @@ -1,19 +1,19 @@ "use client"; import { ApiRequestError } from "src/errors"; -import { SessionPayload, UserFetcher } from "src/services/auth/types"; +import { UserFetcher, UserSession } from "src/services/auth/types"; // this fetcher is a one off for now, since the request is made from the client to the // NextJS Node server. We will need to build out a fetcher pattern to accomodate this usage in the future export const userFetcher: UserFetcher = async (url) => { let response; try { - response = await fetch(url); + response = await fetch(url, { cache: "no-store" }); } catch (e) { console.error("User session fetch network error", e); throw new ApiRequestError(0); // Network error } if (response.status === 204) return undefined; - if (response.ok) return (await response.json()) as SessionPayload; + if (response.ok) return (await response.json()) as UserSession; throw new ApiRequestError(response.status); }; diff --git a/frontend/src/styles/_uswds-theme-custom-styles.scss b/frontend/src/styles/_uswds-theme-custom-styles.scss index 22d96e5f8..7de4a6f15 100644 --- a/frontend/src/styles/_uswds-theme-custom-styles.scss +++ b/frontend/src/styles/_uswds-theme-custom-styles.scss @@ -383,4 +383,43 @@ button.usa-pagination__button.usa-button { .desktop\:margin-bottom-5px { margin-bottom: 5px !important; } + .usa-nav__submenu { + right: 0; + } +} + +// we are implementing the uswds nav drop down at mobile widths, which is not ordinarily supported +// these styles are taken from the desktop imlementation of the dropdown and applied at all breakpoints +.usa-nav__primary { + .mobile-nav-dropdown-uncollapsed-override { + button[aria-expanded="true"] { + background-color: color("mint-60"); + a { + color: white; + } + span:after { + mask-image: url("/uswds/img/usa-icons/expand_less.svg"), + linear-gradient(transparent, transparent); + } + } + button[aria-expanded="false"] { + span:after { + mask-image: url("/uswds/img/usa-icons/expand_more.svg"), + linear-gradient(transparent, transparent); + } + } + .usa-nav__submenu-item { + background-color: color("mint-60"); + a { + padding-left: 1rem; + padding-right: 1rem; + color: white; + line-height: 1.4; + display: block; + } + } + .usa-nav__submenu { + right: 3.6em; + } + } } diff --git a/frontend/src/utils/authUtil.ts b/frontend/src/utils/authUtil.ts index 405012542..9bc91811b 100644 --- a/frontend/src/utils/authUtil.ts +++ b/frontend/src/utils/authUtil.ts @@ -1,6 +1,6 @@ -import { UserSession } from "src/services/auth/types"; +import { UserProfile } from "src/services/auth/types"; -export const isSessionExpired = (userSession: UserSession): boolean => { +export const isSessionExpired = (userSession: UserProfile): boolean => { // if we haven't implemented expiration yet // TODO: remove this once expiration is implemented in the token if (!userSession?.expiresAt) { diff --git a/frontend/tests/api/auth/logout/route.test.ts b/frontend/tests/api/auth/logout/route.test.ts index 89ce4330c..4e3893cba 100644 --- a/frontend/tests/api/auth/logout/route.test.ts +++ b/frontend/tests/api/auth/logout/route.test.ts @@ -10,6 +10,9 @@ const postLogoutMock = jest.fn(); jest.mock("src/services/auth/session", () => ({ getSession: (): unknown => getSessionMock(), +})); + +jest.mock("src/services/auth/sessionUtils", () => ({ deleteSession: (): unknown => deleteSessionMock(), })); diff --git a/frontend/tests/services/auth/session.test.ts b/frontend/tests/services/auth/session.test.ts index ba2889d4d..f5f585dba 100644 --- a/frontend/tests/services/auth/session.test.ts +++ b/frontend/tests/services/auth/session.test.ts @@ -1,38 +1,16 @@ -import { - createSession, - decrypt, - deleteSession, - encrypt, - getSession, - getTokenFromCookie, -} from "src/services/auth/session"; +import { createSession, getSession } from "src/services/auth/session"; -type RecursiveObject = { - [key: string]: () => RecursiveObject | string; -}; - -const getCookiesMock = jest.fn(); +const getCookiesMock = jest.fn(() => ({ + value: "some cookie value", +})); const setCookiesMock = jest.fn(); const deleteCookiesMock = jest.fn(); -const reallyFakeMockJWTConstructor = jest.fn(); -const setProtectedHeaderMock = jest.fn(() => fakeJWTInstance()); -const setIssuedAtMock = jest.fn(() => fakeJWTInstance()); -const setExpirationTimeMock = jest.fn(() => fakeJWTInstance()); -const signMock = jest.fn(); -const jwtVerifyMock = jest.fn(); -// close over the token -// all of this rigmarole means that the mocked signing functionality will output the token passed into it -const setJWTMocksWithToken = (token: string) => { - signMock.mockImplementation(() => token); -}; +const encodeTextMock = jest.fn((arg: string): string => arg); +const createPublicKeyMock = jest.fn((arg: string): string => arg); -const fakeJWTInstance = (): RecursiveObject => ({ - setProtectedHeader: setProtectedHeaderMock, - setIssuedAt: setIssuedAtMock, - setExpirationTime: setExpirationTimeMock, - sign: signMock, -}); +const decryptMock = jest.fn(); +const encryptMock = jest.fn(); const cookiesMock = () => { return { @@ -42,145 +20,106 @@ const cookiesMock = () => { }; }; -jest.mock("next/headers", () => ({ - cookies: () => cookiesMock(), +jest.mock("src/services/auth/sessionUtils", () => ({ + decrypt: (...args: unknown[]) => decryptMock(args) as unknown, + encrypt: (...args: unknown[]) => encryptMock(args) as unknown, + CLIENT_JWT_ENCRYPTION_ALGORITHM: "algo one", + API_JWT_ENCRYPTION_ALGORITHM: "algo two", + newExpirationDate: () => new Date(0), })); -jest.mock("jose", () => ({ - jwtVerify: (...args: unknown[]): unknown => jwtVerifyMock(...args), - SignJWT: function SignJWTMock( - this: { - setProtectedHeader: typeof jest.fn; - setIssuedAt: typeof jest.fn; - setExpirationTime: typeof jest.fn; - sign: typeof jest.fn; - token: string; - }, - { token = "" } = {}, - ) { - reallyFakeMockJWTConstructor(); - setJWTMocksWithToken(token); - return { - ...fakeJWTInstance(), - }; - }, +jest.mock("next/headers", () => ({ + cookies: () => cookiesMock(), })); jest.mock("src/constants/environments", () => ({ environment: { SESSION_SECRET: "session secret", + API_JWT_PUBLIC_KEY: "api secret", }, })); jest.mock("src/utils/generalUtils", () => ({ - encodeText: (arg: string): string => arg, + encodeText: (arg: string): string => encodeTextMock(arg), })); -describe("encrypt", () => { - afterEach(() => jest.clearAllMocks()); - it("calls all the JWT functions with expected values and returns expected value", async () => { - const token = "fakeToken"; - const expiresAt = new Date(); - const encrypted = await encrypt({ token, expiresAt }); - - expect(reallyFakeMockJWTConstructor).toHaveBeenCalledTimes(1); - - expect(setProtectedHeaderMock).toHaveBeenCalledTimes(1); - expect(setProtectedHeaderMock).toHaveBeenCalledWith({ alg: "HS256" }); - - expect(setIssuedAtMock).toHaveBeenCalledTimes(1); - - expect(setExpirationTimeMock).toHaveBeenCalledTimes(1); - expect(setExpirationTimeMock).toHaveBeenCalledWith(expiresAt); - - expect(signMock).toHaveBeenCalledTimes(1); - expect(signMock).toHaveBeenCalledWith("session secret"); - - // this is synthetic but generally proves things are working - expect(encrypted).toEqual(token); - }); -}); +jest.mock("crypto", () => ({ + createPublicKey: (arg: string): string => createPublicKeyMock(arg), +})); -describe("decrypt", () => { +describe("getSession", () => { afterEach(() => jest.clearAllMocks()); - it("calls JWT verification with expected values and returns payload", async () => { - const cookie = "fakeCookie"; - jwtVerifyMock.mockImplementation((...args) => ({ payload: args })); - const decrypted = await decrypt(cookie); - - expect(jwtVerifyMock).toHaveBeenCalledTimes(1); - expect(jwtVerifyMock).toHaveBeenCalledWith(cookie, "session secret", { - algorithms: ["HS256"], + it("initializes session secrets if necessary", async () => { + await getSession(); + expect(encodeTextMock).toHaveBeenCalledWith("session secret"); + expect(createPublicKeyMock).toHaveBeenCalledWith("api secret"); + }); + it("calls decrypt with the correct arguments and returns successfully", async () => { + decryptMock.mockReturnValue({ + token: "some decrypted token", + exp: 123, }); - - expect(decrypted).toEqual([ - cookie, + const session = await getSession(); + expect(decryptMock).toHaveBeenCalledTimes(2); + expect(decryptMock).toHaveBeenCalledWith([ + "some cookie value", "session secret", - { algorithms: ["HS256"] }, + "algo one", ]); - }); - - it("returns null on error", async () => { - jwtVerifyMock.mockImplementation(() => { - throw new Error(); + expect(decryptMock).toHaveBeenCalledWith([ + "some decrypted token", + "api secret", + "algo two", + ]); + expect(session).toEqual({ + token: "some decrypted token", + exp: 123, }); - const cookie = "fakeCookie"; - const decrypted = await decrypt(cookie); - expect(decrypted).toEqual(null); + }); + it("returns null if client token decrypt does not return a payload and token", async () => { + decryptMock.mockReturnValue(null); + const session = await getSession(); + expect(session).toEqual(null); + }); + it("returns null if api token decrypt does not return a payload", async () => { + decryptMock + .mockReturnValueOnce({ + token: "some decrypted token", + exp: 123, + }) + .mockReturnValueOnce(null); + const session = await getSession(); + expect(session).toEqual(null); }); }); describe("createSession", () => { afterEach(() => jest.clearAllMocks()); + // to get this to work we'd need to manage resetting all modules before the test, which is a bit of a pain + it.skip("initializes session secrets if necessary", async () => { + await createSession("nothingSpecial"); + expect(encodeTextMock).toHaveBeenCalledWith("session secret"); + expect(createPublicKeyMock).toHaveBeenCalledWith("api secret"); + }); it("calls cookie.set with expected values", async () => { + encryptMock.mockReturnValue("encrypted session"); await createSession("nothingSpecial"); + expect(encryptMock).toHaveBeenCalledWith([ + "nothingSpecial", + new Date(0), + "session secret", + ]); expect(setCookiesMock).toHaveBeenCalledTimes(1); - expect(setCookiesMock).toHaveBeenCalledWith("session", "nothingSpecial", { - httpOnly: true, - secure: true, - expires: expect.any(Date) as Date, - sameSite: "lax", - path: "/", - }); - }); -}); - -describe("deleteSession", () => { - afterEach(() => jest.clearAllMocks()); - it("calls cookie.delete with expected values", () => { - deleteSession(); - expect(deleteCookiesMock).toHaveBeenCalledTimes(1); - expect(deleteCookiesMock).toHaveBeenCalledWith("session"); - }); -}); - -describe("getTokenFromCookie", () => { - afterEach(() => jest.clearAllMocks()); - it("returns null if decrypt returns no session", async () => { - jwtVerifyMock.mockImplementation(() => null); - const result = await getTokenFromCookie("invalidEncryptedCookie"); - expect(result).toEqual(null); - }); - - it("returns null if decrypt returns session with no token", async () => { - jwtVerifyMock.mockImplementation(() => ({})); - const result = await getTokenFromCookie("invalidEncryptedCookie"); - expect(result).toEqual(null); - }); - - it("returns token returned from decryp", async () => { - jwtVerifyMock.mockImplementation((token: string) => ({ - payload: { token }, - })); - const result = await getTokenFromCookie("invalidEncryptedCookie"); - expect(result).toEqual({ token: "invalidEncryptedCookie" }); - }); -}); - -describe("getSession", () => { - afterEach(() => jest.clearAllMocks()); - it("returns null if there is no session cookie", async () => { - const result = await getSession(); - expect(result).toEqual(null); + expect(setCookiesMock).toHaveBeenCalledWith( + "session", + "encrypted session", + { + httpOnly: true, + secure: true, + expires: new Date(0), + sameSite: "lax", + path: "/", + }, + ); }); }); diff --git a/frontend/tests/services/auth/sessionUtils.test.ts b/frontend/tests/services/auth/sessionUtils.test.ts new file mode 100644 index 000000000..a43884875 --- /dev/null +++ b/frontend/tests/services/auth/sessionUtils.test.ts @@ -0,0 +1,125 @@ +import { + decrypt, + deleteSession, + encrypt, +} from "src/services/auth/sessionUtils"; + +type RecursiveObject = { + [key: string]: () => RecursiveObject | string; +}; + +const getCookiesMock = jest.fn(); +const setCookiesMock = jest.fn(); +const deleteCookiesMock = jest.fn(); +const reallyFakeMockJWTConstructor = jest.fn(); +const setProtectedHeaderMock = jest.fn(() => fakeJWTInstance()); +const setIssuedAtMock = jest.fn(() => fakeJWTInstance()); +const setExpirationTimeMock = jest.fn(() => fakeJWTInstance()); +const signMock = jest.fn(); +const jwtVerifyMock = jest.fn(); + +const fakeKey = new Uint8Array([1, 2, 3]); + +// close over the token +// all of this rigmarole means that the mocked signing functionality will output the token passed into it +const setJWTMocksWithToken = (token: string) => { + signMock.mockImplementation(() => token); +}; + +const fakeJWTInstance = (): RecursiveObject => ({ + setProtectedHeader: setProtectedHeaderMock, + setIssuedAt: setIssuedAtMock, + setExpirationTime: setExpirationTimeMock, + sign: signMock, +}); + +const cookiesMock = () => { + return { + get: getCookiesMock, + set: setCookiesMock, + delete: deleteCookiesMock, + }; +}; + +jest.mock("next/headers", () => ({ + cookies: () => cookiesMock(), +})); + +jest.mock("jose", () => ({ + jwtVerify: (...args: unknown[]): unknown => jwtVerifyMock(...args), + SignJWT: function SignJWTMock( + this: { + setProtectedHeader: typeof jest.fn; + setIssuedAt: typeof jest.fn; + setExpirationTime: typeof jest.fn; + sign: typeof jest.fn; + token: string; + }, + { token = "" } = {}, + ) { + reallyFakeMockJWTConstructor(); + setJWTMocksWithToken(token); + return { + ...fakeJWTInstance(), + }; + }, +})); + +describe("deleteSession", () => { + afterEach(() => jest.clearAllMocks()); + it("calls cookie.delete with expected values", () => { + deleteSession(); + expect(deleteCookiesMock).toHaveBeenCalledTimes(1); + expect(deleteCookiesMock).toHaveBeenCalledWith("session"); + }); +}); + +describe("encrypt", () => { + afterEach(() => jest.clearAllMocks()); + it("calls all the JWT functions with expected values and returns expected value", async () => { + const token = "fakeToken"; + const expiresAt = new Date(); + + const encrypted = await encrypt(token, expiresAt, fakeKey); + + expect(reallyFakeMockJWTConstructor).toHaveBeenCalledTimes(1); + + expect(setProtectedHeaderMock).toHaveBeenCalledTimes(1); + expect(setProtectedHeaderMock).toHaveBeenCalledWith({ alg: "HS256" }); + + expect(setIssuedAtMock).toHaveBeenCalledTimes(1); + + expect(setExpirationTimeMock).toHaveBeenCalledTimes(1); + expect(setExpirationTimeMock).toHaveBeenCalledWith(expiresAt); + + expect(signMock).toHaveBeenCalledTimes(1); + expect(signMock).toHaveBeenCalledWith(fakeKey); + + // this is synthetic but generally proves things are working + expect(encrypted).toEqual(token); + }); +}); + +describe("decrypt", () => { + const cookie = "fakeCookie"; + afterEach(() => jest.clearAllMocks()); + it("calls JWT verification with expected values and returns payload", async () => { + jwtVerifyMock.mockImplementation((...args) => ({ payload: args })); + const decrypted = await decrypt(cookie, fakeKey, "HS256"); + + expect(jwtVerifyMock).toHaveBeenCalledTimes(1); + expect(jwtVerifyMock).toHaveBeenCalledWith(cookie, fakeKey, { + algorithms: ["HS256"], + }); + + expect(decrypted).toEqual([cookie, fakeKey, { algorithms: ["HS256"] }]); + }); + + it("returns null on error", async () => { + jwtVerifyMock.mockImplementation(() => { + throw new Error(); + }); + const decrypted = await decrypt(cookie, fakeKey, "HS256"); + expect(decrypted).toEqual(null); + }); +}); diff --git a/infra/frontend/app-config/env-config/environment-variables.tf b/infra/frontend/app-config/env-config/environment-variables.tf index 2551d89dc..857afce37 100644 --- a/infra/frontend/app-config/env-config/environment-variables.tf +++ b/infra/frontend/app-config/env-config/environment-variables.tf @@ -75,5 +75,9 @@ locals { manage_method = "manual" secret_store_name = "/${var.app_name}/${var.environment}/feature-auth-on" }, + API_JWT_PUBLIC_KEY = { + manage_method = "manual" + secret_store_name = "/api/${var.environment}/api-jwt-public-key" + }, } }