|
| 1 | +import * as jose from "jose"; |
| 2 | + |
| 3 | +import type { AccessTokenPayload, LoadedSessionContext } from "./recipe/session/types"; |
| 4 | +import { enableLogging, logDebugMessage } from "./logger"; |
| 5 | + |
| 6 | +const COOKIE_ACCESS_TOKEN_NAME = "sAccessToken"; |
| 7 | +const HEADER_ACCESS_TOKEN_NAME = "st-access-token"; |
| 8 | +const FRONT_TOKEN_NAME = "sFrontToken"; |
| 9 | + |
| 10 | +// TODO: Figure out a way to reference the config |
| 11 | +const API_BASE_PATH = "/api/auth"; |
| 12 | +const WEBSITE_BASE_PATH = "/auth"; |
| 13 | + |
| 14 | +type SSRSessionState = |
| 15 | + | "front-token-not-found" |
| 16 | + | "front-token-invalid" |
| 17 | + | "front-token-expired" |
| 18 | + | "access-token-not-found" |
| 19 | + | "access-token-invalid" |
| 20 | + | "tokens-do-not-match" |
| 21 | + | "tokens-match"; |
| 22 | + |
| 23 | +/** |
| 24 | + * Function signature for usage with Next.js App Router |
| 25 | + * @param cookies - The cookies store exposed by next/headers (await cookies()) |
| 26 | + * @param redirect - The redirect function exposed by next/navigation |
| 27 | + * @returns The session context value or directly redirect the user to either the login page or the refresh API |
| 28 | + **/ |
| 29 | +export async function getSSRSession( |
| 30 | + cookies: CookiesStore, |
| 31 | + redirect: (url: string) => never |
| 32 | +): Promise<LoadedSessionContext>; |
| 33 | +/** |
| 34 | + * Function signature for usage with getServerSideProps/Next.js Pages Router |
| 35 | + * @param cookies - The cookie object that can be extracted from context.req.headers.cookie |
| 36 | + * @returns A props object with the session context value or a redirect object |
| 37 | + **/ |
| 38 | +export async function getSSRSession(cookies: CookiesObject): Promise<GetServerSidePropsReturnValue>; |
| 39 | +export async function getSSRSession( |
| 40 | + cookies: CookiesObject | CookiesStore, |
| 41 | + redirect?: (url: string) => never |
| 42 | +): Promise<LoadedSessionContext | GetServerSidePropsReturnValue> { |
| 43 | + enableLogging(); |
| 44 | + if (isCookiesStore(cookies)) { |
| 45 | + if (!redirect) { |
| 46 | + throw new Error("Undefined redirect function"); |
| 47 | + } |
| 48 | + } |
| 49 | + |
| 50 | + const { state, session } = await getSSRSessionState(cookies); |
| 51 | + logDebugMessage(`SSR Session State: ${state}`); |
| 52 | + // const refreshResponse = await fetch("/api/auth/session/refresh", { |
| 53 | + // method: "POST", |
| 54 | + // }); |
| 55 | + // console.log(refreshResponse); |
| 56 | + |
| 57 | + switch (state) { |
| 58 | + case "front-token-not-found": |
| 59 | + case "front-token-invalid": |
| 60 | + case "access-token-invalid": |
| 61 | + // TODO; this should reset the auth state(save cookies/tokens) from the frontend |
| 62 | + logDebugMessage(`Redirecting to Auth Page: ${getAuthPagePath()}`); |
| 63 | + if (!redirect) { |
| 64 | + return { redirect: { destination: getAuthPagePath(), permanent: false } }; |
| 65 | + } else { |
| 66 | + return redirect(getAuthPagePath()); |
| 67 | + } |
| 68 | + case "front-token-expired": |
| 69 | + case "access-token-not-found": |
| 70 | + case "tokens-do-not-match": |
| 71 | + logDebugMessage(`Redirecting to refresh API: ${getRefreshApiPath()}`); |
| 72 | + if (!redirect) { |
| 73 | + return { redirect: { destination: getRefreshApiPath(), permanent: false } }; |
| 74 | + } else { |
| 75 | + return redirect(getRefreshApiPath()); |
| 76 | + } |
| 77 | + case "tokens-match": |
| 78 | + logDebugMessage("Returning session object"); |
| 79 | + if (!redirect) { |
| 80 | + return { props: { session: session as LoadedSessionContext } }; |
| 81 | + } |
| 82 | + return session as LoadedSessionContext; |
| 83 | + default: |
| 84 | + // This is here just to prevent typescript from complaining |
| 85 | + // about the function not returning a value |
| 86 | + throw new Error(`Unknown state: ${state}`); |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +function getCookieValue(cookieStore: CookiesStore | CookiesObject, name: string): string | undefined { |
| 91 | + if (isCookiesStore(cookieStore)) { |
| 92 | + return cookieStore.get(name)?.value; |
| 93 | + } |
| 94 | + return cookieStore[name]; |
| 95 | +} |
| 96 | + |
| 97 | +async function getSSRSessionState( |
| 98 | + cookies: CookiesObject | CookiesStore |
| 99 | +): Promise<{ state: SSRSessionState; session?: LoadedSessionContext }> { |
| 100 | + const frontToken = getCookieValue(cookies, FRONT_TOKEN_NAME); |
| 101 | + if (!frontToken) { |
| 102 | + return { state: "front-token-not-found" }; |
| 103 | + } |
| 104 | + |
| 105 | + const parsedFrontToken = parseFrontToken(frontToken); |
| 106 | + if (!parsedFrontToken.isValid) { |
| 107 | + return { state: "front-token-invalid" }; |
| 108 | + } |
| 109 | + if (parsedFrontToken.ate < Date.now()) { |
| 110 | + return { state: "front-token-expired" }; |
| 111 | + } |
| 112 | + |
| 113 | + const accessToken = |
| 114 | + getCookieValue(cookies, COOKIE_ACCESS_TOKEN_NAME) || getCookieValue(cookies, HEADER_ACCESS_TOKEN_NAME); |
| 115 | + if (!accessToken) { |
| 116 | + return { state: "access-token-not-found" }; |
| 117 | + } |
| 118 | + |
| 119 | + const parsedAccessToken = await parseAccessToken(accessToken); |
| 120 | + if (!parsedAccessToken.isValid) { |
| 121 | + return { state: "access-token-invalid" }; |
| 122 | + } |
| 123 | + if (!comparePayloads(parsedFrontToken.payload, parsedAccessToken.payload)) { |
| 124 | + return { state: "tokens-do-not-match" }; |
| 125 | + } |
| 126 | + |
| 127 | + return { |
| 128 | + state: "tokens-match", |
| 129 | + session: { |
| 130 | + userId: parsedAccessToken.payload.sub, |
| 131 | + accessTokenPayload: parsedAccessToken, |
| 132 | + doesSessionExist: true, |
| 133 | + loading: false, |
| 134 | + invalidClaims: [], |
| 135 | + accessDeniedValidatorError: undefined, |
| 136 | + }, |
| 137 | + }; |
| 138 | +} |
| 139 | + |
| 140 | +const getRefreshApiPath = () => { |
| 141 | + return `${API_BASE_PATH}/session/refresh`; |
| 142 | +}; |
| 143 | + |
| 144 | +const getAuthPagePath = () => { |
| 145 | + return WEBSITE_BASE_PATH; |
| 146 | +}; |
| 147 | + |
| 148 | +function parseFrontToken( |
| 149 | + frontToken: string |
| 150 | +): { payload: AccessTokenPayload["up"]; ate: number; isValid: true } | { isValid: false } { |
| 151 | + try { |
| 152 | + const parsedToken = JSON.parse(decodeURIComponent(escape(atob(frontToken)))) as AccessTokenPayload; |
| 153 | + if (!parsedToken.uid || !parsedToken.ate || !parsedToken.up) { |
| 154 | + return { isValid: false }; |
| 155 | + } |
| 156 | + return { payload: parsedToken.up, ate: parsedToken.ate, isValid: true }; |
| 157 | + } catch (err) { |
| 158 | + logDebugMessage(`Error while parsing fronttoken: ${err}`); |
| 159 | + return { isValid: false }; |
| 160 | + } |
| 161 | +} |
| 162 | + |
| 163 | +// TODO: |
| 164 | +// - Do we need to check the token version and handle ERR_JWKS_MULTIPLE_MATCHING_KEYS like in the node SDK? |
| 165 | +// - Is there anything else to check in the access token in order to make sure it's valid? |
| 166 | +async function parseAccessToken( |
| 167 | + token: string |
| 168 | +): Promise<{ isValid: true; payload: AccessTokenPayload["up"] } | { isValid: false }> { |
| 169 | + const JWKS = jose.createRemoteJWKSet(new URL(`http://localhost:3000/api/auth/jwt/jwks.json`)); |
| 170 | + try { |
| 171 | + const { payload } = await jose.jwtVerify<AccessTokenPayload["up"]>(token, JWKS); |
| 172 | + if (!payload.sub || !payload.exp) { |
| 173 | + return { isValid: false }; |
| 174 | + } |
| 175 | + return { isValid: true, payload }; |
| 176 | + } catch (err) { |
| 177 | + logDebugMessage(`Error while parsing access token: ${err}`); |
| 178 | + return { isValid: false }; |
| 179 | + } |
| 180 | +} |
| 181 | + |
| 182 | +function comparePayloads(payload1: AccessTokenPayload["up"], payload2: AccessTokenPayload["up"]): boolean { |
| 183 | + return JSON.stringify(payload1) === JSON.stringify(payload2); |
| 184 | +} |
| 185 | + |
| 186 | +type CookiesStore = { |
| 187 | + get: (name: string) => { value: string }; |
| 188 | +}; |
| 189 | + |
| 190 | +function isCookiesStore(obj: unknown): obj is CookiesStore { |
| 191 | + return typeof obj === "object" && obj !== null && "get" in obj && typeof (obj as CookiesStore).get === "function"; |
| 192 | +} |
| 193 | + |
| 194 | +type CookiesObject = Record<string, string>; |
| 195 | + |
| 196 | +type GetServerSidePropsRedirect = { |
| 197 | + redirect: { destination: string; permanent: boolean }; |
| 198 | +}; |
| 199 | + |
| 200 | +type GetServerSidePropsReturnValue = |
| 201 | + | { |
| 202 | + props: { session: LoadedSessionContext }; |
| 203 | + } |
| 204 | + | GetServerSidePropsRedirect; |
0 commit comments