Skip to content

Commit

Permalink
Fix payload processing
Browse files Browse the repository at this point in the history
  • Loading branch information
bcbogdan committed Feb 12, 2025
1 parent 4613363 commit da4e86d
Show file tree
Hide file tree
Showing 5 changed files with 363 additions and 5 deletions.
1 change: 1 addition & 0 deletions lib/ts/framework/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./nextjs";
204 changes: 204 additions & 0 deletions lib/ts/nextjs.ts
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;
1 change: 1 addition & 0 deletions lib/ts/recipe/session/framework/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./nextjs";
8 changes: 3 additions & 5 deletions lib/ts/recipe/session/framework/nextjs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import jose from "jose";

import SuperTokens from "../../../superTokens";
import * as jose from "jose";

import { isCookiesStore } from "./types";

Expand Down Expand Up @@ -117,11 +115,11 @@ async function getSSRSessionState(
}

const getRefreshApiPath = () => {
return `${SuperTokens.getInstanceOrThrow().appInfo.apiBasePath.getAsStringDangerous()}/refresh`;
return "/refresh";
};

const getAuthPagePath = () => {
return `${SuperTokens.getInstanceOrThrow().appInfo.websiteBasePath.getAsStringDangerous()}/`;
return "/";
};

function parseFrontToken(frontToken: string): AccessTokenPayload {
Expand Down
154 changes: 154 additions & 0 deletions lib/ts/recipe/session/ssr.ts
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;

0 comments on commit da4e86d

Please sign in to comment.