Skip to content

Commit cfdc2af

Browse files
committed
Add tests for getSSRSession
1 parent 531e5e9 commit cfdc2af

File tree

3 files changed

+250
-45
lines changed

3 files changed

+250
-45
lines changed

lib/ts/nextjs/middleware.ts

Lines changed: 37 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { enableLogging, logDebugMessage } from "../logger";
22

3-
import type { SuperTokensNextjsConfig, SuperTokensRequestToken } from "./types";
3+
import type { SuperTokensNextjsConfig } from "./types";
44

55
const ACCESS_TOKEN_COOKIE_NAME = "sAccessToken";
66
const ACCESS_TOKEN_HEADER_NAME = "st-access-token";
@@ -41,9 +41,8 @@ export async function refreshSession(config: SuperTokensNextjsConfig, request: R
4141
const redirectTo = requestUrl.searchParams.get(REDIRECT_PATH_PARAM_NAME) || "/";
4242
try {
4343
const tokens = await fetchNewTokens(refreshToken);
44-
const hasRequiredCookies = tokens.accessToken.cookie && tokens.refreshToken.cookie && tokens.frontToken.cookie;
45-
const hasRequiredHeaders = tokens.accessToken.header && tokens.refreshToken.header && tokens.frontToken.header;
46-
if (!hasRequiredCookies && !hasRequiredHeaders) {
44+
const hasRequiredCookies = tokens.accessToken && tokens.refreshToken && tokens.frontToken;
45+
if (!hasRequiredCookies) {
4746
logDebugMessage("Missing tokens from refresh response");
4847
return redirectToAuthPage(request);
4948
}
@@ -54,21 +53,11 @@ export async function refreshSession(config: SuperTokensNextjsConfig, request: R
5453
Location: redirectUrl.toString(),
5554
},
5655
});
57-
finalResponse.headers.append(FRONT_TOKEN_HEADER_NAME, tokens.frontToken.header as string);
58-
if (hasRequiredCookies) {
59-
finalResponse.headers.append("set-cookie", `${tokens.accessToken.cookie as string}`);
60-
finalResponse.headers.append("set-cookie", `${tokens.refreshToken.cookie as string}`);
61-
finalResponse.headers.append("set-cookie", `${tokens.frontToken.cookie as string}`);
62-
}
63-
if (hasRequiredHeaders) {
64-
finalResponse.headers.append(REFRESH_TOKEN_HEADER_NAME, tokens.refreshToken.header as string);
65-
finalResponse.headers.append(ACCESS_TOKEN_HEADER_NAME, tokens.accessToken.header as string);
66-
}
67-
68-
if (tokens.antiCsrfToken.cookie) {
69-
finalResponse.headers.append("set-cookie", `${tokens.antiCsrfToken.cookie as string}`);
70-
} else if (tokens.antiCsrfToken.header) {
71-
finalResponse.headers.append(ANTI_CSRF_TOKEN_HEADER_NAME, tokens.antiCsrfToken.header);
56+
finalResponse.headers.append("set-cookie", tokens.accessToken);
57+
finalResponse.headers.append("set-cookie", tokens.refreshToken);
58+
finalResponse.headers.append("set-cookie", tokens.frontToken);
59+
if (tokens.antiCsrfToken) {
60+
finalResponse.headers.append("set-cookie", tokens.antiCsrfToken);
7261
}
7362
finalResponse.headers.append(
7463
"set-cookie",
@@ -137,10 +126,10 @@ function redirectToAuthPage(request: Request): Response {
137126
}
138127

139128
async function fetchNewTokens(currentRefreshToken: string): Promise<{
140-
accessToken: SuperTokensRequestToken;
141-
refreshToken: SuperTokensRequestToken;
142-
frontToken: SuperTokensRequestToken;
143-
antiCsrfToken: SuperTokensRequestToken;
129+
accessToken: string;
130+
refreshToken: string;
131+
frontToken: string;
132+
antiCsrfToken: string;
144133
}> {
145134
const refreshApiURL = new URL(`${AppInfo.apiBasePath}/session/refresh`, AppInfo.apiDomain);
146135
const refreshResponse = await fetch(refreshApiURL, {
@@ -153,40 +142,45 @@ async function fetchNewTokens(currentRefreshToken: string): Promise<{
153142
credentials: "include",
154143
});
155144
logDebugMessage("Session refresh request completed");
156-
const frontToken: SuperTokensRequestToken = {
157-
header: refreshResponse.headers.get("front-token"),
158-
cookie: `${FRONT_TOKEN_COOKIE_NAME}=${refreshResponse.headers.get("front-token")}; Path=/`,
159-
};
160-
const accessToken: SuperTokensRequestToken = {
161-
header: refreshResponse.headers.get(ACCESS_TOKEN_HEADER_NAME),
162-
cookie: null,
163-
};
164-
const refreshToken: SuperTokensRequestToken = {
165-
header: refreshResponse.headers.get(REFRESH_TOKEN_HEADER_NAME),
166-
cookie: null,
167-
};
168-
const antiCsrfToken: SuperTokensRequestToken = {
169-
header: refreshResponse.headers.get(ANTI_CSRF_TOKEN_HEADER_NAME),
170-
cookie: null,
171-
};
172145

146+
const frontTokenHeaderValue = refreshResponse.headers.get(FRONT_TOKEN_HEADER_NAME);
147+
const cookieTokens = {
148+
accessToken: "",
149+
refreshToken: "",
150+
frontToken: `${FRONT_TOKEN_COOKIE_NAME}=${frontTokenHeaderValue}; HTTPOnly; Path=/`,
151+
antiCsrfToken: "",
152+
};
153+
// TOOD: Review the current build target
173154
// getSetCookie was added in node 18 and our build target is ES5
174155
// This should not a problem here since the function runs in the Vercel edge runtime environment
175156
// @ts-expect-error TS(2339): Property 'getSetCookie' does not exist on type 'Headers'.
176157
const setCookieHeaders = refreshResponse.headers.getSetCookie();
177158
for (const header of setCookieHeaders) {
178159
if (header.includes(ACCESS_TOKEN_COOKIE_NAME)) {
179-
accessToken.cookie = header;
160+
cookieTokens.accessToken = header;
180161
}
181162
if (header.includes(REFRESH_TOKEN_COOKIE_NAME)) {
182-
refreshToken.cookie = header;
163+
cookieTokens.refreshToken = header;
183164
}
184165
if (header.includes(ANTI_CSRF_TOKEN_COOKIE_NAME)) {
185-
antiCsrfToken.cookie = header;
166+
cookieTokens.antiCsrfToken = header;
186167
}
187168
}
188169

189-
return { accessToken, refreshToken, frontToken, antiCsrfToken };
170+
const accessTokenHeaderValue = refreshResponse.headers.get(ACCESS_TOKEN_HEADER_NAME);
171+
const refreshTokenHeaderValue = refreshResponse.headers.get(REFRESH_TOKEN_HEADER_NAME);
172+
const antiCsrfTokenHeaderValue = refreshResponse.headers.get(ANTI_CSRF_TOKEN_HEADER_NAME);
173+
if (!cookieTokens.accessToken) {
174+
cookieTokens.accessToken = `${ACCESS_TOKEN_COOKIE_NAME}=${accessTokenHeaderValue}; Path=/; HttpOnly; SameSite=Lax`;
175+
}
176+
if (!cookieTokens.refreshToken) {
177+
cookieTokens.refreshToken = `${REFRESH_TOKEN_COOKIE_NAME}=${refreshTokenHeaderValue}; Path=/api/auth/session/refresh; HttpOnly; SameSite=Lax`;
178+
}
179+
if (!cookieTokens.antiCsrfToken && antiCsrfTokenHeaderValue) {
180+
cookieTokens.antiCsrfToken = `${ANTI_CSRF_TOKEN_COOKIE_NAME}=${antiCsrfTokenHeaderValue}; Path=/; HttpOnly; SameSite=Lax`;
181+
}
182+
183+
return cookieTokens;
190184
}
191185

192186
function getCookie(request: Request, name: string) {

lib/ts/nextjs/ssr.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,6 @@ function parseFrontToken(
185185
}
186186
}
187187

188-
// TODO:
189-
// - Do we need to check the token version and handle ERR_JWKS_MULTIPLE_MATCHING_KEYS like in the node SDK?
190188
async function parseAccessToken(
191189
token: string
192190
): Promise<{ isValid: true; payload: AccessTokenPayload["up"] } | { isValid: false }> {
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import * as jose from "jose";
2+
import SuperTokensNextjsSSRAPIWrapper from "../../../lib/ts/nextjs/ssr";
3+
import type { CookiesStore } from "../../../lib/ts/nextjs/types";
4+
5+
jest.mock("jose", () => ({
6+
createRemoteJWKSet: jest.fn(),
7+
jwtVerify: jest.fn(),
8+
}));
9+
10+
describe("nextjs/ssr", () => {
11+
describe("getSSRSession", () => {
12+
const API_BASE_PATH = "/api/auth";
13+
const CURRENT_PATH_COOKIE_NAME = "sCurrentPath";
14+
const WEBSITE_BASE_PATH = "/auth";
15+
const REFRESH_PATH = `${API_BASE_PATH}/session/refresh`;
16+
const AUTH_PATH = WEBSITE_BASE_PATH;
17+
const FORCE_LOGOUT_PARAM = "forceLogout=true";
18+
19+
const buildRedirectUrl = (basePath: string, params: string[]) => {
20+
return `${basePath}?${params.join("&")}`;
21+
};
22+
23+
const getRedirectParam = (redirectTo: string) => {
24+
return `stRedirectTo=${redirectTo}`;
25+
};
26+
27+
const createFrontToken = (
28+
overrides: Partial<{
29+
uid: string;
30+
ate: number;
31+
up: Record<string, any>;
32+
}> = {}
33+
) => {
34+
const defaultToken = {
35+
uid: "user123",
36+
ate: Date.now() + 3600000, // 1 hour from now
37+
up: { sub: "user123" },
38+
...overrides,
39+
};
40+
return Buffer.from(JSON.stringify(defaultToken)).toString("base64");
41+
};
42+
43+
const mockRedirect = jest.fn((url: string): never => {
44+
throw url;
45+
});
46+
47+
const mockConfig = {
48+
appInfo: {
49+
apiBasePath: API_BASE_PATH,
50+
apiDomain: "http://localhost:3000",
51+
websiteBasePath: WEBSITE_BASE_PATH,
52+
appName: "SuperTokens Demo",
53+
websiteDomain: "http://localhost:3000",
54+
},
55+
};
56+
57+
const createMockCookiesStore = (cookies: Record<string, string>): CookiesStore => ({
58+
get: (name: string) => {
59+
const value = cookies[name];
60+
return { value: value || "" };
61+
},
62+
set: jest.fn(),
63+
});
64+
65+
beforeEach(() => {
66+
jest.clearAllMocks();
67+
SuperTokensNextjsSSRAPIWrapper.init(mockConfig);
68+
});
69+
70+
it("should redirect to the auth page if the front token is not found", async () => {
71+
const currentPath = "/";
72+
const cookies = createMockCookiesStore({ [CURRENT_PATH_COOKIE_NAME]: currentPath });
73+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(cookies, mockRedirect)).rejects.toBe(
74+
buildRedirectUrl(AUTH_PATH, [FORCE_LOGOUT_PARAM, getRedirectParam(currentPath)])
75+
);
76+
});
77+
78+
it("should redirect to the auth page if the front token is invalid", async () => {
79+
const currentPath = "/dashboard";
80+
const cookies = createMockCookiesStore({
81+
sFrontToken: "invalid-token",
82+
[CURRENT_PATH_COOKIE_NAME]: currentPath,
83+
});
84+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(cookies, mockRedirect)).rejects.toBe(
85+
buildRedirectUrl(AUTH_PATH, [FORCE_LOGOUT_PARAM, getRedirectParam(currentPath)])
86+
);
87+
});
88+
89+
it("should redirect to the auth page if the access token is invalid", async () => {
90+
const currentPath = "/profile";
91+
const mockFrontToken = createFrontToken();
92+
const cookies = createMockCookiesStore({
93+
sFrontToken: mockFrontToken,
94+
sAccessToken: "invalid-access-token",
95+
[CURRENT_PATH_COOKIE_NAME]: currentPath,
96+
});
97+
98+
(jose.jwtVerify as jest.Mock).mockRejectedValueOnce({ isValid: false });
99+
100+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(cookies, mockRedirect)).rejects.toBe(
101+
buildRedirectUrl(AUTH_PATH, [FORCE_LOGOUT_PARAM, getRedirectParam(currentPath)])
102+
);
103+
});
104+
105+
it("should redirect to the refresh path if the front token is expired", async () => {
106+
const currentPath = "/settings";
107+
const mockFrontToken = createFrontToken({
108+
ate: Date.now() - 1000, // Expired
109+
});
110+
111+
const cookies = createMockCookiesStore({
112+
sFrontToken: mockFrontToken,
113+
[CURRENT_PATH_COOKIE_NAME]: currentPath,
114+
});
115+
116+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(cookies, mockRedirect)).rejects.toBe(
117+
buildRedirectUrl(REFRESH_PATH, [getRedirectParam(currentPath)])
118+
);
119+
});
120+
121+
it("should redirect to the refresh path if the access token was not found", async () => {
122+
const currentPath = "/account";
123+
const mockFrontToken = createFrontToken();
124+
const cookies = createMockCookiesStore({
125+
sFrontToken: mockFrontToken,
126+
[CURRENT_PATH_COOKIE_NAME]: currentPath,
127+
});
128+
129+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(cookies, mockRedirect)).rejects.toBe(
130+
buildRedirectUrl(REFRESH_PATH, [getRedirectParam(currentPath)])
131+
);
132+
});
133+
134+
it("should redirect to the refresh path if token payloads do not match", async () => {
135+
const currentPath = "/home";
136+
const mockFrontToken = createFrontToken({
137+
up: { sub: "user123", someData: "value1" },
138+
});
139+
140+
const cookies = createMockCookiesStore({
141+
sFrontToken: mockFrontToken,
142+
sAccessToken: "valid-access-token",
143+
[CURRENT_PATH_COOKIE_NAME]: currentPath,
144+
});
145+
146+
(jose.jwtVerify as jest.Mock).mockResolvedValueOnce({
147+
payload: { sub: "user123", someData: "different-value", exp: Date.now() + 3600000 },
148+
isValid: true,
149+
});
150+
151+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(cookies, mockRedirect)).rejects.toBe(
152+
buildRedirectUrl(REFRESH_PATH, [getRedirectParam(currentPath)])
153+
);
154+
});
155+
156+
it("should return the session if tokens match", async () => {
157+
const mockPayload = { sub: "user123", someData: "value1", exp: Date.now() + 360000 };
158+
const mockFrontToken = createFrontToken({
159+
up: mockPayload,
160+
});
161+
162+
const cookies = createMockCookiesStore({
163+
sFrontToken: mockFrontToken,
164+
sAccessToken: "valid-access-token",
165+
});
166+
167+
(jose.jwtVerify as jest.Mock).mockResolvedValueOnce({
168+
payload: mockPayload,
169+
isValid: true,
170+
});
171+
172+
const result = await SuperTokensNextjsSSRAPIWrapper.getSSRSession(cookies, mockRedirect);
173+
expect(result).toEqual({
174+
userId: "user123",
175+
accessTokenPayload: { isValid: true, payload: mockPayload },
176+
doesSessionExist: true,
177+
loading: false,
178+
invalidClaims: [],
179+
accessDeniedValidatorError: undefined,
180+
});
181+
});
182+
183+
it("redirects to the correct page based on the sCurrentPath cookie", async () => {
184+
// Test with a simple path
185+
const authCookies = createMockCookiesStore({
186+
[CURRENT_PATH_COOKIE_NAME]: "/dashboard",
187+
});
188+
189+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(authCookies, mockRedirect)).rejects.toBe(
190+
buildRedirectUrl(AUTH_PATH, [FORCE_LOGOUT_PARAM, getRedirectParam("/dashboard")])
191+
);
192+
193+
// Test with a complex path (including query params)
194+
const complexPathCookies = createMockCookiesStore({
195+
[CURRENT_PATH_COOKIE_NAME]: "/settings/profile?tab=security#password",
196+
});
197+
198+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(complexPathCookies, mockRedirect)).rejects.toBe(
199+
buildRedirectUrl(AUTH_PATH, [
200+
FORCE_LOGOUT_PARAM,
201+
getRedirectParam("/settings/profile?tab=security#password"),
202+
])
203+
);
204+
205+
// Test with no current path cookie (should default to "/")
206+
const cookiesWithoutPath = createMockCookiesStore({});
207+
208+
await expect(SuperTokensNextjsSSRAPIWrapper.getSSRSession(cookiesWithoutPath, mockRedirect)).rejects.toBe(
209+
buildRedirectUrl(AUTH_PATH, [FORCE_LOGOUT_PARAM, getRedirectParam("/")])
210+
);
211+
});
212+
});
213+
});

0 commit comments

Comments
 (0)