-
Notifications
You must be signed in to change notification settings - Fork 16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Issue #2654] Next login flow backend #3182
Changes from all commits
4cc8c1d
d0527e4
6bc8fac
00db434
5b6cc03
cae5dec
cd54852
b44c14a
befeca2
a61b937
d917760
7ee37c5
3c86912
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ | |
|
||
import QueryProvider from "src/app/[locale]/search/QueryProvider"; | ||
import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; | ||
import { Breakpoints } from "src/types/uiTypes"; | ||
import { Breakpoints, ErrorProps } from "src/types/uiTypes"; | ||
import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes"; | ||
|
||
import { useTranslations } from "next-intl"; | ||
|
@@ -13,12 +13,6 @@ import SearchBar from "src/components/search/SearchBar"; | |
import SearchFilters from "src/components/search/SearchFilters"; | ||
import ServerErrorAlert from "src/components/ServerErrorAlert"; | ||
|
||
interface ErrorProps { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as I removed the error page for User, this doesn't need to be moved at this point, but it's still good practice, I think, so we have it more widely available for future error pages. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we need to look a little more at error handling across the app. |
||
// Next's error boundary also includes a reset function as a prop for retries, | ||
// but it was not needed as users can retry with new inputs in the normal page flow. | ||
error: Error & { digest?: string }; | ||
} | ||
|
||
export interface ParsedError { | ||
message: string; | ||
searchInputs: ServerSideSearchParams; | ||
|
@@ -54,7 +48,7 @@ function createBlankParsedError(): ParsedError { | |
}; | ||
} | ||
|
||
export default function Error({ error }: ErrorProps) { | ||
export default function SearchError({ error }: ErrorProps) { | ||
const t = useTranslations("Search"); | ||
|
||
// The error message is passed as an object that's been stringified. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import { Metadata } from "next"; | ||
import { LocalizedPageProps } from "src/types/intl"; | ||
|
||
import { getTranslations } from "next-intl/server"; | ||
import { GridContainer } from "@trussworks/react-uswds"; | ||
|
||
export async function generateMetadata({ | ||
params: { locale }, | ||
}: LocalizedPageProps) { | ||
const t = await getTranslations({ locale }); | ||
const meta: Metadata = { | ||
title: t("User.pageTitle"), | ||
description: t("Index.meta_description"), | ||
}; | ||
return meta; | ||
} | ||
|
||
// this is a placeholder page used as temporary landing page for login redirects. | ||
// Note that this page only functions to display the message passed down in query params from | ||
// the /api/auth/callback route, and it does not handle errors. | ||
// How to handle errors or failures from the callback route in the UI will need to be revisited | ||
// later on, but note that throwing to an error page won't be an option, as that produces a 500 | ||
// response in the client. | ||
export default async function UserDisplay({ | ||
searchParams, | ||
params: { locale }, | ||
}: LocalizedPageProps & { searchParams: { message?: string } }) { | ||
const { message } = searchParams; | ||
|
||
const t = await getTranslations({ locale, namespace: "User" }); | ||
return ( | ||
<GridContainer> | ||
<h1>{t("heading")}</h1> | ||
{message && <div>{message}</div>} | ||
</GridContainer> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import { createSession, getSession } from "src/services/auth/session"; | ||
|
||
import { redirect } from "next/navigation"; | ||
import { NextRequest } from "next/server"; | ||
|
||
const createSessionAndSetStatus = async ( | ||
token: string, | ||
successStatus: string, | ||
): Promise<string> => { | ||
try { | ||
await createSession(token); | ||
return successStatus; | ||
} catch (error) { | ||
console.error("error in creating session", error); | ||
return "error!"; | ||
} | ||
}; | ||
|
||
/* | ||
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 | ||
|
||
message: str ("success" or "error") | ||
token: str | None | ||
is_user_new: bool | None | ||
error_description: str | None | ||
|
||
TODOS: | ||
|
||
- translating messages? | ||
- ... | ||
*/ | ||
export async function GET(request: NextRequest) { | ||
const currentSession = await getSession(); | ||
if (currentSession && currentSession.token) { | ||
const status = await createSessionAndSetStatus( | ||
currentSession.token, | ||
"already logged in", | ||
); | ||
return redirect(`/user?message=${status}`); | ||
} | ||
const token = request.nextUrl.searchParams.get("token"); | ||
if (!token) { | ||
return redirect("/user?message=no token provided"); | ||
} | ||
const status = await createSessionAndSetStatus(token, "created session"); | ||
return redirect(`/user?message=${status}`); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
# User Auth | ||
|
||
### Notes | ||
|
||
- Server components can't write cookies, but middleware, route handlers and server actions can. | ||
|
||
### Login flow | ||
|
||
- user clicks "login" | ||
- client side component directs users to /api link | ||
- user comes back with a simpler JWT to /auth/callback | ||
- verifies JWT | ||
- sets cookie | ||
- useUser / UserProvider | ||
- checks cookie / API (see diagram) | ||
|
||
```mermaid | ||
flowchart TD | ||
checkCookie[Check cookie] | ||
cookieExists{Cookie Exists?} | ||
useUser/UserProvider --> checkCookie | ||
cookieValid{Cookie is Valid} | ||
redirectToLogin[redirect to login] | ||
|
||
checkCookie --> cookieExists | ||
cookieExists --> |Yes| cookieValid | ||
cookieExists --> |No| redirectToLogin | ||
cookieValid --> |Yes| d[Return User Data] | ||
cookieValid --> |No| redirectToLogin | ||
|
||
``` | ||
|
||
## Next step | ||
|
||
```mermaid | ||
flowchart TD | ||
checkCookie[Check cookie] | ||
cookieExists{Cookie Exists?} | ||
useUser/UserProvider --> checkCookie | ||
cookieValid{Cookie is Valid} | ||
cookieIsCurrent{Cookie is Current} | ||
redirectToLogin[redirect to login] | ||
|
||
checkCookie --> cookieExists | ||
cookieExists --> |Yes| cookieValid | ||
cookieExists --> |No| redirectToLogin | ||
cookieValid --> |Yes| cookieIsCurrent | ||
cookieValid --> |No | redirectToLogin | ||
cookieIsCurrent --> |Yes| d[Return User Data] | ||
cookieIsCurrent --> |No| e{User exists with session from /api/user} | ||
e --> |Yes| f[set cookie] | ||
e --> |No| redirectToLogin | ||
|
||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import "server-only"; | ||
|
||
import { JWTPayload, jwtVerify, SignJWT } from "jose"; | ||
import { environment } from "src/constants/environments"; | ||
import { SessionPayload, 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); | ||
|
||
// 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<string> { | ||
const jwt = await new SignJWT({ token }) | ||
.setProtectedHeader({ alg: "HS256" }) | ||
.setIssuedAt() | ||
.setExpirationTime(expiresAt) | ||
.sign(encodedKey); | ||
return jwt; | ||
} | ||
|
||
export async function decrypt( | ||
sessionCookie: string | undefined = "", | ||
): Promise<JWTPayload | null> { | ||
try { | ||
const { payload } = await jwtVerify(sessionCookie, encodedKey, { | ||
algorithms: ["HS256"], | ||
}); | ||
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<UserSession> => { | ||
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<UserSession> => { | ||
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: "/", | ||
}); | ||
} | ||
|
||
// currently unused, will be used in the future for logout | ||
export function deleteSession() { | ||
cookies().delete("session"); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks like maybe a remnant from the nj ui project?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch.