-
Notifications
You must be signed in to change notification settings - Fork 94
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
363 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./nextjs"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,204 @@ | ||
import * as jose from "jose"; | ||
|
||
import type { AccessTokenPayload, LoadedSessionContext } from "./recipe/session/types"; | ||
import { enableLogging, logDebugMessage } from "./logger"; | ||
|
||
const COOKIE_ACCESS_TOKEN_NAME = "sAccessToken"; | ||
const HEADER_ACCESS_TOKEN_NAME = "st-access-token"; | ||
const FRONT_TOKEN_NAME = "sFrontToken"; | ||
|
||
// TODO: Figure out a way to reference the config | ||
const API_BASE_PATH = "/api/auth"; | ||
const WEBSITE_BASE_PATH = "/auth"; | ||
|
||
type SSRSessionState = | ||
| "front-token-not-found" | ||
| "front-token-invalid" | ||
| "front-token-expired" | ||
| "access-token-not-found" | ||
| "access-token-invalid" | ||
| "tokens-do-not-match" | ||
| "tokens-match"; | ||
|
||
/** | ||
* Function signature for usage with Next.js App Router | ||
* @param cookies - The cookies store exposed by next/headers (await cookies()) | ||
* @param redirect - The redirect function exposed by next/navigation | ||
* @returns The session context value or directly redirect the user to either the login page or the refresh API | ||
**/ | ||
export async function getSSRSession( | ||
cookies: CookiesStore, | ||
redirect: (url: string) => never | ||
): Promise<LoadedSessionContext>; | ||
/** | ||
* Function signature for usage with getServerSideProps/Next.js Pages Router | ||
* @param cookies - The cookie object that can be extracted from context.req.headers.cookie | ||
* @returns A props object with the session context value or a redirect object | ||
**/ | ||
export async function getSSRSession(cookies: CookiesObject): Promise<GetServerSidePropsReturnValue>; | ||
export async function getSSRSession( | ||
cookies: CookiesObject | CookiesStore, | ||
redirect?: (url: string) => never | ||
): Promise<LoadedSessionContext | GetServerSidePropsReturnValue> { | ||
enableLogging(); | ||
if (isCookiesStore(cookies)) { | ||
if (!redirect) { | ||
throw new Error("Undefined redirect function"); | ||
} | ||
} | ||
|
||
const { state, session } = await getSSRSessionState(cookies); | ||
logDebugMessage(`SSR Session State: ${state}`); | ||
// const refreshResponse = await fetch("/api/auth/session/refresh", { | ||
// method: "POST", | ||
// }); | ||
// console.log(refreshResponse); | ||
|
||
switch (state) { | ||
case "front-token-not-found": | ||
case "front-token-invalid": | ||
case "access-token-invalid": | ||
// TODO; this should reset the auth state(save cookies/tokens) from the frontend | ||
logDebugMessage(`Redirecting to Auth Page: ${getAuthPagePath()}`); | ||
if (!redirect) { | ||
return { redirect: { destination: getAuthPagePath(), permanent: false } }; | ||
} else { | ||
return redirect(getAuthPagePath()); | ||
} | ||
case "front-token-expired": | ||
case "access-token-not-found": | ||
case "tokens-do-not-match": | ||
logDebugMessage(`Redirecting to refresh API: ${getRefreshApiPath()}`); | ||
if (!redirect) { | ||
return { redirect: { destination: getRefreshApiPath(), permanent: false } }; | ||
} else { | ||
return redirect(getRefreshApiPath()); | ||
} | ||
case "tokens-match": | ||
logDebugMessage("Returning session object"); | ||
if (!redirect) { | ||
return { props: { session: session as LoadedSessionContext } }; | ||
} | ||
return session as LoadedSessionContext; | ||
default: | ||
// This is here just to prevent typescript from complaining | ||
// about the function not returning a value | ||
throw new Error(`Unknown state: ${state}`); | ||
} | ||
} | ||
|
||
function getCookieValue(cookieStore: CookiesStore | CookiesObject, name: string): string | undefined { | ||
if (isCookiesStore(cookieStore)) { | ||
return cookieStore.get(name)?.value; | ||
} | ||
return cookieStore[name]; | ||
} | ||
|
||
async function getSSRSessionState( | ||
cookies: CookiesObject | CookiesStore | ||
): Promise<{ state: SSRSessionState; session?: LoadedSessionContext }> { | ||
const frontToken = getCookieValue(cookies, FRONT_TOKEN_NAME); | ||
if (!frontToken) { | ||
return { state: "front-token-not-found" }; | ||
} | ||
|
||
const parsedFrontToken = parseFrontToken(frontToken); | ||
if (!parsedFrontToken.isValid) { | ||
return { state: "front-token-invalid" }; | ||
} | ||
if (parsedFrontToken.ate < Date.now()) { | ||
return { state: "front-token-expired" }; | ||
} | ||
|
||
const accessToken = | ||
getCookieValue(cookies, COOKIE_ACCESS_TOKEN_NAME) || getCookieValue(cookies, HEADER_ACCESS_TOKEN_NAME); | ||
if (!accessToken) { | ||
return { state: "access-token-not-found" }; | ||
} | ||
|
||
const parsedAccessToken = await parseAccessToken(accessToken); | ||
if (!parsedAccessToken.isValid) { | ||
return { state: "access-token-invalid" }; | ||
} | ||
if (!comparePayloads(parsedFrontToken.payload, parsedAccessToken.payload)) { | ||
return { state: "tokens-do-not-match" }; | ||
} | ||
|
||
return { | ||
state: "tokens-match", | ||
session: { | ||
userId: parsedAccessToken.payload.sub, | ||
accessTokenPayload: parsedAccessToken, | ||
doesSessionExist: true, | ||
loading: false, | ||
invalidClaims: [], | ||
accessDeniedValidatorError: undefined, | ||
}, | ||
}; | ||
} | ||
|
||
const getRefreshApiPath = () => { | ||
return `${API_BASE_PATH}/session/refresh`; | ||
}; | ||
|
||
const getAuthPagePath = () => { | ||
return WEBSITE_BASE_PATH; | ||
}; | ||
|
||
function parseFrontToken( | ||
frontToken: string | ||
): { payload: AccessTokenPayload["up"]; ate: number; isValid: true } | { isValid: false } { | ||
try { | ||
const parsedToken = JSON.parse(decodeURIComponent(escape(atob(frontToken)))) as AccessTokenPayload; | ||
if (!parsedToken.uid || !parsedToken.ate || !parsedToken.up) { | ||
return { isValid: false }; | ||
} | ||
return { payload: parsedToken.up, ate: parsedToken.ate, isValid: true }; | ||
} catch (err) { | ||
logDebugMessage(`Error while parsing fronttoken: ${err}`); | ||
return { isValid: false }; | ||
} | ||
} | ||
|
||
// TODO: | ||
// - Do we need to check the token version and handle ERR_JWKS_MULTIPLE_MATCHING_KEYS like in the node SDK? | ||
// - Is there anything else to check in the access token in order to make sure it's valid? | ||
async function parseAccessToken( | ||
token: string | ||
): Promise<{ isValid: true; payload: AccessTokenPayload["up"] } | { isValid: false }> { | ||
const JWKS = jose.createRemoteJWKSet(new URL(`http://localhost:3000/api/auth/jwt/jwks.json`)); | ||
try { | ||
const { payload } = await jose.jwtVerify<AccessTokenPayload["up"]>(token, JWKS); | ||
if (!payload.sub || !payload.exp) { | ||
return { isValid: false }; | ||
} | ||
return { isValid: true, payload }; | ||
} catch (err) { | ||
logDebugMessage(`Error while parsing access token: ${err}`); | ||
return { isValid: false }; | ||
} | ||
} | ||
|
||
function comparePayloads(payload1: AccessTokenPayload["up"], payload2: AccessTokenPayload["up"]): boolean { | ||
return JSON.stringify(payload1) === JSON.stringify(payload2); | ||
} | ||
|
||
type CookiesStore = { | ||
get: (name: string) => { value: string }; | ||
}; | ||
|
||
function isCookiesStore(obj: unknown): obj is CookiesStore { | ||
return typeof obj === "object" && obj !== null && "get" in obj && typeof (obj as CookiesStore).get === "function"; | ||
} | ||
|
||
type CookiesObject = Record<string, string>; | ||
|
||
type GetServerSidePropsRedirect = { | ||
redirect: { destination: string; permanent: boolean }; | ||
}; | ||
|
||
type GetServerSidePropsReturnValue = | ||
| { | ||
props: { session: LoadedSessionContext }; | ||
} | ||
| GetServerSidePropsRedirect; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./nextjs"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
import * as jose from "jose"; | ||
|
||
import type { AccessTokenPayload, LoadedSessionContext } from "./types"; | ||
|
||
const COOKIE_ACCESS_TOKEN_NAME = "sAccessToken"; | ||
const HEADER_ACCESS_TOKEN_NAME = "st-access-token"; | ||
const FRONT_TOKEN_NAME = "sFrontToken"; | ||
|
||
type SSRSessionState = | ||
| "front-token-not-found" | ||
| "front-token-expired" | ||
| "access-token-not-found" | ||
| "tokens-do-not-match" | ||
| "tokens-match"; | ||
|
||
/** | ||
* Function signature for usage with Next.js App Router | ||
* @param cookies - The cookies store exposed by next/headers (await cookies()) | ||
* @param redirect - The redirect function exposed by next/navigation | ||
* @returns The session context value or directly redirect the user to either the login page or the refresh API | ||
**/ | ||
export async function getSSRSession( | ||
cookies: CookiesStore, | ||
redirect: (url: string) => never | ||
): Promise<LoadedSessionContext>; | ||
/** | ||
* Function signature for usage with getServerSideProps/Next.js Pages Router | ||
* @param cookies - The cookie object that can be extracted from context.req.headers.cookie | ||
* @returns A props object with the session context value or a redirect object | ||
**/ | ||
export async function getSSRSession(cookies: CookiesObject): Promise<GetServerSidePropsReturnValue>; | ||
export async function getSSRSession( | ||
cookies: CookiesObject | CookiesStore, | ||
redirect?: (url: string) => never | ||
): Promise<LoadedSessionContext | GetServerSidePropsReturnValue> { | ||
if (isCookiesStore(cookies)) { | ||
if (!redirect) { | ||
throw new Error("Undefined redirect function"); | ||
} | ||
} | ||
|
||
const { state, session } = await getSSRSessionState(cookies); | ||
switch (state) { | ||
case "front-token-not-found": | ||
if (!redirect) { | ||
return { redirect: { destination: getAuthPagePath(), permanent: false } }; | ||
} else { | ||
return redirect(getAuthPagePath()); | ||
} | ||
case "front-token-expired": | ||
case "access-token-not-found": | ||
case "tokens-do-not-match": | ||
if (!redirect) { | ||
return { redirect: { destination: getRefreshApiPath(), permanent: false } }; | ||
} else { | ||
return redirect(getRefreshApiPath()); | ||
} | ||
case "tokens-match": | ||
if (!redirect) { | ||
return { props: { session: session as LoadedSessionContext } }; | ||
} | ||
return session as LoadedSessionContext; | ||
default: | ||
// This is here just to prevent typescript from complaining | ||
// about the function not returning a value | ||
throw new Error(`Unknown state: ${state}`); | ||
} | ||
} | ||
|
||
function getCookieValue(cookieStore: CookiesStore | CookiesObject, name: string): string | undefined { | ||
if (isCookiesStore(cookieStore)) { | ||
return cookieStore.get(name)?.value; | ||
} | ||
return cookieStore[name]; | ||
} | ||
|
||
async function getSSRSessionState( | ||
cookies: CookiesObject | CookiesStore | ||
): Promise<{ state: SSRSessionState; session?: LoadedSessionContext }> { | ||
const frontToken = getCookieValue(cookies, FRONT_TOKEN_NAME); | ||
if (!frontToken) { | ||
return { state: "front-token-not-found" }; | ||
} | ||
|
||
const parsedFrontToken = parseFrontToken(frontToken); | ||
if (parsedFrontToken.up?.exp && parsedFrontToken.up.exp < Date.now()) { | ||
return { state: "front-token-expired" }; | ||
} | ||
|
||
const accessToken = | ||
getCookieValue(cookies, COOKIE_ACCESS_TOKEN_NAME) || getCookieValue(cookies, HEADER_ACCESS_TOKEN_NAME); | ||
if (!accessToken) { | ||
return { state: "access-token-not-found" }; | ||
} | ||
|
||
const parsedAccessToken = await parseAccessToken(accessToken); | ||
if (!comparePayloads(parsedFrontToken, parsedAccessToken)) { | ||
return { state: "tokens-do-not-match" }; | ||
} | ||
|
||
return { | ||
state: "tokens-match", | ||
session: { | ||
userId: parsedAccessToken.up.sub, | ||
accessTokenPayload: parsedAccessToken, | ||
doesSessionExist: true, | ||
loading: false, | ||
invalidClaims: [], | ||
accessDeniedValidatorError: undefined, | ||
}, | ||
}; | ||
} | ||
|
||
const getRefreshApiPath = () => { | ||
return "/refresh"; | ||
}; | ||
|
||
const getAuthPagePath = () => { | ||
return "/"; | ||
}; | ||
|
||
function parseFrontToken(frontToken: string): AccessTokenPayload { | ||
return JSON.parse(decodeURIComponent(escape(atob(frontToken)))); | ||
} | ||
|
||
async function parseAccessToken(token: string): Promise<AccessTokenPayload> { | ||
const JWKS = jose.createRemoteJWKSet(new URL(`${getRefreshApiPath()}/authjwt/jwks.json`)); | ||
const { payload } = await jose.jwtVerify<AccessTokenPayload>(token, JWKS); | ||
return payload; | ||
} | ||
|
||
function comparePayloads(payload1: AccessTokenPayload, payload2: AccessTokenPayload): boolean { | ||
return JSON.stringify(payload1) === JSON.stringify(payload2); | ||
} | ||
|
||
type CookiesStore = { | ||
get: (name: string) => { value: string }; | ||
}; | ||
|
||
function isCookiesStore(obj: unknown): obj is CookiesStore { | ||
return typeof obj === "object" && obj !== null && "get" in obj && typeof (obj as CookiesStore).get === "function"; | ||
} | ||
|
||
type CookiesObject = Record<string, string>; | ||
|
||
type GetServerSidePropsRedirect = { | ||
redirect: { destination: string; permanent: boolean }; | ||
}; | ||
|
||
type GetServerSidePropsReturnValue = | ||
| { | ||
props: { session: LoadedSessionContext }; | ||
} | ||
| GetServerSidePropsRedirect; |