Skip to content

Commit da4e86d

Browse files
committed
Fix payload processing
1 parent 4613363 commit da4e86d

File tree

5 files changed

+363
-5
lines changed

5 files changed

+363
-5
lines changed

lib/ts/framework/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./nextjs";

lib/ts/nextjs.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./nextjs";

lib/ts/recipe/session/framework/nextjs.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import jose from "jose";
2-
3-
import SuperTokens from "../../../superTokens";
1+
import * as jose from "jose";
42

53
import { isCookiesStore } from "./types";
64

@@ -117,11 +115,11 @@ async function getSSRSessionState(
117115
}
118116

119117
const getRefreshApiPath = () => {
120-
return `${SuperTokens.getInstanceOrThrow().appInfo.apiBasePath.getAsStringDangerous()}/refresh`;
118+
return "/refresh";
121119
};
122120

123121
const getAuthPagePath = () => {
124-
return `${SuperTokens.getInstanceOrThrow().appInfo.websiteBasePath.getAsStringDangerous()}/`;
122+
return "/";
125123
};
126124

127125
function parseFrontToken(frontToken: string): AccessTokenPayload {

lib/ts/recipe/session/ssr.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import * as jose from "jose";
2+
3+
import type { AccessTokenPayload, LoadedSessionContext } from "./types";
4+
5+
const COOKIE_ACCESS_TOKEN_NAME = "sAccessToken";
6+
const HEADER_ACCESS_TOKEN_NAME = "st-access-token";
7+
const FRONT_TOKEN_NAME = "sFrontToken";
8+
9+
type SSRSessionState =
10+
| "front-token-not-found"
11+
| "front-token-expired"
12+
| "access-token-not-found"
13+
| "tokens-do-not-match"
14+
| "tokens-match";
15+
16+
/**
17+
* Function signature for usage with Next.js App Router
18+
* @param cookies - The cookies store exposed by next/headers (await cookies())
19+
* @param redirect - The redirect function exposed by next/navigation
20+
* @returns The session context value or directly redirect the user to either the login page or the refresh API
21+
**/
22+
export async function getSSRSession(
23+
cookies: CookiesStore,
24+
redirect: (url: string) => never
25+
): Promise<LoadedSessionContext>;
26+
/**
27+
* Function signature for usage with getServerSideProps/Next.js Pages Router
28+
* @param cookies - The cookie object that can be extracted from context.req.headers.cookie
29+
* @returns A props object with the session context value or a redirect object
30+
**/
31+
export async function getSSRSession(cookies: CookiesObject): Promise<GetServerSidePropsReturnValue>;
32+
export async function getSSRSession(
33+
cookies: CookiesObject | CookiesStore,
34+
redirect?: (url: string) => never
35+
): Promise<LoadedSessionContext | GetServerSidePropsReturnValue> {
36+
if (isCookiesStore(cookies)) {
37+
if (!redirect) {
38+
throw new Error("Undefined redirect function");
39+
}
40+
}
41+
42+
const { state, session } = await getSSRSessionState(cookies);
43+
switch (state) {
44+
case "front-token-not-found":
45+
if (!redirect) {
46+
return { redirect: { destination: getAuthPagePath(), permanent: false } };
47+
} else {
48+
return redirect(getAuthPagePath());
49+
}
50+
case "front-token-expired":
51+
case "access-token-not-found":
52+
case "tokens-do-not-match":
53+
if (!redirect) {
54+
return { redirect: { destination: getRefreshApiPath(), permanent: false } };
55+
} else {
56+
return redirect(getRefreshApiPath());
57+
}
58+
case "tokens-match":
59+
if (!redirect) {
60+
return { props: { session: session as LoadedSessionContext } };
61+
}
62+
return session as LoadedSessionContext;
63+
default:
64+
// This is here just to prevent typescript from complaining
65+
// about the function not returning a value
66+
throw new Error(`Unknown state: ${state}`);
67+
}
68+
}
69+
70+
function getCookieValue(cookieStore: CookiesStore | CookiesObject, name: string): string | undefined {
71+
if (isCookiesStore(cookieStore)) {
72+
return cookieStore.get(name)?.value;
73+
}
74+
return cookieStore[name];
75+
}
76+
77+
async function getSSRSessionState(
78+
cookies: CookiesObject | CookiesStore
79+
): Promise<{ state: SSRSessionState; session?: LoadedSessionContext }> {
80+
const frontToken = getCookieValue(cookies, FRONT_TOKEN_NAME);
81+
if (!frontToken) {
82+
return { state: "front-token-not-found" };
83+
}
84+
85+
const parsedFrontToken = parseFrontToken(frontToken);
86+
if (parsedFrontToken.up?.exp && parsedFrontToken.up.exp < Date.now()) {
87+
return { state: "front-token-expired" };
88+
}
89+
90+
const accessToken =
91+
getCookieValue(cookies, COOKIE_ACCESS_TOKEN_NAME) || getCookieValue(cookies, HEADER_ACCESS_TOKEN_NAME);
92+
if (!accessToken) {
93+
return { state: "access-token-not-found" };
94+
}
95+
96+
const parsedAccessToken = await parseAccessToken(accessToken);
97+
if (!comparePayloads(parsedFrontToken, parsedAccessToken)) {
98+
return { state: "tokens-do-not-match" };
99+
}
100+
101+
return {
102+
state: "tokens-match",
103+
session: {
104+
userId: parsedAccessToken.up.sub,
105+
accessTokenPayload: parsedAccessToken,
106+
doesSessionExist: true,
107+
loading: false,
108+
invalidClaims: [],
109+
accessDeniedValidatorError: undefined,
110+
},
111+
};
112+
}
113+
114+
const getRefreshApiPath = () => {
115+
return "/refresh";
116+
};
117+
118+
const getAuthPagePath = () => {
119+
return "/";
120+
};
121+
122+
function parseFrontToken(frontToken: string): AccessTokenPayload {
123+
return JSON.parse(decodeURIComponent(escape(atob(frontToken))));
124+
}
125+
126+
async function parseAccessToken(token: string): Promise<AccessTokenPayload> {
127+
const JWKS = jose.createRemoteJWKSet(new URL(`${getRefreshApiPath()}/authjwt/jwks.json`));
128+
const { payload } = await jose.jwtVerify<AccessTokenPayload>(token, JWKS);
129+
return payload;
130+
}
131+
132+
function comparePayloads(payload1: AccessTokenPayload, payload2: AccessTokenPayload): boolean {
133+
return JSON.stringify(payload1) === JSON.stringify(payload2);
134+
}
135+
136+
type CookiesStore = {
137+
get: (name: string) => { value: string };
138+
};
139+
140+
function isCookiesStore(obj: unknown): obj is CookiesStore {
141+
return typeof obj === "object" && obj !== null && "get" in obj && typeof (obj as CookiesStore).get === "function";
142+
}
143+
144+
type CookiesObject = Record<string, string>;
145+
146+
type GetServerSidePropsRedirect = {
147+
redirect: { destination: string; permanent: boolean };
148+
};
149+
150+
type GetServerSidePropsReturnValue =
151+
| {
152+
props: { session: LoadedSessionContext };
153+
}
154+
| GetServerSidePropsRedirect;

0 commit comments

Comments
 (0)