From 0204314ce6b98654cd667f8a72a426ad969658c8 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Fri, 3 Jan 2025 09:19:48 -0500 Subject: [PATCH 01/16] wip --- frontend/src/services/auth/UserProvider.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/services/auth/UserProvider.tsx b/frontend/src/services/auth/UserProvider.tsx index eb55878e7..ac171ff65 100644 --- a/frontend/src/services/auth/UserProvider.tsx +++ b/frontend/src/services/auth/UserProvider.tsx @@ -34,6 +34,7 @@ export default function UserProvider({ setIsLoading(true); const fetchedUser = await debouncedUserFetcher(); if (fetchedUser) { + console.log("!!! USER", fetchedUser); setLocalUser(fetchedUser); setUserFetchError(undefined); setIsLoading(false); From 735106ca8c1cd202b48891c748bb9a0da22e2d65 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Fri, 3 Jan 2025 14:13:54 -0500 Subject: [PATCH 02/16] got this far trying to use JWK but running into errors re: public vs private vs secret jwk --- frontend/src/app/api/auth/callback/route.ts | 8 +- frontend/src/app/api/auth/logout/route.ts | 5 +- frontend/src/app/api/auth/session/route.ts | 5 +- frontend/src/constants/environments.ts | 2 + frontend/src/services/auth/UserProvider.tsx | 4 +- frontend/src/services/auth/session.ts | 192 +++++++++++++----- frontend/src/services/auth/types.tsx | 32 +-- .../fetch/fetchers/clientUserFetcher.ts | 4 +- frontend/src/utils/authUtil.ts | 4 +- 9 files changed, 177 insertions(+), 79 deletions(-) diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index 34863b885..217d06c01 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -1,4 +1,4 @@ -import { createSession, getSession } from "src/services/auth/session"; +import { getSessionManager } from "src/services/auth/session"; import { redirect } from "next/navigation"; import { NextRequest } from "next/server"; @@ -8,7 +8,8 @@ const createSessionAndSetStatus = async ( successStatus: string, ): Promise => { try { - await createSession(token); + const sessionManager = await getSessionManager(); + sessionManager.createSession(token); return successStatus; } catch (error) { console.error("error in creating session", error); @@ -31,7 +32,8 @@ const createSessionAndSetStatus = async ( - ... */ export async function GET(request: NextRequest) { - const currentSession = await getSession(); + const sessionManager = await getSessionManager(); + const currentSession = await sessionManager.getSession(); if (currentSession && currentSession.token) { const status = await createSessionAndSetStatus( currentSession.token, diff --git a/frontend/src/app/api/auth/logout/route.ts b/frontend/src/app/api/auth/logout/route.ts index 9ba2136df..6aca304c0 100644 --- a/frontend/src/app/api/auth/logout/route.ts +++ b/frontend/src/app/api/auth/logout/route.ts @@ -1,10 +1,11 @@ -import { deleteSession, getSession } from "src/services/auth/session"; +import { deleteSession, getSessionManager } from "src/services/auth/session"; 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(); + const sessionManager = await getSessionManager(); + const session = await sessionManager.getSession(); if (!session || !session.token) { throw new Error("No active session to logout"); } diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts index 668eae8bb..c5b42bdb4 100644 --- a/frontend/src/app/api/auth/session/route.ts +++ b/frontend/src/app/api/auth/session/route.ts @@ -1,9 +1,10 @@ -import { getSession } from "src/services/auth/session"; +import { getSessionManager } from "src/services/auth/session"; import { NextResponse } from "next/server"; export async function GET() { - const currentSession = await getSession(); + const sessionManager = await getSessionManager(); + const currentSession = await sessionManager.getSession(); if (currentSession) { return NextResponse.json({ token: currentSession.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/services/auth/UserProvider.tsx b/frontend/src/services/auth/UserProvider.tsx index ac171ff65..6d6c68d23 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(); diff --git a/frontend/src/services/auth/session.ts b/frontend/src/services/auth/session.ts index ba57cddce..3927f7b7a 100644 --- a/frontend/src/services/auth/session.ts +++ b/frontend/src/services/auth/session.ts @@ -1,78 +1,168 @@ import "server-only"; -import { JWTPayload, jwtVerify, SignJWT } from "jose"; +import { exportJWK, JWK, JWTPayload, jwtVerify, SignJWT } from "jose"; import { environment } from "src/constants/environments"; -import { SessionPayload, UserSession } from "src/services/auth/types"; +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); +const CLIENT_JWT_ENCRYPTION_ALGORITHM = "HS256"; +const API_JWT_ENCRYPTION_ALGORITHM = "RS256"; + +let sessionManager: SessionManager; + +class SessionManager { + private clientSecret: Uint8Array; + private loginGovSecret: JWK; + + private constructor(clientSecret: Uint8Array, loginGovSecret: JWK) { + this.clientSecret = clientSecret; + this.loginGovSecret = loginGovSecret; + } + + static async createSessionManager() { + // const [clientJwk, apiJwk] = await Promise.all([ + // exportJWK(encodeText(environment.SESSION_SECRET)), + // exportJWK(encodeText(environment.API_JWT_PUBLIC_KEY)), + // ]); + // return new SessionManager(clientJwk, apiJwk); + const clientSecret = encodeText(environment.SESSION_SECRET); + const apiJwk = await exportJWK(encodeText(environment.API_JWT_PUBLIC_KEY)); + return new SessionManager(clientSecret, apiJwk); + } + + async decryptClientToken(jwt: string): Promise { + const payload = await decrypt( + jwt, + this.clientSecret, + CLIENT_JWT_ENCRYPTION_ALGORITHM, + ); + if (!payload || !payload.token) return null; + return payload as SimplerJwtPayload; + } + async decryptLoginGovToken(jwt: string): Promise { + const payload = await decrypt( + jwt, + this.loginGovSecret, + API_JWT_ENCRYPTION_ALGORITHM, + ); + if (!payload || !payload.token) return null; + return payload as UserSession; + } + + async createSession(token: string) { + const expiresAt = newExpirationDate(); + const session = await encrypt(token, expiresAt, this.clientSecret); + cookies().set("session", session, { + httpOnly: true, + secure: true, + expires: expiresAt, + sameSite: "lax", + path: "/", + }); + } + + async getSession() { + const cookie = cookies().get("session")?.value; + if (!cookie) return null; + const payload = await this.decryptClientToken(cookie); + if (!payload) { + return null; + } + const { token } = payload; + const session = await this.decryptLoginGovToken(token); + return session || null; + } +} // 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 { +// extracts payload object from jwt string using passed encrytion key +const decrypt = async ( + jwt = "", + encryptionKey: JWK | Uint8Array, + algorithm: string, +): Promise => { try { - const { payload } = await jwtVerify(sessionCookie, encodedKey, { - algorithms: ["HS256"], + const { payload } = await jwtVerify(jwt, encryptionKey, { + algorithms: [algorithm], }); + console.log("!!! session payload", payload); return payload; } catch (error) { console.error("Failed to decrypt session cookie", error); return null; } -} - -// 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, - }; -}; - -// 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); }; -export async function createSession(token: string) { - const expiresAt = newExpirationDate(); - const session = await encrypt({ token, expiresAt }); - cookies().set("session", session, { - httpOnly: true, - secure: true, - expires: expiresAt, - sameSite: "lax", - path: "/", - }); +export async function encrypt( + token: string, + expiresAt: Date, + secret: Uint8Array, +): Promise { + const jwt = await new SignJWT({ token }) + .setProtectedHeader({ alg: CLIENT_JWT_ENCRYPTION_ALGORITHM }) + .setIssuedAt() + .setExpirationTime(expiresAt || "") + .sign(secret); + return jwt; } - // currently unused, will be used in the future for logout export function deleteSession() { cookies().delete("session"); } + +export async function getSessionManager() { + if (!sessionManager) { + sessionManager = await SessionManager.createSessionManager(); + } + return sessionManager; +} + +// const decryptJwtWithKey = +// (encryptionKey: JWK) => +// async (jwt: string): Promise => { +// const payload = await decrypt(jwt, encryptionKey); +// if (!payload || !payload.token) return null; +// return payload as T; +// }; + +// const decryptWithKey = +// (encryptionKey: Uint8Array) => +// async (sessionCookie = ""): Promise => { +// try { +// const { payload } = await jwtVerify(sessionCookie, encryptionKey, { +// algorithms: ["HS256"], +// }); +// console.log("!!! session payload", payload); +// return payload; +// } catch (error) { +// console.error("Failed to decrypt session cookie", error); +// return null; +// } +// }; + +// export const decryptClientToken = decryptJwtWithKey( +// encodedClientSessionSecret, +// ); + +// export const decryptLoginGovToken = decryptJwtWithKey( +// encodedLoginGovSessionSecret, +// ); + +// // could try memoizing this function if it is a performance risk +// export const getTokenFromCookie = async ( +// cookie: string, +// ): Promise => { +// const decryptedSession = await decryptClientToken(cookie); +// if (!decryptedSession) return null; +// const token = (decryptedSession.token as string) ?? null; +// if (!token) return null; +// return { +// token, +// }; +// }; diff --git a/frontend/src/services/auth/types.tsx b/frontend/src/services/auth/types.tsx index 56eb3db76..aad524cd8 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,36 @@ * @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; + email?: string; + expiresAt: Date; } -export type UserSession = { - token: string; - expiresAt?: Date; -} | null; +// represents API JWT payload +export type UserSession = UserProfile & SimplerJwtPayload; -export type SessionPayload = { +// represents client JWT payload +export interface SimplerJwtPayload extends JWTPayload { token: string; - expiresAt: Date; -}; +} + +// export interface SessionPayload { +// token: string; +// expiresAt: Date; +// } /** * 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; }; diff --git a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts index 2093f4e06..300ce1aad 100644 --- a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts +++ b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts @@ -1,7 +1,7 @@ "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 @@ -14,6 +14,6 @@ export const userFetcher: UserFetcher = async (url) => { 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/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) { From b154c3deeb88f27bad18dc4063e9467417007adc Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Fri, 3 Jan 2025 14:42:35 -0500 Subject: [PATCH 03/16] working now using KeyObject --- frontend/src/services/auth/session.ts | 95 ++++++++++----------------- 1 file changed, 33 insertions(+), 62 deletions(-) diff --git a/frontend/src/services/auth/session.ts b/frontend/src/services/auth/session.ts index 3927f7b7a..69183bde8 100644 --- a/frontend/src/services/auth/session.ts +++ b/frontend/src/services/auth/session.ts @@ -1,6 +1,7 @@ import "server-only"; -import { exportJWK, JWK, JWTPayload, jwtVerify, SignJWT } from "jose"; +import { createPublicKey, KeyObject } from "crypto"; +import { JWTPayload, jwtVerify, SignJWT } from "jose"; import { environment } from "src/constants/environments"; import { SimplerJwtPayload, UserSession } from "src/services/auth/types"; import { encodeText } from "src/utils/generalUtils"; @@ -13,24 +14,26 @@ const API_JWT_ENCRYPTION_ALGORITHM = "RS256"; let sessionManager: SessionManager; +/* + Some session management operations require access to an encoded version of the + API's public key, which entails async operations. This class includes all functionality + that depends on that key in order to simplify some timing and organization issues. The + related operations using the client side key are included as well for ease of use. +*/ + class SessionManager { private clientSecret: Uint8Array; - private loginGovSecret: JWK; + private loginGovSecret: KeyObject; - private constructor(clientSecret: Uint8Array, loginGovSecret: JWK) { + private constructor(clientSecret: Uint8Array, loginGovSecret: KeyObject) { this.clientSecret = clientSecret; this.loginGovSecret = loginGovSecret; } static async createSessionManager() { - // const [clientJwk, apiJwk] = await Promise.all([ - // exportJWK(encodeText(environment.SESSION_SECRET)), - // exportJWK(encodeText(environment.API_JWT_PUBLIC_KEY)), - // ]); - // return new SessionManager(clientJwk, apiJwk); const clientSecret = encodeText(environment.SESSION_SECRET); - const apiJwk = await exportJWK(encodeText(environment.API_JWT_PUBLIC_KEY)); - return new SessionManager(clientSecret, apiJwk); + const apiKeyObject = await createPublicKey(environment.API_JWT_PUBLIC_KEY); + return new SessionManager(clientSecret, apiKeyObject); } async decryptClientToken(jwt: string): Promise { @@ -42,16 +45,17 @@ class SessionManager { if (!payload || !payload.token) return null; return payload as SimplerJwtPayload; } + async decryptLoginGovToken(jwt: string): Promise { const payload = await decrypt( jwt, this.loginGovSecret, API_JWT_ENCRYPTION_ALGORITHM, ); - if (!payload || !payload.token) return null; - return payload as UserSession; + return (payload as UserSession) ?? null; } + // sets client token on cookie async createSession(token: string) { const expiresAt = newExpirationDate(); const session = await encrypt(token, expiresAt, this.clientSecret); @@ -64,16 +68,24 @@ class SessionManager { }); } - async getSession() { + // returns the necessary user info from decrypted login gov token + // plus client token and expiration + async getSession(): Promise { const cookie = cookies().get("session")?.value; if (!cookie) return null; const payload = await this.decryptClientToken(cookie); if (!payload) { return null; } - const { token } = payload; + const { token, exp } = payload; const session = await this.decryptLoginGovToken(token); - return session || null; + return session + ? { + ...session, + token, + exp, + } + : null; } } @@ -81,17 +93,16 @@ class SessionManager { export const newExpirationDate = () => new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); -// extracts payload object from jwt string using passed encrytion key +// extracts payload object from jwt string using passed encrytion key and algo const decrypt = async ( jwt = "", - encryptionKey: JWK | Uint8Array, + encryptionKey: KeyObject | Uint8Array, algorithm: string, ): Promise => { try { const { payload } = await jwtVerify(jwt, encryptionKey, { algorithms: [algorithm], }); - console.log("!!! session payload", payload); return payload; } catch (error) { console.error("Failed to decrypt session cookie", error); @@ -99,6 +110,7 @@ const decrypt = async ( } }; +// we only encrypt using the client key export async function encrypt( token: string, expiresAt: Date, @@ -111,58 +123,17 @@ export async function encrypt( .sign(secret); return jwt; } + // currently unused, will be used in the future for logout export function deleteSession() { cookies().delete("session"); } +// this getter is necessary for dealing with the async operation of encoding +// the API JWT key export async function getSessionManager() { if (!sessionManager) { sessionManager = await SessionManager.createSessionManager(); } return sessionManager; } - -// const decryptJwtWithKey = -// (encryptionKey: JWK) => -// async (jwt: string): Promise => { -// const payload = await decrypt(jwt, encryptionKey); -// if (!payload || !payload.token) return null; -// return payload as T; -// }; - -// const decryptWithKey = -// (encryptionKey: Uint8Array) => -// async (sessionCookie = ""): Promise => { -// try { -// const { payload } = await jwtVerify(sessionCookie, encryptionKey, { -// algorithms: ["HS256"], -// }); -// console.log("!!! session payload", payload); -// return payload; -// } catch (error) { -// console.error("Failed to decrypt session cookie", error); -// return null; -// } -// }; - -// export const decryptClientToken = decryptJwtWithKey( -// encodedClientSessionSecret, -// ); - -// export const decryptLoginGovToken = decryptJwtWithKey( -// encodedLoginGovSessionSecret, -// ); - -// // could try memoizing this function if it is a performance risk -// export const getTokenFromCookie = async ( -// cookie: string, -// ): Promise => { -// const decryptedSession = await decryptClientToken(cookie); -// if (!decryptedSession) return null; -// const token = (decryptedSession.token as string) ?? null; -// if (!token) return null; -// return { -// token, -// }; -// }; From 03921ff93dbb4faabd9328dfcc058b5d28c59b89 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Fri, 3 Jan 2025 15:55:17 -0500 Subject: [PATCH 04/16] reorg, fixups and wip on tests --- frontend/src/app/api/auth/callback/route.ts | 6 +- frontend/src/app/api/auth/logout/route.ts | 4 +- frontend/src/app/api/auth/session/route.ts | 3 +- frontend/src/services/auth/UserProvider.tsx | 1 - frontend/src/services/auth/session.ts | 98 ++++--------- frontend/src/services/auth/sessionUtils.ts | 46 +++++++ frontend/src/services/auth/types.tsx | 6 +- frontend/tests/services/auth/session.test.ts | 130 +++--------------- .../tests/services/auth/sessionUtils.test.ts | 125 +++++++++++++++++ 9 files changed, 224 insertions(+), 195 deletions(-) create mode 100644 frontend/src/services/auth/sessionUtils.ts create mode 100644 frontend/tests/services/auth/sessionUtils.test.ts diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index 217d06c01..c637e222a 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -1,4 +1,4 @@ -import { getSessionManager } from "src/services/auth/session"; +import { sessionManager } from "src/services/auth/session"; import { redirect } from "next/navigation"; import { NextRequest } from "next/server"; @@ -8,8 +8,7 @@ const createSessionAndSetStatus = async ( successStatus: string, ): Promise => { try { - const sessionManager = await getSessionManager(); - sessionManager.createSession(token); + await sessionManager.createSession(token); return successStatus; } catch (error) { console.error("error in creating session", error); @@ -32,7 +31,6 @@ const createSessionAndSetStatus = async ( - ... */ export async function GET(request: NextRequest) { - const sessionManager = await getSessionManager(); const currentSession = await sessionManager.getSession(); if (currentSession && currentSession.token) { const status = await createSessionAndSetStatus( diff --git a/frontend/src/app/api/auth/logout/route.ts b/frontend/src/app/api/auth/logout/route.ts index 6aca304c0..8cfe8124c 100644 --- a/frontend/src/app/api/auth/logout/route.ts +++ b/frontend/src/app/api/auth/logout/route.ts @@ -1,10 +1,10 @@ -import { deleteSession, getSessionManager } from "src/services/auth/session"; +import { sessionManager } 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 sessionManager = await getSessionManager(); const session = await sessionManager.getSession(); if (!session || !session.token) { throw new Error("No active session to logout"); diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts index c5b42bdb4..35945c1fa 100644 --- a/frontend/src/app/api/auth/session/route.ts +++ b/frontend/src/app/api/auth/session/route.ts @@ -1,9 +1,8 @@ -import { getSessionManager } from "src/services/auth/session"; +import { sessionManager } from "src/services/auth/session"; import { NextResponse } from "next/server"; export async function GET() { - const sessionManager = await getSessionManager(); const currentSession = await sessionManager.getSession(); if (currentSession) { return NextResponse.json({ diff --git a/frontend/src/services/auth/UserProvider.tsx b/frontend/src/services/auth/UserProvider.tsx index 6d6c68d23..145ebbe96 100644 --- a/frontend/src/services/auth/UserProvider.tsx +++ b/frontend/src/services/auth/UserProvider.tsx @@ -34,7 +34,6 @@ export default function UserProvider({ setIsLoading(true); const fetchedUser = await debouncedUserFetcher(); if (fetchedUser) { - console.log("!!! USER", fetchedUser); setLocalUser(fetchedUser); setUserFetchError(undefined); setIsLoading(false); diff --git a/frontend/src/services/auth/session.ts b/frontend/src/services/auth/session.ts index 69183bde8..57d313439 100644 --- a/frontend/src/services/auth/session.ts +++ b/frontend/src/services/auth/session.ts @@ -1,55 +1,50 @@ import "server-only"; import { createPublicKey, KeyObject } from "crypto"; -import { JWTPayload, jwtVerify, SignJWT } from "jose"; import { environment } from "src/constants/environments"; +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 CLIENT_JWT_ENCRYPTION_ALGORITHM = "HS256"; -const API_JWT_ENCRYPTION_ALGORITHM = "RS256"; - -let sessionManager: SessionManager; - /* - Some session management operations require access to an encoded version of the - API's public key, which entails async operations. This class includes all functionality - that depends on that key in order to simplify some timing and organization issues. The - related operations using the client side key are included as well for ease of use. + All operations that need access to the client and API side session keys are handled + in this class */ -class SessionManager { - private clientSecret: Uint8Array; - private loginGovSecret: KeyObject; - - private constructor(clientSecret: Uint8Array, loginGovSecret: KeyObject) { - this.clientSecret = clientSecret; - this.loginGovSecret = loginGovSecret; - } +export class SessionManager { + private clientJwtKey: Uint8Array; + private loginGovJwtKey: KeyObject; - static async createSessionManager() { - const clientSecret = encodeText(environment.SESSION_SECRET); - const apiKeyObject = await createPublicKey(environment.API_JWT_PUBLIC_KEY); - return new SessionManager(clientSecret, apiKeyObject); + constructor() { + this.clientJwtKey = encodeText(environment.SESSION_SECRET); + this.loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); } - async decryptClientToken(jwt: string): Promise { + private async decryptClientToken( + jwt: string, + ): Promise { const payload = await decrypt( jwt, - this.clientSecret, + this.clientJwtKey, CLIENT_JWT_ENCRYPTION_ALGORITHM, ); if (!payload || !payload.token) return null; return payload as SimplerJwtPayload; } - async decryptLoginGovToken(jwt: string): Promise { + private async decryptLoginGovToken(jwt: string): Promise { const payload = await decrypt( jwt, - this.loginGovSecret, + this.loginGovJwtKey, API_JWT_ENCRYPTION_ALGORITHM, ); return (payload as UserSession) ?? null; @@ -58,7 +53,7 @@ class SessionManager { // sets client token on cookie async createSession(token: string) { const expiresAt = newExpirationDate(); - const session = await encrypt(token, expiresAt, this.clientSecret); + const session = await encrypt(token, expiresAt, this.clientJwtKey); cookies().set("session", session, { httpOnly: true, secure: true, @@ -89,51 +84,4 @@ class SessionManager { } } -// 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 -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 async function encrypt( - token: string, - expiresAt: Date, - secret: Uint8Array, -): Promise { - const jwt = await new SignJWT({ token }) - .setProtectedHeader({ alg: CLIENT_JWT_ENCRYPTION_ALGORITHM }) - .setIssuedAt() - .setExpirationTime(expiresAt || "") - .sign(secret); - return jwt; -} - -// currently unused, will be used in the future for logout -export function deleteSession() { - cookies().delete("session"); -} - -// this getter is necessary for dealing with the async operation of encoding -// the API JWT key -export async function getSessionManager() { - if (!sessionManager) { - sessionManager = await SessionManager.createSessionManager(); - } - return sessionManager; -} +export const sessionManager = new SessionManager(); diff --git a/frontend/src/services/auth/sessionUtils.ts b/frontend/src/services/auth/sessionUtils.ts new file mode 100644 index 000000000..c88f149fc --- /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 async function encrypt( + 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 aad524cd8..af31d1e1c 100644 --- a/frontend/src/services/auth/types.tsx +++ b/frontend/src/services/auth/types.tsx @@ -38,16 +38,16 @@ import { JWTPayload } from "jose"; // represents relevant client side data from API JWT export interface UserProfile { email?: string; + token: string; expiresAt: Date; } -// represents API JWT payload -export type UserSession = UserProfile & SimplerJwtPayload; - // represents client JWT payload export interface SimplerJwtPayload extends JWTPayload { token: string; } +// represents API JWT payload +export type UserSession = UserProfile & SimplerJwtPayload; // export interface SessionPayload { // token: string; diff --git a/frontend/tests/services/auth/session.test.ts b/frontend/tests/services/auth/session.test.ts index ba2889d4d..4c7fb9716 100644 --- a/frontend/tests/services/auth/session.test.ts +++ b/frontend/tests/services/auth/session.test.ts @@ -1,38 +1,12 @@ -import { - createSession, - decrypt, - deleteSession, - encrypt, - getSession, - getTokenFromCookie, -} from "src/services/auth/session"; - -type RecursiveObject = { - [key: string]: () => RecursiveObject | string; -}; +import { SessionManager } from "src/services/auth/session"; 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(); -// 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 encodeTextMock = jest.fn((arg) => arg); +const createPublicKeyMock = jest.fn((arg) => arg); const cookiesMock = () => { return { @@ -46,92 +20,41 @@ 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(), - }; - }, -})); - 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"); +jest.mock("crypto", () => ({ + createPublicKey: (arg: string): string => createPublicKeyMock(arg), +})); - // this is synthetic but generally proves things are working - expect(encrypted).toEqual(token); +describe("SessionManager", () => { + let manager: SessionManager; + beforeEach(() => { + manager = new SessionManager(); }); -}); - -describe("decrypt", () => { 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"], - }); - - expect(decrypted).toEqual([ - cookie, - "session secret", - { algorithms: ["HS256"] }, - ]); + it("constructs correctly with necessary key values", () => { + expect(encodeTextMock).toHaveBeenCalledWith("session secret"); + expect(createPublicKeyMock).toHaveBeenCalledWith("api secret"); + expect(manager).toBeInstanceOf(SessionManager); }); - it("returns null on error", async () => { - jwtVerifyMock.mockImplementation(() => { - throw new Error(); - }); - const cookie = "fakeCookie"; - const decrypted = await decrypt(cookie); - expect(decrypted).toEqual(null); + describe("getSession", () => {\ + it('calls decrypt with the correct arguments and returns successfully', () => { + + }) + it('returns null if client token decrypt does not return a payload and token', () => {}) + it('returns null if api token decrypt does not return a payload', () => {}) }); -}); -describe("createSession", () => { - afterEach(() => jest.clearAllMocks()); it("calls cookie.set with expected values", async () => { await createSession("nothingSpecial"); expect(setCookiesMock).toHaveBeenCalledTimes(1); @@ -145,15 +68,6 @@ describe("createSession", () => { }); }); -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 () => { 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); + }); +}); From 89dc19312f07e822db46d89af3c947e83149d77f Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Fri, 3 Jan 2025 16:11:41 -0500 Subject: [PATCH 05/16] more wip tests --- frontend/src/services/auth/session.ts | 4 +- frontend/tests/services/auth/session.test.ts | 123 ++++++++++++------- 2 files changed, 80 insertions(+), 47 deletions(-) diff --git a/frontend/src/services/auth/session.ts b/frontend/src/services/auth/session.ts index 57d313439..d9b20841c 100644 --- a/frontend/src/services/auth/session.ts +++ b/frontend/src/services/auth/session.ts @@ -84,4 +84,6 @@ export class SessionManager { } } -export const sessionManager = new SessionManager(); +// instantiating here is messing with testing. Gotta put it somewhere else or be ok with instantiating for each use + +// export const sessionManager = new SessionManager(); diff --git a/frontend/tests/services/auth/session.test.ts b/frontend/tests/services/auth/session.test.ts index 4c7fb9716..0736af66f 100644 --- a/frontend/tests/services/auth/session.test.ts +++ b/frontend/tests/services/auth/session.test.ts @@ -1,13 +1,17 @@ import { SessionManager } from "src/services/auth/session"; -const getCookiesMock = jest.fn(); +const getCookiesMock = jest.fn(() => ({ + value: "some cookie value", +})); const setCookiesMock = jest.fn(); const deleteCookiesMock = jest.fn(); -const jwtVerifyMock = jest.fn(); const encodeTextMock = jest.fn((arg) => arg); const createPublicKeyMock = jest.fn((arg) => arg); +const decryptMock = jest.fn(); +const encryptMock = jest.fn(); + const cookiesMock = () => { return { get: getCookiesMock, @@ -16,6 +20,13 @@ const cookiesMock = () => { }; }; +jest.mock("src/services/auth/sessionUtils", () => ({ + decrypt: (...args) => decryptMock(args), + encrypt: (...args) => encryptMock(args), + CLIENT_JWT_ENCRYPTION_ALGORITHM: "algo one", + API_JWT_ENCRYPTION_ALGORITHM: "algo two", +})); + jest.mock("next/headers", () => ({ cookies: () => cookiesMock(), })); @@ -47,54 +58,74 @@ describe("SessionManager", () => { expect(manager).toBeInstanceOf(SessionManager); }); - describe("getSession", () => {\ - it('calls decrypt with the correct arguments and returns successfully', () => { - - }) - it('returns null if client token decrypt does not return a payload and token', () => {}) - it('returns null if api token decrypt does not return a payload', () => {}) - }); - - it("calls cookie.set with expected values", async () => { - await createSession("nothingSpecial"); - expect(setCookiesMock).toHaveBeenCalledTimes(1); - expect(setCookiesMock).toHaveBeenCalledWith("session", "nothingSpecial", { - httpOnly: true, - secure: true, - expires: expect.any(Date) as Date, - sameSite: "lax", - path: "/", + describe("getSession", () => { + it("calls decrypt with the correct arguments and returns successfully", async () => { + decryptMock.mockReturnValue({ + token: "some decrypted token", + exp: 123, + }); + const session = await manager.getSession(); + expect(decryptMock).toHaveBeenCalledTimes(2); + expect(decryptMock).toHaveBeenCalledWith([ + "some cookie value", + "session secret", + "algo one", + ]); + expect(decryptMock).toHaveBeenCalledWith([ + "some decrypted token", + "api secret", + "algo two", + ]); + expect(session).toEqual({ + token: "some decrypted token", + exp: 123, + }); }); + it("returns null if client token decrypt does not return a payload and token", () => {}); + it("returns null if api token decrypt does not return a payload", () => {}); }); }); -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("calls cookie.set with expected values", async () => { +// await createSession("nothingSpecial"); +// expect(setCookiesMock).toHaveBeenCalledTimes(1); +// expect(setCookiesMock).toHaveBeenCalledWith("session", "nothingSpecial", { +// httpOnly: true, +// secure: true, +// expires: expect.any(Date) as Date, +// sameSite: "lax", +// path: "/", +// }); +// }); +// }); - it("returns null if decrypt returns session with no token", async () => { - jwtVerifyMock.mockImplementation(() => ({})); - const result = await getTokenFromCookie("invalidEncryptedCookie"); - expect(result).toEqual(null); - }); +// 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 token returned from decryp", async () => { - jwtVerifyMock.mockImplementation((token: string) => ({ - payload: { token }, - })); - const result = await getTokenFromCookie("invalidEncryptedCookie"); - expect(result).toEqual({ token: "invalidEncryptedCookie" }); - }); -}); +// it("returns null if decrypt returns session with no token", async () => { +// jwtVerifyMock.mockImplementation(() => ({})); +// const result = await getTokenFromCookie("invalidEncryptedCookie"); +// expect(result).toEqual(null); +// }); -describe("getSession", () => { - afterEach(() => jest.clearAllMocks()); - it("returns null if there is no session cookie", async () => { - const result = await getSession(); - 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); +// }); +// }); From f648391b8751ed957c6a73788f8f26e585f00e24 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Mon, 6 Jan 2025 08:52:19 -0500 Subject: [PATCH 06/16] wip get rid of the class --- frontend/src/app/api/auth/callback/route.ts | 9 +- frontend/src/app/api/auth/logout/route.ts | 6 +- frontend/src/app/api/auth/session/route.ts | 4 +- frontend/src/services/auth/SessionManager.ts | 89 ++++++++++++++ frontend/src/services/auth/session.ts | 123 +++++++++---------- 5 files changed, 154 insertions(+), 77 deletions(-) create mode 100644 frontend/src/services/auth/SessionManager.ts diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index c637e222a..d874ed17d 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -1,4 +1,4 @@ -import { sessionManager } from "src/services/auth/session"; +import { createSession, getSession } from "src/services/auth/session"; import { redirect } from "next/navigation"; import { NextRequest } from "next/server"; @@ -8,7 +8,7 @@ const createSessionAndSetStatus = async ( successStatus: string, ): Promise => { try { - await sessionManager.createSession(token); + await createSession(token); return successStatus; } catch (error) { console.error("error in creating session", error); @@ -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 @@ -31,7 +30,7 @@ const createSessionAndSetStatus = async ( - ... */ export async function GET(request: NextRequest) { - const currentSession = await sessionManager.getSession(); + const currentSession = await getSession(); if (currentSession && currentSession.token) { const status = await createSessionAndSetStatus( currentSession.token, diff --git a/frontend/src/app/api/auth/logout/route.ts b/frontend/src/app/api/auth/logout/route.ts index 8cfe8124c..a8e1ea9f3 100644 --- a/frontend/src/app/api/auth/logout/route.ts +++ b/frontend/src/app/api/auth/logout/route.ts @@ -1,11 +1,11 @@ -import { sessionManager } 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"; +// logout on API via /v1/users/token/logout export async function POST() { try { - // logout on API via /v1/users/token/logout - const session = await sessionManager.getSession(); + const session = await getSession(); if (!session || !session.token) { throw new Error("No active session to logout"); } diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts index 35945c1fa..668eae8bb 100644 --- a/frontend/src/app/api/auth/session/route.ts +++ b/frontend/src/app/api/auth/session/route.ts @@ -1,9 +1,9 @@ -import { sessionManager } from "src/services/auth/session"; +import { getSession } from "src/services/auth/session"; import { NextResponse } from "next/server"; export async function GET() { - const currentSession = await sessionManager.getSession(); + const currentSession = await getSession(); if (currentSession) { return NextResponse.json({ token: currentSession.token, diff --git a/frontend/src/services/auth/SessionManager.ts b/frontend/src/services/auth/SessionManager.ts new file mode 100644 index 000000000..c66596099 --- /dev/null +++ b/frontend/src/services/auth/SessionManager.ts @@ -0,0 +1,89 @@ +import "server-only"; + +import { createPublicKey, KeyObject } from "crypto"; +import { environment } from "src/constants/environments"; +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"; + +/* + All operations that need access to the client and API side session keys are handled + in this class +*/ + +export class SessionManager { + private clientJwtKey: Uint8Array; + private loginGovJwtKey: KeyObject; + + constructor() { + this.clientJwtKey = encodeText(environment.SESSION_SECRET); + this.loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); + } + + private async decryptClientToken( + jwt: string, + ): Promise { + const payload = await decrypt( + jwt, + this.clientJwtKey, + CLIENT_JWT_ENCRYPTION_ALGORITHM, + ); + if (!payload || !payload.token) return null; + return payload as SimplerJwtPayload; + } + + private async decryptLoginGovToken(jwt: string): Promise { + const payload = await decrypt( + jwt, + this.loginGovJwtKey, + API_JWT_ENCRYPTION_ALGORITHM, + ); + return (payload as UserSession) ?? null; + } + + // sets client token on cookie + async createSession(token: string) { + const expiresAt = newExpirationDate(); + const session = await encrypt(token, expiresAt, this.clientJwtKey); + cookies().set("session", session, { + httpOnly: true, + secure: true, + expires: expiresAt, + sameSite: "lax", + path: "/", + }); + } + + // returns the necessary user info from decrypted login gov token + // plus client token and expiration + async getSession(): Promise { + const cookie = cookies().get("session")?.value; + if (!cookie) return null; + const payload = await this.decryptClientToken(cookie); + if (!payload) { + return null; + } + const { token, exp } = payload; + const session = await this.decryptLoginGovToken(token); + return session + ? { + ...session, + token, + exp, + } + : null; + } +} + +// instantiating here is messing with testing. Gotta put it somewhere else or be ok with instantiating for each use + +export const sessionManager = new SessionManager(); diff --git a/frontend/src/services/auth/session.ts b/frontend/src/services/auth/session.ts index d9b20841c..a0d3d1e1b 100644 --- a/frontend/src/services/auth/session.ts +++ b/frontend/src/services/auth/session.ts @@ -1,6 +1,6 @@ import "server-only"; -import { createPublicKey, KeyObject } from "crypto"; +import { createPublicKey } from "crypto"; import { environment } from "src/constants/environments"; import { API_JWT_ENCRYPTION_ALGORITHM, @@ -15,75 +15,64 @@ import { encodeText } from "src/utils/generalUtils"; // note that cookies will be async in Next 15 import { cookies } from "next/headers"; -/* - All operations that need access to the client and API side session keys are handled - in this class -*/ +// creates a Uint8Array +const clientJwtKey = encodeText(environment.SESSION_SECRET); -export class SessionManager { - private clientJwtKey: Uint8Array; - private loginGovJwtKey: KeyObject; +// creates a KeyObject +const loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); - constructor() { - this.clientJwtKey = encodeText(environment.SESSION_SECRET); - this.loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); - } +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; +}; - private async decryptClientToken( - jwt: string, - ): Promise { - const payload = await decrypt( - jwt, - this.clientJwtKey, - CLIENT_JWT_ENCRYPTION_ALGORITHM, - ); - if (!payload || !payload.token) return null; - return payload as SimplerJwtPayload; - } +const decryptLoginGovToken = async ( + jwt: string, +): Promise => { + const payload = await decrypt( + jwt, + loginGovJwtKey, + API_JWT_ENCRYPTION_ALGORITHM, + ); + return (payload as UserSession) ?? null; +}; - private async decryptLoginGovToken(jwt: string): Promise { - const payload = await decrypt( - jwt, - this.loginGovJwtKey, - API_JWT_ENCRYPTION_ALGORITHM, - ); - return (payload as UserSession) ?? null; - } +// sets client token on cookie +export const createSession = async (token: string) => { + const expiresAt = newExpirationDate(); + const session = await encrypt(token, expiresAt, clientJwtKey); + cookies().set("session", session, { + httpOnly: true, + secure: true, + expires: expiresAt, + sameSite: "lax", + path: "/", + }); +}; - // sets client token on cookie - async createSession(token: string) { - const expiresAt = newExpirationDate(); - const session = await encrypt(token, expiresAt, this.clientJwtKey); - cookies().set("session", session, { - httpOnly: true, - secure: true, - expires: expiresAt, - sameSite: "lax", - path: "/", - }); +// returns the necessary user info from decrypted login gov token +// plus client token and expiration +export const getSession = async (): Promise => { + const cookie = cookies().get("session")?.value; + if (!cookie) return null; + const payload = await decryptClientToken(cookie); + if (!payload) { + return null; } - - // returns the necessary user info from decrypted login gov token - // plus client token and expiration - async getSession(): Promise { - const cookie = cookies().get("session")?.value; - if (!cookie) return null; - const payload = await this.decryptClientToken(cookie); - if (!payload) { - return null; - } - const { token, exp } = payload; - const session = await this.decryptLoginGovToken(token); - return session - ? { - ...session, - token, - exp, - } - : null; - } -} - -// instantiating here is messing with testing. Gotta put it somewhere else or be ok with instantiating for each use - -// export const sessionManager = new SessionManager(); + const { token, exp } = payload; + const session = await decryptLoginGovToken(token); + return session + ? { + ...session, + token, + exp, + } + : null; +}; From 39642aaa8f872a05bafc0dd84892997d7aac5cad Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Mon, 6 Jan 2025 10:38:16 -0500 Subject: [PATCH 07/16] class dismissed, tests working --- .../dev/feature-flags/FeatureFlagsTable.tsx | 4 +- frontend/src/app/api/auth/session/route.ts | 4 +- frontend/src/services/auth/session.ts | 21 ++- frontend/src/services/auth/sessionUtils.ts | 6 +- frontend/tests/api/auth/logout/route.test.ts | 3 + frontend/tests/services/auth/session.test.ts | 152 +++++++++--------- 6 files changed, 97 insertions(+), 93 deletions(-) diff --git a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx index c800fe9f1..f22715892 100644 --- a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx +++ b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx @@ -31,9 +31,7 @@ export default function FeatureFlagsTable() { return ( <> -

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

+

{user?.email ? `Logged in as: ${user.email}` : "Not logged in"}

diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts index 668eae8bb..26381e400 100644 --- a/frontend/src/app/api/auth/session/route.ts +++ b/frontend/src/app/api/auth/session/route.ts @@ -5,9 +5,7 @@ import { NextResponse } from "next/server"; 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/services/auth/session.ts b/frontend/src/services/auth/session.ts index a0d3d1e1b..214f0960d 100644 --- a/frontend/src/services/auth/session.ts +++ b/frontend/src/services/auth/session.ts @@ -1,6 +1,6 @@ import "server-only"; -import { createPublicKey } from "crypto"; +import { createPublicKey, KeyObject } from "crypto"; import { environment } from "src/constants/environments"; import { API_JWT_ENCRYPTION_ALGORITHM, @@ -15,11 +15,16 @@ import { encodeText } from "src/utils/generalUtils"; // note that cookies will be async in Next 15 import { cookies } from "next/headers"; -// creates a Uint8Array -const clientJwtKey = encodeText(environment.SESSION_SECRET); +let clientJwtKey: Uint8Array; +let loginGovJwtKey: KeyObject; -// creates a KeyObject -const loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); +// isolate encoding behavior from file execution +const initializeSessionSecrets = () => { + // eslint-disable-next-line + console.debug("Initializing Session Secrets"); + clientJwtKey = encodeText(environment.SESSION_SECRET); + loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); +}; const decryptClientToken = async ( jwt: string, @@ -46,6 +51,9 @@ const decryptLoginGovToken = async ( // sets client token on cookie export const createSession = async (token: string) => { + if (!clientJwtKey) { + initializeSessionSecrets(); + } const expiresAt = newExpirationDate(); const session = await encrypt(token, expiresAt, clientJwtKey); cookies().set("session", session, { @@ -60,6 +68,9 @@ export const createSession = async (token: string) => { // 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); diff --git a/frontend/src/services/auth/sessionUtils.ts b/frontend/src/services/auth/sessionUtils.ts index c88f149fc..d075dd55c 100644 --- a/frontend/src/services/auth/sessionUtils.ts +++ b/frontend/src/services/auth/sessionUtils.ts @@ -28,18 +28,18 @@ export const decrypt = async ( }; // we only encrypt using the client key -export async function encrypt( +export const encrypt = async ( token: string, expiresAt: Date, clientJwtKey: Uint8Array, -): Promise { +): 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/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 0736af66f..f5f585dba 100644 --- a/frontend/tests/services/auth/session.test.ts +++ b/frontend/tests/services/auth/session.test.ts @@ -1,4 +1,4 @@ -import { SessionManager } from "src/services/auth/session"; +import { createSession, getSession } from "src/services/auth/session"; const getCookiesMock = jest.fn(() => ({ value: "some cookie value", @@ -6,8 +6,8 @@ const getCookiesMock = jest.fn(() => ({ const setCookiesMock = jest.fn(); const deleteCookiesMock = jest.fn(); -const encodeTextMock = jest.fn((arg) => arg); -const createPublicKeyMock = jest.fn((arg) => arg); +const encodeTextMock = jest.fn((arg: string): string => arg); +const createPublicKeyMock = jest.fn((arg: string): string => arg); const decryptMock = jest.fn(); const encryptMock = jest.fn(); @@ -21,10 +21,11 @@ const cookiesMock = () => { }; jest.mock("src/services/auth/sessionUtils", () => ({ - decrypt: (...args) => decryptMock(args), - encrypt: (...args) => encryptMock(args), + 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("next/headers", () => ({ @@ -46,86 +47,79 @@ jest.mock("crypto", () => ({ createPublicKey: (arg: string): string => createPublicKeyMock(arg), })); -describe("SessionManager", () => { - let manager: SessionManager; - beforeEach(() => { - manager = new SessionManager(); - }); +describe("getSession", () => { afterEach(() => jest.clearAllMocks()); - it("constructs correctly with necessary key values", () => { + it("initializes session secrets if necessary", async () => { + await getSession(); expect(encodeTextMock).toHaveBeenCalledWith("session secret"); expect(createPublicKeyMock).toHaveBeenCalledWith("api secret"); - expect(manager).toBeInstanceOf(SessionManager); }); - - describe("getSession", () => { - it("calls decrypt with the correct arguments and returns successfully", async () => { - decryptMock.mockReturnValue({ - token: "some decrypted token", - exp: 123, - }); - const session = await manager.getSession(); - expect(decryptMock).toHaveBeenCalledTimes(2); - expect(decryptMock).toHaveBeenCalledWith([ - "some cookie value", - "session secret", - "algo one", - ]); - expect(decryptMock).toHaveBeenCalledWith([ - "some decrypted token", - "api secret", - "algo two", - ]); - expect(session).toEqual({ + it("calls decrypt with the correct arguments and returns successfully", async () => { + decryptMock.mockReturnValue({ + token: "some decrypted token", + exp: 123, + }); + const session = await getSession(); + expect(decryptMock).toHaveBeenCalledTimes(2); + expect(decryptMock).toHaveBeenCalledWith([ + "some cookie value", + "session secret", + "algo one", + ]); + expect(decryptMock).toHaveBeenCalledWith([ + "some decrypted token", + "api secret", + "algo two", + ]); + expect(session).toEqual({ + token: "some decrypted token", + exp: 123, + }); + }); + 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, - }); - }); - it("returns null if client token decrypt does not return a payload and token", () => {}); - it("returns null if api token decrypt does not return a payload", () => {}); + }) + .mockReturnValueOnce(null); + const session = await getSession(); + expect(session).toEqual(null); }); }); -// it("calls cookie.set with expected values", async () => { -// await createSession("nothingSpecial"); -// expect(setCookiesMock).toHaveBeenCalledTimes(1); -// expect(setCookiesMock).toHaveBeenCalledWith("session", "nothingSpecial", { -// httpOnly: true, -// secure: true, -// expires: expect.any(Date) as Date, -// sameSite: "lax", -// path: "/", -// }); -// }); -// }); - -// 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); -// }); -// }); +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", + "encrypted session", + { + httpOnly: true, + secure: true, + expires: new Date(0), + sameSite: "lax", + path: "/", + }, + ); + }); +}); From c6b5dc5c457ab3e5c0e01d50036d35793f9ff649 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Mon, 6 Jan 2025 12:40:08 -0500 Subject: [PATCH 08/16] wip header component state --- frontend/src/app/[locale]/dev/layout.tsx | 7 -- .../src/app/[locale]/process/ProcessNext.tsx | 2 +- frontend/src/app/api/auth/logout/route.ts | 2 +- .../src/components/ContentDisplayToggle.tsx | 9 ++- frontend/src/components/Footer.tsx | 2 +- frontend/src/components/Header.tsx | 71 +++++++++++++++---- frontend/src/components/Layout.tsx | 26 ++++--- frontend/src/components/USWDSIcon.tsx | 6 +- .../components/content/IndexGoalContent.tsx | 2 +- .../content/ProcessAndResearchContent.tsx | 4 +- .../components/opportunity/OpportunityCTA.tsx | 5 +- .../opportunity/OpportunityDownload.tsx | 5 +- frontend/src/i18n/messages/en/index.ts | 1 + 13 files changed, 91 insertions(+), 51 deletions(-) delete mode 100644 frontend/src/app/[locale]/dev/layout.tsx 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/logout/route.ts b/frontend/src/app/api/auth/logout/route.ts index a8e1ea9f3..ec6247aa3 100644 --- a/frontend/src/app/api/auth/logout/route.ts +++ b/frontend/src/app/api/auth/logout/route.ts @@ -2,13 +2,13 @@ import { getSession } from "src/services/auth/session"; import { deleteSession } from "src/services/auth/sessionUtils"; import { postLogout } from "src/services/fetch/fetchers/userFetcher"; -// logout on API via /v1/users/token/logout export async function POST() { try { 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/components/ContentDisplayToggle.tsx b/frontend/src/components/ContentDisplayToggle.tsx index 360efcce9..47caf246d 100644 --- a/frontend/src/components/ContentDisplayToggle.tsx +++ b/frontend/src/components/ContentDisplayToggle.tsx @@ -14,6 +14,7 @@ type ContentDisplayToggleTypes = "default" | "centered"; * * Toggles display of child content * + * @param {string} hideCallToAction - text to show when content is expanded, falls back to showCallToAction if not passed * @param {string} breakpoint - used to: * - add a class to toggled content to always display it at viewport sizes above the specified breakpoint * - add a class to toggled button to hide it at viewport sizes above the specified breakpoint @@ -28,7 +29,7 @@ export default function ContentDisplayToggle({ type = "default", children, }: { - hideCallToAction: string; + hideCallToAction?: string; showCallToAction: string; breakpoint?: Breakpoints; showContentByDefault?: boolean; @@ -70,9 +71,11 @@ export default function ContentDisplayToggle({ aria-pressed={toggledContentVisible} className="usa-button usa-button--unstyled text-no-underline" > - + - {toggledContentVisible ? hideCallToAction : showCallToAction} + {toggledContentVisible + ? hideCallToAction || showCallToAction + : showCallToAction} 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) => ( { }, []); return ( - - - {navLoginLinkText} - + + ); +}; + +const UserDropdown = ({ + user, + navLogoutLinkText, +}: { + user: UserProfile; + navLogoutLinkText: string; +}) => { + const logout = useCallback(async (): Promise => { + await fetch("/api/auth/logout", { + method: "POST", + }); + }, []); + return ( +
+ + + +
logout()} + > + {navLogoutLinkText} +
+
+
); }; @@ -174,6 +208,9 @@ const Header = ({ logoPath, locale }: Props) => { const { checkFeatureFlag } = useFeatureFlags(); const showLoginLink = checkFeatureFlag("authOn"); + const { user } = useUser(); + console.log("!!! user", user); + const language = locale && locale.match("/^es/") ? "spanish" : "english"; const handleMobileNavToggle = () => { @@ -224,9 +261,15 @@ const Header = ({ logoPath, locale }: Props) => { {!!showLoginLink && (
-
+ {!user?.token && ( -
+ )} + {!!user?.token && ( + + )}
)} - - {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) => {
Date: Mon, 6 Jan 2025 14:28:29 -0500 Subject: [PATCH 09/16] fix caching issue / race condition --- .../dev/feature-flags/FeatureFlagsTable.tsx | 19 ---- frontend/src/app/api/auth/callback/route.ts | 1 + frontend/src/app/api/auth/session/route.ts | 3 + frontend/src/services/auth/SessionManager.ts | 89 ------------------- frontend/src/services/auth/UserProvider.tsx | 2 + .../fetch/fetchers/clientUserFetcher.ts | 2 +- 6 files changed, 7 insertions(+), 109 deletions(-) delete mode 100644 frontend/src/services/auth/SessionManager.ts diff --git a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx index f22715892..eef88cbdc 100644 --- a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx +++ b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx @@ -1,37 +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?.email ? `Logged in as: ${user.email}` : "Not logged in"}

diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index d874ed17d..a3e06ff5c 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -9,6 +9,7 @@ const createSessionAndSetStatus = async ( ): Promise => { try { await createSession(token); + console.log("~~~ session set"); return successStatus; } catch (error) { console.error("error in creating session", error); diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts index 26381e400..97867a1dc 100644 --- a/frontend/src/app/api/auth/session/route.ts +++ b/frontend/src/app/api/auth/session/route.ts @@ -2,8 +2,11 @@ import { getSession } from "src/services/auth/session"; import { NextResponse } from "next/server"; +export const revalidate = 0; + export async function GET() { const currentSession = await getSession(); + console.log("~~~ session got", currentSession); if (currentSession) { return NextResponse.json(currentSession); } else { diff --git a/frontend/src/services/auth/SessionManager.ts b/frontend/src/services/auth/SessionManager.ts deleted file mode 100644 index c66596099..000000000 --- a/frontend/src/services/auth/SessionManager.ts +++ /dev/null @@ -1,89 +0,0 @@ -import "server-only"; - -import { createPublicKey, KeyObject } from "crypto"; -import { environment } from "src/constants/environments"; -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"; - -/* - All operations that need access to the client and API side session keys are handled - in this class -*/ - -export class SessionManager { - private clientJwtKey: Uint8Array; - private loginGovJwtKey: KeyObject; - - constructor() { - this.clientJwtKey = encodeText(environment.SESSION_SECRET); - this.loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); - } - - private async decryptClientToken( - jwt: string, - ): Promise { - const payload = await decrypt( - jwt, - this.clientJwtKey, - CLIENT_JWT_ENCRYPTION_ALGORITHM, - ); - if (!payload || !payload.token) return null; - return payload as SimplerJwtPayload; - } - - private async decryptLoginGovToken(jwt: string): Promise { - const payload = await decrypt( - jwt, - this.loginGovJwtKey, - API_JWT_ENCRYPTION_ALGORITHM, - ); - return (payload as UserSession) ?? null; - } - - // sets client token on cookie - async createSession(token: string) { - const expiresAt = newExpirationDate(); - const session = await encrypt(token, expiresAt, this.clientJwtKey); - cookies().set("session", session, { - httpOnly: true, - secure: true, - expires: expiresAt, - sameSite: "lax", - path: "/", - }); - } - - // returns the necessary user info from decrypted login gov token - // plus client token and expiration - async getSession(): Promise { - const cookie = cookies().get("session")?.value; - if (!cookie) return null; - const payload = await this.decryptClientToken(cookie); - if (!payload) { - return null; - } - const { token, exp } = payload; - const session = await this.decryptLoginGovToken(token); - return session - ? { - ...session, - token, - exp, - } - : null; - } -} - -// instantiating here is messing with testing. Gotta put it somewhere else or be ok with instantiating for each use - -export const sessionManager = new SessionManager(); diff --git a/frontend/src/services/auth/UserProvider.tsx b/frontend/src/services/auth/UserProvider.tsx index 145ebbe96..d3a0146c9 100644 --- a/frontend/src/services/auth/UserProvider.tsx +++ b/frontend/src/services/auth/UserProvider.tsx @@ -32,8 +32,10 @@ export default function UserProvider({ const getUserSession = useCallback(async (): Promise => { try { setIsLoading(true); + console.log("~~~ fetching user"); const fetchedUser = await debouncedUserFetcher(); if (fetchedUser) { + console.log("~~~ fetchedUser", fetchedUser); setLocalUser(fetchedUser); setUserFetchError(undefined); setIsLoading(false); diff --git a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts index 300ce1aad..649ba7d99 100644 --- a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts +++ b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts @@ -8,7 +8,7 @@ import { UserFetcher, UserSession } from "src/services/auth/types"; 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 From c924df9c3f375894f8b043c437546a525d2e61a9 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Mon, 6 Jan 2025 14:52:03 -0500 Subject: [PATCH 10/16] update ui on logout --- frontend/src/components/Header.tsx | 80 +------------- frontend/src/components/user/UserControl.tsx | 104 +++++++++++++++++++ frontend/src/services/auth/UserProvider.tsx | 7 +- frontend/src/services/auth/types.tsx | 1 + 4 files changed, 113 insertions(+), 79 deletions(-) create mode 100644 frontend/src/components/user/UserControl.tsx diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 0e412092a..1a4622921 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,8 +2,6 @@ import clsx from "clsx"; import { useFeatureFlags } from "src/hooks/useFeatureFlags"; -import { UserProfile } from "src/services/auth/types"; -import { useUser } from "src/services/auth/useUser"; import { assetPath } from "src/utils/assetPath"; import { useTranslations } from "next-intl"; @@ -18,8 +16,7 @@ import { Header as USWDSHeader, } from "@trussworks/react-uswds"; -import ContentDisplayToggle from "src/components/ContentDisplayToggle"; -import { USWDSIcon } from "src/components/USWDSIcon"; +import { UserControl } from "./user/UserControl"; type PrimaryLink = { text?: string; @@ -123,67 +120,6 @@ const NavLinks = ({ ); }; -const LoginLink = ({ navLoginLinkText }: { navLoginLinkText: string }) => { - 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 ( - - ); -}; - -const UserDropdown = ({ - user, - navLogoutLinkText, -}: { - user: UserProfile; - navLogoutLinkText: string; -}) => { - const logout = useCallback(async (): Promise => { - await fetch("/api/auth/logout", { - method: "POST", - }); - }, []); - return ( -
- - - -
logout()} - > - {navLogoutLinkText} -
-
-
- ); -}; - const Header = ({ logoPath, locale }: Props) => { logoPath = "./img/grants-logo.svg"; const t = useTranslations("Header"); @@ -207,10 +143,6 @@ const Header = ({ logoPath, locale }: Props) => { const { checkFeatureFlag } = useFeatureFlags(); const showLoginLink = checkFeatureFlag("authOn"); - - const { user } = useUser(); - console.log("!!! user", user); - const language = locale && locale.match("/^es/") ? "spanish" : "english"; const handleMobileNavToggle = () => { @@ -261,15 +193,7 @@ const Header = ({ logoPath, locale }: Props) => { {!!showLoginLink && (
- {!user?.token && ( - - )} - {!!user?.token && ( - - )} +
)} { + return ( + + ); +}; + +const UserDropdown = ({ + user, + navLogoutLinkText, + logout, +}: { + user: UserProfile; + navLogoutLinkText: string; + logout: () => {}; +}) => { + return ( +
+ + + +
logout()} + > + {navLogoutLinkText} +
+
+
+ ); +}; + +export const UserControl = () => { + const t = useTranslations("Header"); + + const logout = useCallback(async (): Promise => { + await fetch("/api/auth/logout", { + method: "POST", + }); + refreshUser(); + }, []); + + const { user, refreshUser } = useUser(); + console.log("!!!! user", user); + + 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/services/auth/UserProvider.tsx b/frontend/src/services/auth/UserProvider.tsx index d3a0146c9..5f822067e 100644 --- a/frontend/src/services/auth/UserProvider.tsx +++ b/frontend/src/services/auth/UserProvider.tsx @@ -54,7 +54,12 @@ export default function UserProvider({ }, [localUser, getUserSession]); const value = useMemo( - () => ({ user: localUser, error: userFetchError, isLoading }), + () => ({ + user: localUser, + error: userFetchError, + isLoading, + refreshUser: getUserSession, + }), [localUser, userFetchError, isLoading], ); diff --git a/frontend/src/services/auth/types.tsx b/frontend/src/services/auth/types.tsx index af31d1e1c..0294f2f57 100644 --- a/frontend/src/services/auth/types.tsx +++ b/frontend/src/services/auth/types.tsx @@ -67,4 +67,5 @@ export type UserProviderState = { user?: UserProfile; error?: Error; isLoading: boolean; + refreshUser: () => Promise; }; From 0ae107a96606dedc6db1a712ef52978710a77a37 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Mon, 6 Jan 2025 15:17:02 -0500 Subject: [PATCH 11/16] styling --- .../src/components/ContentDisplayToggle.tsx | 9 +-- frontend/src/components/user/UserControl.tsx | 70 ++++++++++++++----- .../styles/_uswds-theme-custom-styles.scss | 9 +++ 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/ContentDisplayToggle.tsx b/frontend/src/components/ContentDisplayToggle.tsx index 47caf246d..360efcce9 100644 --- a/frontend/src/components/ContentDisplayToggle.tsx +++ b/frontend/src/components/ContentDisplayToggle.tsx @@ -14,7 +14,6 @@ type ContentDisplayToggleTypes = "default" | "centered"; * * Toggles display of child content * - * @param {string} hideCallToAction - text to show when content is expanded, falls back to showCallToAction if not passed * @param {string} breakpoint - used to: * - add a class to toggled content to always display it at viewport sizes above the specified breakpoint * - add a class to toggled button to hide it at viewport sizes above the specified breakpoint @@ -29,7 +28,7 @@ export default function ContentDisplayToggle({ type = "default", children, }: { - hideCallToAction?: string; + hideCallToAction: string; showCallToAction: string; breakpoint?: Breakpoints; showContentByDefault?: boolean; @@ -71,11 +70,9 @@ export default function ContentDisplayToggle({ aria-pressed={toggledContentVisible} className="usa-button usa-button--unstyled text-no-underline" > - + - {toggledContentVisible - ? hideCallToAction || showCallToAction - : showCallToAction} + {toggledContentVisible ? hideCallToAction : showCallToAction} diff --git a/frontend/src/components/user/UserControl.tsx b/frontend/src/components/user/UserControl.tsx index 8968956c2..b3938ff36 100644 --- a/frontend/src/components/user/UserControl.tsx +++ b/frontend/src/components/user/UserControl.tsx @@ -3,8 +3,15 @@ import { useUser } from "src/services/auth/useUser"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; +import { + IconList, + IconListContent, + IconListIcon, + IconListItem, + Menu, + NavDropDownButton, +} from "@trussworks/react-uswds"; -import ContentDisplayToggle from "src/components/ContentDisplayToggle"; import { USWDSIcon } from "src/components/USWDSIcon"; const LoginLink = ({ @@ -41,19 +48,47 @@ const UserDropdown = ({ navLogoutLinkText: string; logout: () => {}; }) => { + const [userProfileMenuOpen, setUserProfileMenuOpen] = useState(false); + // TODO mobile view + // TODO match sizing + const buttonContent = ( + + + {user.email} + + ); + + const logoutNavItem = ( + logout()} + > + + + {navLogoutLinkText} + + + ); + return ( -
- - - -
logout()} - > - {navLogoutLinkText} -
-
+
+ setUserProfileMenuOpen(!userProfileMenuOpen)} + isCurrent={false} + menuId="user-control" + /> +
); }; @@ -61,15 +96,14 @@ const UserDropdown = ({ export const UserControl = () => { const t = useTranslations("Header"); + const { user, refreshUser } = useUser(); + const logout = useCallback(async (): Promise => { await fetch("/api/auth/logout", { method: "POST", }); - refreshUser(); - }, []); - - const { user, refreshUser } = useUser(); - console.log("!!!! user", user); + await refreshUser(); + }, [refreshUser]); const [authLoginUrl, setAuthLoginUrl] = useState(null); diff --git a/frontend/src/styles/_uswds-theme-custom-styles.scss b/frontend/src/styles/_uswds-theme-custom-styles.scss index 22d96e5f8..b5206a192 100644 --- a/frontend/src/styles/_uswds-theme-custom-styles.scss +++ b/frontend/src/styles/_uswds-theme-custom-styles.scss @@ -384,3 +384,12 @@ button.usa-pagination__button.usa-button { margin-bottom: 5px !important; } } + +.mobile-nav-dropdown-uncollapsed-override { + .usa-nav__submenu-item { + a { + color: white + } + + } +} From c77d8a231aa349d90dfdd77565ac84a80f2ddb2d Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Tue, 7 Jan 2025 15:45:26 -0500 Subject: [PATCH 12/16] more styles --- frontend/src/components/Header.tsx | 2 +- .../opportunity/OpportunityStatusWidget.tsx | 8 +- frontend/src/components/user/UserControl.tsx | 57 +++++++++----- frontend/src/hooks/useMediaQuery.ts | 74 +++++++++++++++++++ .../styles/_uswds-theme-custom-styles.scss | 19 ++++- 5 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 frontend/src/hooks/useMediaQuery.ts diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 1a4622921..14db7eb20 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -192,7 +192,7 @@ const Header = ({ logoPath, locale }: Props) => { />
{!!showLoginLink && ( -
+
)} diff --git a/frontend/src/components/opportunity/OpportunityStatusWidget.tsx b/frontend/src/components/opportunity/OpportunityStatusWidget.tsx index 8319a6c92..49caff7b0 100644 --- a/frontend/src/components/opportunity/OpportunityStatusWidget.tsx +++ b/frontend/src/components/opportunity/OpportunityStatusWidget.tsx @@ -62,7 +62,7 @@ const OpportunityStatusWidget = ({ opportunityData }: 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 index b3938ff36..d21d1bef1 100644 --- a/frontend/src/components/user/UserControl.tsx +++ b/frontend/src/components/user/UserControl.tsx @@ -1,13 +1,11 @@ +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 { - IconList, IconListContent, - IconListIcon, - IconListItem, Menu, NavDropDownButton, } from "@trussworks/react-uswds"; @@ -39,6 +37,36 @@ const LoginLink = ({ ); }; +const UserEmailItem = ({ + email, + isSubnav, +}: { + email?: string; + isSubnav: boolean; +}) => { + return ( + + + + {email} + + + ); +}; + const UserDropdown = ({ user, navLogoutLinkText, @@ -46,20 +74,11 @@ const UserDropdown = ({ }: { user: UserProfile; navLogoutLinkText: string; - logout: () => {}; + logout: () => Promise; }) => { const [userProfileMenuOpen, setUserProfileMenuOpen] = useState(false); // TODO mobile view // TODO match sizing - const buttonContent = ( - - - {user.email} - - ); const logoutNavItem = ( +
} isOpen={userProfileMenuOpen} onToggle={() => setUserProfileMenuOpen(!userProfileMenuOpen)} isCurrent={false} menuId="user-control" /> , + logoutNavItem, + ]} type="subnav" isOpen={userProfileMenuOpen} /> diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts new file mode 100644 index 000000000..f2612a0dc --- /dev/null +++ b/frontend/src/hooks/useMediaQuery.ts @@ -0,0 +1,74 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react"; + +// reference to all active uswds breakpoints +// see https://designsystem.digital.gov/utilities/display/ +// and _uswds-theme.scss +const BREAKPOINTS = { + mobileLg: 480, + tablet: 640, + tabletLg: 880, + desktop: 1024, + desktopLg: 1200, +}; + +type ActiveBreakpoints = keyof typeof BREAKPOINTS; + +const onMediaChange = (fn: (value: boolean) => void) => { + return (e: MediaQueryListEvent) => { + if (e.matches) { + fn(true); + return; + } + fn(false); + }; +}; + +export const useMediaQuery = () => { + const [mobileLg, setMobileLg] = useState(false); + const [tablet, setTablet] = useState(false); + const [tabletLg, setTabletLg] = useState(false); + const [desktop, setDesktop] = useState(false); + const [desktopLg, setDesktopLg] = useState(false); + + useEffect(() => { + const onMediaChangeFunctions: { + [key in ActiveBreakpoints]: (e: MediaQueryListEvent) => void; + } = { + mobileLg: onMediaChange(setMobileLg), + tablet: onMediaChange(setTablet), + tabletLg: onMediaChange(setTabletLg), + desktop: onMediaChange(setDesktop), + desktopLg: onMediaChange(setDesktopLg), + }; + + const setStateFunctions: { + [key in ActiveBreakpoints]: Dispatch>; + } = { + mobileLg: setMobileLg, + tablet: setTablet, + tabletLg: setTabletLg, + desktop: setDesktop, + desktopLg: setDesktopLg, + }; + + Object.entries(BREAKPOINTS).forEach(([breakpointName, breakpointPx]) => { + const onMediaChange = + onMediaChangeFunctions[breakpointName as ActiveBreakpoints]; + const setState = setStateFunctions[breakpointName as ActiveBreakpoints]; + const mediaQuery = `(min-width:${breakpointPx}px)`; + const mediaQueryList = matchMedia(mediaQuery); + mediaQueryList.addEventListener("change", onMediaChange); + if (mediaQueryList.matches) { + setState(true); + } + }, {}); + }, []); + + return { + mobileLg, + tablet, + tabletLg, + desktop, + desktopLg, + }; +}; diff --git a/frontend/src/styles/_uswds-theme-custom-styles.scss b/frontend/src/styles/_uswds-theme-custom-styles.scss index b5206a192..e26994a96 100644 --- a/frontend/src/styles/_uswds-theme-custom-styles.scss +++ b/frontend/src/styles/_uswds-theme-custom-styles.scss @@ -383,13 +383,28 @@ 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 .mobile-nav-dropdown-uncollapsed-override { + button[aria-expanded=true] { + background-color: color("mint-60"); + } .usa-nav__submenu-item { + background-color: color('mint-60'); a { - color: white + padding-left: 1rem; + padding-right: 1rem; + color: white; + line-height: 1.4; + display: block; } - + } + .usa-nav__submenu { + right: 3.6em; } } From f7aee8469536e07233dd500c951539a0696b4f68 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Tue, 7 Jan 2025 17:27:02 -0500 Subject: [PATCH 13/16] finish styles --- frontend/src/components/Header.tsx | 2 +- frontend/src/components/user/UserControl.tsx | 17 +++++--- .../styles/_uswds-theme-custom-styles.scss | 43 ++++++++++++------- 3 files changed, 41 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 14db7eb20..e94b91fe6 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -192,7 +192,7 @@ const Header = ({ logoPath, locale }: Props) => { />
{!!showLoginLink && ( -
); }; @@ -95,7 +102,7 @@ const UserDropdown = ({ return (
} isOpen={userProfileMenuOpen} onToggle={() => setUserProfileMenuOpen(!userProfileMenuOpen)} diff --git a/frontend/src/styles/_uswds-theme-custom-styles.scss b/frontend/src/styles/_uswds-theme-custom-styles.scss index e26994a96..e2c2424b3 100644 --- a/frontend/src/styles/_uswds-theme-custom-styles.scss +++ b/frontend/src/styles/_uswds-theme-custom-styles.scss @@ -390,21 +390,34 @@ button.usa-pagination__button.usa-button { // 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 -.mobile-nav-dropdown-uncollapsed-override { - button[aria-expanded=true] { - background-color: color("mint-60"); - } - .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__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; } - } - .usa-nav__submenu { - right: 3.6em; } } From f0a44b755af3fa456503eb208d0a10146dceac46 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Wed, 8 Jan 2025 09:18:32 -0500 Subject: [PATCH 14/16] fix linting and ts errors --- frontend/src/app/api/auth/callback/route.ts | 1 - frontend/src/app/api/auth/session/route.ts | 1 - frontend/src/components/user/UserControl.tsx | 7 +++++-- frontend/src/services/auth/UserProvider.tsx | 4 +--- frontend/src/styles/_uswds-theme-custom-styles.scss | 12 +++++++----- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts index a3e06ff5c..d874ed17d 100644 --- a/frontend/src/app/api/auth/callback/route.ts +++ b/frontend/src/app/api/auth/callback/route.ts @@ -9,7 +9,6 @@ const createSessionAndSetStatus = async ( ): Promise => { try { await createSession(token); - console.log("~~~ session set"); return successStatus; } catch (error) { console.error("error in creating session", error); diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts index 97867a1dc..85235315c 100644 --- a/frontend/src/app/api/auth/session/route.ts +++ b/frontend/src/app/api/auth/session/route.ts @@ -6,7 +6,6 @@ export const revalidate = 0; export async function GET() { const currentSession = await getSession(); - console.log("~~~ session got", currentSession); if (currentSession) { return NextResponse.json(currentSession); } else { diff --git a/frontend/src/components/user/UserControl.tsx b/frontend/src/components/user/UserControl.tsx index 9b645f760..64e2847bc 100644 --- a/frontend/src/components/user/UserControl.tsx +++ b/frontend/src/components/user/UserControl.tsx @@ -84,12 +84,11 @@ const UserDropdown = ({ logout: () => Promise; }) => { const [userProfileMenuOpen, setUserProfileMenuOpen] = useState(false); - // TODO mobile view - // TODO match sizing const logoutNavItem = ( logout()} > @@ -103,6 +102,10 @@ const UserDropdown = ({
} isOpen={userProfileMenuOpen} onToggle={() => setUserProfileMenuOpen(!userProfileMenuOpen)} diff --git a/frontend/src/services/auth/UserProvider.tsx b/frontend/src/services/auth/UserProvider.tsx index 5f822067e..c812f3784 100644 --- a/frontend/src/services/auth/UserProvider.tsx +++ b/frontend/src/services/auth/UserProvider.tsx @@ -32,10 +32,8 @@ export default function UserProvider({ const getUserSession = useCallback(async (): Promise => { try { setIsLoading(true); - console.log("~~~ fetching user"); const fetchedUser = await debouncedUserFetcher(); if (fetchedUser) { - console.log("~~~ fetchedUser", fetchedUser); setLocalUser(fetchedUser); setUserFetchError(undefined); setIsLoading(false); @@ -60,7 +58,7 @@ export default function UserProvider({ isLoading, refreshUser: getUserSession, }), - [localUser, userFetchError, isLoading], + [localUser, userFetchError, isLoading, getUserSession], ); return {children}; diff --git a/frontend/src/styles/_uswds-theme-custom-styles.scss b/frontend/src/styles/_uswds-theme-custom-styles.scss index e2c2424b3..7de4a6f15 100644 --- a/frontend/src/styles/_uswds-theme-custom-styles.scss +++ b/frontend/src/styles/_uswds-theme-custom-styles.scss @@ -392,22 +392,24 @@ button.usa-pagination__button.usa-button { // 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] { + 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); + mask-image: url("/uswds/img/usa-icons/expand_less.svg"), + linear-gradient(transparent, transparent); } } - button[aria-expanded=false] { + button[aria-expanded="false"] { span:after { - mask-image: url("/uswds/img/usa-icons/expand_more.svg"), linear-gradient(transparent, transparent); + mask-image: url("/uswds/img/usa-icons/expand_more.svg"), + linear-gradient(transparent, transparent); } } .usa-nav__submenu-item { - background-color: color('mint-60'); + background-color: color("mint-60"); a { padding-left: 1rem; padding-right: 1rem; From 5fd25dd8d00d616803c2260b6881c35b394254e2 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Wed, 8 Jan 2025 10:13:12 -0500 Subject: [PATCH 15/16] fix some more errors, add tf env var for API JWT key --- frontend/.env.development | 2 +- frontend/src/components/user/UserControl.tsx | 2 +- frontend/src/services/auth/session.ts | 5 +++++ .../frontend/app-config/env-config/environment-variables.tf | 4 ++++ 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/.env.development b/frontend/.env.development index a9ec42c2e..abf31259f 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -32,4 +32,4 @@ USE_SEARCH_MOCK_DATA=false NEW_RELIC_APP_NAME= NEW_RELIC_LICENSE_KEY= -SESSION_SECRET=extraSecretSessionSecretValueSssh +# SESSION_SECRET=extraSecretSessionSecretValueSssh diff --git a/frontend/src/components/user/UserControl.tsx b/frontend/src/components/user/UserControl.tsx index 64e2847bc..e48c94336 100644 --- a/frontend/src/components/user/UserControl.tsx +++ b/frontend/src/components/user/UserControl.tsx @@ -20,7 +20,7 @@ const LoginLink = ({ loginUrl: string; }) => { return ( -
+
{ + 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); 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" + }, } } From 7b71b55926a72c9d23dde956d15bbcad0cc29560 Mon Sep 17 00:00:00 2001 From: Doug Schrashun Date: Wed, 8 Jan 2025 10:29:16 -0500 Subject: [PATCH 16/16] cleanup --- frontend/.env.development | 2 +- frontend/src/components/user/UserControl.tsx | 2 +- frontend/src/hooks/useMediaQuery.ts | 74 -------------------- frontend/src/services/auth/types.tsx | 5 -- 4 files changed, 2 insertions(+), 81 deletions(-) delete mode 100644 frontend/src/hooks/useMediaQuery.ts diff --git a/frontend/.env.development b/frontend/.env.development index abf31259f..a9ec42c2e 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -32,4 +32,4 @@ USE_SEARCH_MOCK_DATA=false NEW_RELIC_APP_NAME= NEW_RELIC_LICENSE_KEY= -# SESSION_SECRET=extraSecretSessionSecretValueSssh +SESSION_SECRET=extraSecretSessionSecretValueSssh diff --git a/frontend/src/components/user/UserControl.tsx b/frontend/src/components/user/UserControl.tsx index e48c94336..51dc3af9f 100644 --- a/frontend/src/components/user/UserControl.tsx +++ b/frontend/src/components/user/UserControl.tsx @@ -113,7 +113,7 @@ const UserDropdown = ({ menuId="user-control" /> , diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts deleted file mode 100644 index f2612a0dc..000000000 --- a/frontend/src/hooks/useMediaQuery.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Dispatch, SetStateAction, useEffect, useState } from "react"; - -// reference to all active uswds breakpoints -// see https://designsystem.digital.gov/utilities/display/ -// and _uswds-theme.scss -const BREAKPOINTS = { - mobileLg: 480, - tablet: 640, - tabletLg: 880, - desktop: 1024, - desktopLg: 1200, -}; - -type ActiveBreakpoints = keyof typeof BREAKPOINTS; - -const onMediaChange = (fn: (value: boolean) => void) => { - return (e: MediaQueryListEvent) => { - if (e.matches) { - fn(true); - return; - } - fn(false); - }; -}; - -export const useMediaQuery = () => { - const [mobileLg, setMobileLg] = useState(false); - const [tablet, setTablet] = useState(false); - const [tabletLg, setTabletLg] = useState(false); - const [desktop, setDesktop] = useState(false); - const [desktopLg, setDesktopLg] = useState(false); - - useEffect(() => { - const onMediaChangeFunctions: { - [key in ActiveBreakpoints]: (e: MediaQueryListEvent) => void; - } = { - mobileLg: onMediaChange(setMobileLg), - tablet: onMediaChange(setTablet), - tabletLg: onMediaChange(setTabletLg), - desktop: onMediaChange(setDesktop), - desktopLg: onMediaChange(setDesktopLg), - }; - - const setStateFunctions: { - [key in ActiveBreakpoints]: Dispatch>; - } = { - mobileLg: setMobileLg, - tablet: setTablet, - tabletLg: setTabletLg, - desktop: setDesktop, - desktopLg: setDesktopLg, - }; - - Object.entries(BREAKPOINTS).forEach(([breakpointName, breakpointPx]) => { - const onMediaChange = - onMediaChangeFunctions[breakpointName as ActiveBreakpoints]; - const setState = setStateFunctions[breakpointName as ActiveBreakpoints]; - const mediaQuery = `(min-width:${breakpointPx}px)`; - const mediaQueryList = matchMedia(mediaQuery); - mediaQueryList.addEventListener("change", onMediaChange); - if (mediaQueryList.matches) { - setState(true); - } - }, {}); - }, []); - - return { - mobileLg, - tablet, - tabletLg, - desktop, - desktopLg, - }; -}; diff --git a/frontend/src/services/auth/types.tsx b/frontend/src/services/auth/types.tsx index 0294f2f57..3e5666740 100644 --- a/frontend/src/services/auth/types.tsx +++ b/frontend/src/services/auth/types.tsx @@ -49,11 +49,6 @@ export interface SimplerJwtPayload extends JWTPayload { // represents API JWT payload export type UserSession = UserProfile & SimplerJwtPayload; -// export interface SessionPayload { -// token: string; -// expiresAt: Date; -// } - /** * Fetches the user from the profile API route to fill the useUser hook with the * UserProfile object.