diff --git a/CHANGELOG.md b/CHANGELOG.md index 551507e94..5c44886be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added a way to run CI on unmerged PRs - Added support for FDIs: 3.1 and 4.0. Required by: auth-react >=0.49.0 and web-js>=0.15.0 - The `networkInterceptor` now also gets a new `params` prop in the request config. +- Adds `customFramework` util functions to minimize code required in custom frameworks like remix, astro etc. - Replicates `fastify` types based on requirement for the SDK instead of using the original module. ### Breaking change @@ -50,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - This means that we removed `override.openIdFeature` from the Session recipe configuration - Removed `getJWKS` from the OpenId recipe, as it is already exposed by the JWT recipe - We now automatically initialize the OpenId and JWT recipes even if you do not use the Session recipe +- `getAppDirRequestHandler` for `nextjs` will no longer accept a Response object. ### Migration @@ -140,6 +142,25 @@ SuperTokens.init({ }); ``` +#### Using updated `getAppDirRequestHandler` for next.js + +Before: + +```ts +import { getAppDirRequestHandler } from "supertokens-node/nextjs"; +import { NextResponse } from "next/server"; + +const handleCall = getAppDirRequestHandler(NextResponse); +``` + +After: + +```ts +import { getAppDirRequestHandler } from "supertokens-node/nextjs"; + +const handleCall = getAppDirRequestHandler(); +``` + ## [20.1.3] - 2024-09-30 - Replaces `psl` with `tldts` to avoid `punycode` deprecation warning. diff --git a/custom/index.d.ts b/custom/index.d.ts new file mode 100644 index 000000000..6abf6c029 --- /dev/null +++ b/custom/index.d.ts @@ -0,0 +1,11 @@ +export * from "../lib/build/customFramework"; +/** + * 'export *' does not re-export a default. + * import CustomFramework from "supertokens-node/custom"; + * the above import statement won't be possible unless either + * - user add "esModuleInterop": true in their tsconfig.json file + * - we do the following change: + */ + +import * as _default from "../lib/build/customFramework"; +export default _default; diff --git a/custom/index.js b/custom/index.js new file mode 100644 index 000000000..ba833dc37 --- /dev/null +++ b/custom/index.js @@ -0,0 +1,6 @@ +"use strict"; +function __export(m) { + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +exports.__esModule = true; +__export(require("../lib/build/customFramework")); diff --git a/examples/next/with-emailpassword/app/api/auth/[...path]/route.ts b/examples/next/with-emailpassword/app/api/auth/[...path]/route.ts index 4385f250f..51385b9db 100644 --- a/examples/next/with-emailpassword/app/api/auth/[...path]/route.ts +++ b/examples/next/with-emailpassword/app/api/auth/[...path]/route.ts @@ -1,11 +1,11 @@ import { getAppDirRequestHandler } from "supertokens-node/nextjs"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import supertokens from "supertokens-node"; import { backendConfig } from "../../../../config/backendConfig"; supertokens.init(backendConfig()); -const handleCall = getAppDirRequestHandler(NextResponse); +const handleCall = getAppDirRequestHandler(); export async function GET(request: NextRequest) { const res = await handleCall(request); diff --git a/lib/build/customFramework.d.ts b/lib/build/customFramework.d.ts new file mode 100644 index 000000000..d7abeb160 --- /dev/null +++ b/lib/build/customFramework.d.ts @@ -0,0 +1,60 @@ +// @ts-nocheck +/** + * This file has definition of various re-usable util methods + * that can be used to easily integrate the SDK with most + * frameworks if they are not directly supported. + */ +import { CollectingResponse, PreParsedRequest } from "./framework/custom"; +import { SessionContainer, VerifySessionOptions } from "./recipe/session"; +import { JWTPayload } from "jose"; +export interface ParsableRequest { + url: string; + method: string; + headers: Headers; + formData: () => Promise; + json: () => Promise; +} +export declare function getCookieFromRequest( + request: RequestType +): Record; +export declare function getQueryFromRequest( + request: RequestType +): Record; +export declare function handleAuthAPIRequest(): (req: Request) => Promise; +/** + * A helper function to retrieve session details on the server side. + * + * NOTE: This function does not use the getSession function from the supertokens-node SDK + * because getSession can update the access token. These updated tokens would not be + * propagated to the client side, as request interceptors do not run on the server side. + */ +export declare function getSessionForSSR( + request: RequestType +): Promise<{ + accessTokenPayload: JWTPayload | undefined; + hasToken: boolean; + error: Error | undefined; +}>; +export declare function getSessionForSSRUsingAccessToken( + accessToken: string | undefined +): Promise<{ + accessTokenPayload: JWTPayload | undefined; + hasToken: boolean; + error: Error | undefined; +}>; +export declare function withSession< + RequestType extends ParsableRequest = Request, + ResponseType extends Response = Response +>( + request: RequestType, + handler: (error: Error | undefined, session: SessionContainer | undefined) => Promise, + options?: VerifySessionOptions, + userContext?: Record +): Promise; +export declare function withPreParsedRequestResponse< + RequestType extends ParsableRequest = Request, + ResponseType extends Response = Response +>( + req: RequestType, + handler: (baseRequest: PreParsedRequest, baseResponse: CollectingResponse) => Promise +): Promise; diff --git a/lib/build/customFramework.js b/lib/build/customFramework.js new file mode 100644 index 000000000..25064135d --- /dev/null +++ b/lib/build/customFramework.js @@ -0,0 +1,208 @@ +"use strict"; +/** + * This file has definition of various re-usable util methods + * that can be used to easily integrate the SDK with most + * frameworks if they are not directly supported. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.withPreParsedRequestResponse = exports.withSession = exports.getSessionForSSRUsingAccessToken = exports.getSessionForSSR = exports.handleAuthAPIRequest = exports.getQueryFromRequest = exports.getCookieFromRequest = void 0; +const cookie_1 = require("cookie"); +const custom_1 = require("./framework/custom"); +const session_1 = __importDefault(require("./recipe/session")); +const recipe_1 = __importDefault(require("./recipe/session/recipe")); +const jwt_1 = require("./recipe/session/jwt"); +const accessToken_1 = require("./recipe/session/accessToken"); +const combinedRemoteJWKSet_1 = require("./combinedRemoteJWKSet"); +function createPreParsedRequest(request) { + /** + * This helper function can take any `Request` type of object + * and parse the details into an equivalent PreParsedRequest + * that can be used with the custom framework helpers. + */ + return new custom_1.PreParsedRequest({ + cookies: getCookieFromRequest(request), + url: request.url, + method: request.method, + query: getQueryFromRequest(request), + headers: request.headers, + getFormBody: async () => { + return await request.formData(); + }, + getJSONBody: async () => { + return await request.json(); + }, + }); +} +function getCookieFromRequest(request) { + /** + * This function will extract the cookies from any `Request` + * type of object and return them to be usable with PreParsedRequest. + */ + const cookies = {}; + const cookieHeader = request.headers.get("Cookie"); + if (cookieHeader) { + const cookieStrings = cookieHeader.split(";"); + for (const cookieString of cookieStrings) { + const [name, value] = cookieString.trim().split("="); + cookies[name] = decodeURIComponent(value); + } + } + return cookies; +} +exports.getCookieFromRequest = getCookieFromRequest; +function getQueryFromRequest(request) { + /** + * Helper function to extract query from any `Request` type of + * object and return them to be usable with PreParsedRequest. + */ + const query = {}; + const url = new URL(request.url); + const searchParams = url.searchParams; + searchParams.forEach((value, key) => { + query[key] = value; + }); + return query; +} +exports.getQueryFromRequest = getQueryFromRequest; +function getAccessToken(request) { + return getCookieFromRequest(request)["sAccessToken"]; +} +function getHandleCall(stMiddleware) { + return async function handleCall(req) { + return withPreParsedRequestResponse(req, async (baseRequest, baseResponse) => { + const { handled, error } = await stMiddleware(baseRequest, baseResponse); + if (error) { + throw error; + } + if (!handled) { + return new Response("Not found", { status: 404 }); + } + return new Response(baseResponse.body, { + headers: baseResponse.headers, + status: baseResponse.statusCode, + }); + }); + }; +} +function handleAuthAPIRequest() { + /** + * Util function to handle all calls by intercepting them, calling + * Supertokens middleware and then accordingly returning. + */ + const stMiddleware = custom_1.middleware((req) => { + return req; + }); + return getHandleCall(stMiddleware); +} +exports.handleAuthAPIRequest = handleAuthAPIRequest; +/** + * A helper function to retrieve session details on the server side. + * + * NOTE: This function does not use the getSession function from the supertokens-node SDK + * because getSession can update the access token. These updated tokens would not be + * propagated to the client side, as request interceptors do not run on the server side. + */ +async function getSessionForSSR(request) { + return getSessionForSSRUsingAccessToken(getAccessToken(request)); +} +exports.getSessionForSSR = getSessionForSSR; +async function getSessionForSSRUsingAccessToken(accessToken) { + const hasToken = !!accessToken; + try { + const sessionRecipe = recipe_1.default.getInstanceOrThrowError(); + const jwksToUse = combinedRemoteJWKSet_1.getCombinedJWKS(sessionRecipe.config); + try { + if (accessToken) { + const tokenInfo = jwt_1.parseJWTWithoutSignatureVerification(accessToken); + const decoded = await accessToken_1.getInfoFromAccessToken(tokenInfo, jwksToUse, false); + return { accessTokenPayload: decoded.userData, hasToken, error: undefined }; + } + return { accessTokenPayload: undefined, hasToken, error: undefined }; + } catch (error) { + return { accessTokenPayload: undefined, hasToken, error: undefined }; + } + } catch (error) { + return { accessTokenPayload: undefined, hasToken, error: error }; + } +} +exports.getSessionForSSRUsingAccessToken = getSessionForSSRUsingAccessToken; +async function withSession(request, handler, options, userContext) { + try { + return await withPreParsedRequestResponse(request, async (baseRequest, baseResponse) => { + const session = await session_1.default.getSession(baseRequest, baseResponse, options, userContext); + return handler(undefined, session); + }); + } catch (error) { + return await handler(error, undefined); + } +} +exports.withSession = withSession; +async function withPreParsedRequestResponse(req, handler) { + let baseRequest = createPreParsedRequest(req); + let baseResponse = new custom_1.CollectingResponse(); + let userResponse; + try { + userResponse = await handler(baseRequest, baseResponse); + } catch (err) { + userResponse = await handleError(err, baseRequest, baseResponse); + } + return addCookies(baseResponse, userResponse); +} +exports.withPreParsedRequestResponse = withPreParsedRequestResponse; +function addCookies(baseResponse, userResponse) { + /** + * Add cookies to the userResponse passed by copying it from the baseResponse. + */ + let didAddCookies = false; + let didAddHeaders = false; + for (const respCookie of baseResponse.cookies) { + didAddCookies = true; + userResponse.headers.append( + "Set-Cookie", + cookie_1.serialize(respCookie.key, respCookie.value, { + domain: respCookie.domain, + expires: new Date(respCookie.expires), + httpOnly: respCookie.httpOnly, + path: respCookie.path, + sameSite: respCookie.sameSite, + secure: respCookie.secure, + }) + ); + } + baseResponse.headers.forEach((value, key) => { + didAddHeaders = true; + userResponse.headers.set(key, value); + }); + /** + * For some deployment services (Vercel for example) production builds can return cached results for + * APIs with older header values. In this case if the session tokens have changed (because of refreshing + * for example) the cached result would still contain the older tokens and sessions would stop working. + * + * As a result, if we add cookies or headers from base response we also set the Cache-Control header + * to make sure that the final result is not a cached version. + */ + if (didAddCookies || didAddHeaders) { + if (!userResponse.headers.has("Cache-Control")) { + // This is needed for production deployments with Vercel + userResponse.headers.set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"); + } + } + return userResponse; +} +async function handleError(err, baseRequest, baseResponse) { + await custom_1.errorHandler()(err, baseRequest, baseResponse, (errorHandlerError) => { + if (errorHandlerError) { + throw errorHandlerError; + } + }); + // The headers in the userResponse are set twice from baseResponse, but the resulting response contains unique headers. + return new Response(baseResponse.body, { + status: baseResponse.statusCode, + headers: baseResponse.headers, + }); +} diff --git a/lib/build/nextjs.d.ts b/lib/build/nextjs.d.ts index 7b06b2936..18adb76ac 100644 --- a/lib/build/nextjs.d.ts +++ b/lib/build/nextjs.d.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { CollectingResponse, PreParsedRequest } from "./framework/custom"; import { SessionContainer, VerifySessionOptions } from "./recipe/session"; +import { JWTPayload } from "jose"; declare type PartialNextRequest = { method: string; url: string; @@ -20,22 +21,16 @@ export default class NextJS { request: any, response: any ): Promise; - static getAppDirRequestHandler( - NextResponse: typeof Response - ): (req: T) => Promise; - private static commonSSRSession; + static getAppDirRequestHandler(): (req: Request) => Promise; static getSSRSession( cookies: Array<{ name: string; value: string; - }>, - headers: Headers, - options?: VerifySessionOptions, - userContext?: Record + }> ): Promise<{ - session: SessionContainer | undefined; + accessTokenPayload: JWTPayload | undefined; hasToken: boolean; - hasInvalidClaims: boolean; + error: Error | undefined; }>; static withSession( req: NextRequest, diff --git a/lib/build/nextjs.js b/lib/build/nextjs.js index 28c1a63d6..6ced5eff5 100644 --- a/lib/build/nextjs.js +++ b/lib/build/nextjs.js @@ -13,33 +13,10 @@ * License for the specific language governing permissions and limitations * under the License. */ -var __rest = - (this && this.__rest) || - function (s, e) { - var t = {}; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; - if (s != null && typeof Object.getOwnPropertySymbols === "function") - for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { - if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; - } - return t; - }; -var __importDefault = - (this && this.__importDefault) || - function (mod) { - return mod && mod.__esModule ? mod : { default: mod }; - }; Object.defineProperty(exports, "__esModule", { value: true }); exports.withPreParsedRequestResponse = exports.withSession = exports.getSSRSession = exports.getAppDirRequestHandler = exports.superTokensNextWrapper = void 0; -const cookie_1 = require("cookie"); const express_1 = require("./framework/express"); -const utils_1 = require("./utils"); -const custom_1 = require("./framework/custom"); -const session_1 = __importDefault(require("./recipe/session")); -const recipe_1 = __importDefault(require("./recipe/session/recipe")); -const cookieAndHeaders_1 = require("./recipe/session/cookieAndHeaders"); -const constants_1 = require("./recipe/session/constants"); -const jwt_1 = require("./recipe/session/jwt"); +const customFramework_1 = require("./customFramework"); function next(request, response, resolve, reject) { return async function (middlewareError) { if (middlewareError === undefined) { @@ -75,248 +52,22 @@ class NextJS { } }); } - static getAppDirRequestHandler(NextResponse) { - const stMiddleware = custom_1.middleware((req) => { - const query = Object.fromEntries(new URL(req.url).searchParams.entries()); - const cookies = Object.fromEntries(req.cookies.getAll().map((cookie) => [cookie.name, cookie.value])); - return new custom_1.PreParsedRequest({ - method: req.method, - url: req.url, - query: query, - headers: req.headers, - cookies, - getFormBody: () => req.formData(), - getJSONBody: () => req.json(), - }); - }); - return async function handleCall(req) { - const baseResponse = new custom_1.CollectingResponse(); - const { handled, error } = await stMiddleware(req, baseResponse); - if (error) { - throw error; - } - if (!handled) { - return new NextResponse("Not found", { status: 404 }); - } - for (const respCookie of baseResponse.cookies) { - baseResponse.headers.append( - "Set-Cookie", - cookie_1.serialize(respCookie.key, respCookie.value, { - domain: respCookie.domain, - expires: new Date(respCookie.expires), - httpOnly: respCookie.httpOnly, - path: respCookie.path, - sameSite: respCookie.sameSite, - secure: respCookie.secure, - }) - ); - } - return new NextResponse(baseResponse.body, { - headers: baseResponse.headers, - status: baseResponse.statusCode, - }); - }; + static getAppDirRequestHandler() { + return customFramework_1.handleAuthAPIRequest(); } - static async commonSSRSession(baseRequest, options, userContext) { - let baseResponse = new custom_1.CollectingResponse(); - const recipe = recipe_1.default.getInstanceOrThrowError(); - const tokenTransferMethod = recipe.config.getTokenTransferMethod({ - req: baseRequest, - forCreateNewSession: false, - userContext, - }); - const transferMethods = - tokenTransferMethod === "any" ? constants_1.availableTokenTransferMethods : [tokenTransferMethod]; - const hasToken = transferMethods.some((transferMethod) => { - const token = cookieAndHeaders_1.getToken(baseRequest, "access", transferMethod); - if (!token) { - return false; - } - try { - jwt_1.parseJWTWithoutSignatureVerification(token); - return true; - } catch (_a) { - return false; - } - }); - try { - let session = await session_1.default.getSession(baseRequest, baseResponse, options, userContext); - return { - session, - hasInvalidClaims: false, - hasToken, - baseResponse, - }; - } catch (err) { - if (session_1.default.Error.isErrorFromSuperTokens(err)) { - return { - hasToken, - hasInvalidClaims: err.type === session_1.default.Error.INVALID_CLAIMS, - session: undefined, - baseResponse, - nextResponse: new Response("Authentication required", { - status: err.type === session_1.default.Error.INVALID_CLAIMS ? 403 : 401, - }), - }; - } else { - throw err; - } - } - } - static async getSSRSession(cookies, headers, options, userContext) { - let cookiesObj = Object.fromEntries(cookies.map((cookie) => [cookie.name, cookie.value])); - let baseRequest = new custom_1.PreParsedRequest({ - method: "get", - url: "", - query: {}, - headers: headers, - cookies: cookiesObj, - getFormBody: async () => [], - getJSONBody: async () => [], - }); - const _a = await NextJS.commonSSRSession(baseRequest, options, utils_1.getUserContext(userContext)), - { baseResponse, nextResponse } = _a, - result = __rest(_a, ["baseResponse", "nextResponse"]); - return result; + static async getSSRSession(cookies) { + var _a; + let accessToken = + (_a = cookies.find((cookie) => cookie.name === "sAccessToken")) === null || _a === void 0 + ? void 0 + : _a.value; + return await customFramework_1.getSessionForSSRUsingAccessToken(accessToken); } static async withSession(req, handler, options, userContext) { - try { - const query = Object.fromEntries(new URL(req.url).searchParams.entries()); - const cookies = Object.fromEntries(req.cookies.getAll().map((cookie) => [cookie.name, cookie.value])); - let baseRequest = new custom_1.PreParsedRequest({ - method: req.method, - url: req.url, - query: query, - headers: req.headers, - cookies: cookies, - getFormBody: () => req.formData(), - getJSONBody: () => req.json(), - }); - const { session, nextResponse, baseResponse } = await NextJS.commonSSRSession( - baseRequest, - options, - utils_1.getUserContext(userContext) - ); - if (nextResponse) { - return nextResponse; - } - let userResponse; - try { - userResponse = await handler(undefined, session); - } catch (err) { - await custom_1.errorHandler()(err, baseRequest, baseResponse, (errorHandlerError) => { - if (errorHandlerError) { - throw errorHandlerError; - } - }); - // The headers in the userResponse are set twice from baseResponse, but the resulting response contains unique headers. - userResponse = new Response(baseResponse.body, { - status: baseResponse.statusCode, - headers: baseResponse.headers, - }); - } - let didAddCookies = false; - let didAddHeaders = false; - for (const respCookie of baseResponse.cookies) { - didAddCookies = true; - userResponse.headers.append( - "Set-Cookie", - cookie_1.serialize(respCookie.key, respCookie.value, { - domain: respCookie.domain, - expires: new Date(respCookie.expires), - httpOnly: respCookie.httpOnly, - path: respCookie.path, - sameSite: respCookie.sameSite, - secure: respCookie.secure, - }) - ); - } - baseResponse.headers.forEach((value, key) => { - didAddHeaders = true; - userResponse.headers.set(key, value); - }); - /** - * For some deployment services (Vercel for example) production builds can return cached results for - * APIs with older header values. In this case if the session tokens have changed (because of refreshing - * for example) the cached result would still contain the older tokens and sessions would stop working. - * - * As a result, if we add cookies or headers from base response we also set the Cache-Control header - * to make sure that the final result is not a cached version. - */ - if (didAddCookies || didAddHeaders) { - if (!userResponse.headers.has("Cache-Control")) { - // This is needed for production deployments with Vercel - userResponse.headers.set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"); - } - } - return userResponse; - } catch (error) { - return await handler(error, undefined); - } + return await customFramework_1.withSession(req, handler, options, userContext); } static async withPreParsedRequestResponse(req, handler) { - const query = Object.fromEntries(new URL(req.url).searchParams.entries()); - const cookies = Object.fromEntries(req.cookies.getAll().map((cookie) => [cookie.name, cookie.value])); - let baseRequest = new custom_1.PreParsedRequest({ - method: req.method, - url: req.url, - query: query, - headers: req.headers, - cookies: cookies, - getFormBody: () => req.formData(), - getJSONBody: () => req.json(), - }); - let baseResponse = new custom_1.CollectingResponse(); - let userResponse; - try { - userResponse = await handler(baseRequest, baseResponse); - } catch (err) { - await custom_1.errorHandler()(err, baseRequest, baseResponse, (errorHandlerError) => { - if (errorHandlerError) { - throw errorHandlerError; - } - }); - // The headers in the userResponse are set twice from baseResponse, but the resulting response contains unique headers. - userResponse = new Response(baseResponse.body, { - status: baseResponse.statusCode, - headers: baseResponse.headers, - }); - } - let didAddCookies = false; - let didAddHeaders = false; - for (const respCookie of baseResponse.cookies) { - didAddCookies = true; - userResponse.headers.append( - "Set-Cookie", - cookie_1.serialize(respCookie.key, respCookie.value, { - domain: respCookie.domain, - expires: new Date(respCookie.expires), - httpOnly: respCookie.httpOnly, - path: respCookie.path, - sameSite: respCookie.sameSite, - secure: respCookie.secure, - }) - ); - } - baseResponse.headers.forEach((value, key) => { - didAddHeaders = true; - userResponse.headers.set(key, value); - }); - /** - * For some deployment services (Vercel for example) production builds can return cached results for - * APIs with older header values. In this case if the session tokens have changed (because of refreshing - * for example) the cached result would still contain the older tokens and sessions would stop working. - * - * As a result, if we add cookies or headers from base response we also set the Cache-Control header - * to make sure that the final result is not a cached version. - */ - if (didAddCookies || didAddHeaders) { - if (!userResponse.headers.has("Cache-Control")) { - // This is needed for production deployments with Vercel - userResponse.headers.set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"); - } - } - return userResponse; + return customFramework_1.withPreParsedRequestResponse(req, handler); } } exports.default = NextJS; diff --git a/lib/ts/customFramework.ts b/lib/ts/customFramework.ts new file mode 100644 index 000000000..36c273d26 --- /dev/null +++ b/lib/ts/customFramework.ts @@ -0,0 +1,262 @@ +/** + * This file has definition of various re-usable util methods + * that can be used to easily integrate the SDK with most + * frameworks if they are not directly supported. + */ + +import { serialize } from "cookie"; +import { CollectingResponse, errorHandler, middleware, PreParsedRequest } from "./framework/custom"; +import Session, { SessionContainer, VerifySessionOptions } from "./recipe/session"; +import SessionRecipe from "./recipe/session/recipe"; +import { parseJWTWithoutSignatureVerification } from "./recipe/session/jwt"; +import { JWTPayload } from "jose"; +import { HTTPMethod } from "./types"; +import { getInfoFromAccessToken } from "./recipe/session/accessToken"; +import { getCombinedJWKS } from "./combinedRemoteJWKSet"; +import { BaseRequest } from "./framework"; + +export interface ParsableRequest { + url: string; + method: string; + headers: Headers; + formData: () => Promise; + json: () => Promise; +} + +function createPreParsedRequest(request: RequestType): PreParsedRequest { + /** + * This helper function can take any `Request` type of object + * and parse the details into an equivalent PreParsedRequest + * that can be used with the custom framework helpers. + */ + return new PreParsedRequest({ + cookies: getCookieFromRequest(request), + url: request.url as string, + method: request.method as HTTPMethod, + query: getQueryFromRequest(request), + headers: request.headers, + getFormBody: async () => { + return await request.formData(); + }, + getJSONBody: async () => { + return await request.json(); + }, + }); +} + +export function getCookieFromRequest( + request: RequestType +): Record { + /** + * This function will extract the cookies from any `Request` + * type of object and return them to be usable with PreParsedRequest. + */ + const cookies: Record = {}; + const cookieHeader = request.headers.get("Cookie"); + if (cookieHeader) { + const cookieStrings = cookieHeader.split(";"); + for (const cookieString of cookieStrings) { + const [name, value] = cookieString.trim().split("="); + cookies[name] = decodeURIComponent(value); + } + } + return cookies; +} + +export function getQueryFromRequest( + request: RequestType +): Record { + /** + * Helper function to extract query from any `Request` type of + * object and return them to be usable with PreParsedRequest. + */ + const query: Record = {}; + const url = new URL(request.url); + const searchParams = url.searchParams; + searchParams.forEach((value, key) => { + query[key] = value; + }); + return query; +} + +function getAccessToken(request: RequestType): string | undefined { + return getCookieFromRequest(request)["sAccessToken"]; +} + +function getHandleCall(stMiddleware: any) { + return async function handleCall(req: RequestType) { + return withPreParsedRequestResponse(req, async (baseRequest, baseResponse) => { + const { handled, error } = await stMiddleware(baseRequest, baseResponse); + if (error) { + throw error; + } + if (!handled) { + return new Response("Not found", { status: 404 }); + } + return new Response(baseResponse.body, { + headers: baseResponse.headers, + status: baseResponse.statusCode, + }); + }); + }; +} + +export function handleAuthAPIRequest() { + /** + * Util function to handle all calls by intercepting them, calling + * Supertokens middleware and then accordingly returning. + */ + const stMiddleware = middleware((req) => { + return req; + }); + + return getHandleCall(stMiddleware); +} + +/** + * A helper function to retrieve session details on the server side. + * + * NOTE: This function does not use the getSession function from the supertokens-node SDK + * because getSession can update the access token. These updated tokens would not be + * propagated to the client side, as request interceptors do not run on the server side. + */ +export async function getSessionForSSR( + request: RequestType +): Promise<{ + accessTokenPayload: JWTPayload | undefined; + hasToken: boolean; + error: Error | undefined; +}> { + return getSessionForSSRUsingAccessToken(getAccessToken(request)); +} + +export async function getSessionForSSRUsingAccessToken( + accessToken: string | undefined +): Promise<{ + accessTokenPayload: JWTPayload | undefined; + hasToken: boolean; + error: Error | undefined; +}> { + const hasToken = !!accessToken; + try { + const sessionRecipe = SessionRecipe.getInstanceOrThrowError(); + const jwksToUse = getCombinedJWKS(sessionRecipe.config); + + try { + if (accessToken) { + const tokenInfo = parseJWTWithoutSignatureVerification(accessToken); + const decoded = await getInfoFromAccessToken(tokenInfo, jwksToUse, false); + return { accessTokenPayload: decoded.userData, hasToken, error: undefined }; + } + return { accessTokenPayload: undefined, hasToken, error: undefined }; + } catch (error) { + return { accessTokenPayload: undefined, hasToken, error: undefined }; + } + } catch (error) { + return { accessTokenPayload: undefined, hasToken, error: error as Error }; + } +} + +export async function withSession< + RequestType extends ParsableRequest = Request, + ResponseType extends Response = Response +>( + request: RequestType, + handler: (error: Error | undefined, session: SessionContainer | undefined) => Promise, + options?: VerifySessionOptions, + userContext?: Record +): Promise { + try { + return await withPreParsedRequestResponse(request, async (baseRequest, baseResponse) => { + const session = await Session.getSession(baseRequest, baseResponse, options, userContext); + return handler(undefined, session); + }); + } catch (error) { + return await handler(error as Error, undefined); + } +} + +export async function withPreParsedRequestResponse< + RequestType extends ParsableRequest = Request, + ResponseType extends Response = Response +>( + req: RequestType, + handler: (baseRequest: PreParsedRequest, baseResponse: CollectingResponse) => Promise +): Promise { + let baseRequest = createPreParsedRequest(req); + let baseResponse = new CollectingResponse(); + let userResponse: ResponseType; + + try { + userResponse = await handler(baseRequest, baseResponse); + } catch (err) { + userResponse = await handleError(err, baseRequest, baseResponse); + } + + return addCookies(baseResponse, userResponse); +} + +function addCookies( + baseResponse: CollectingResponse, + userResponse: UserResponseType +): UserResponseType { + /** + * Add cookies to the userResponse passed by copying it from the baseResponse. + */ + let didAddCookies = false; + let didAddHeaders = false; + + for (const respCookie of baseResponse.cookies) { + didAddCookies = true; + userResponse.headers.append( + "Set-Cookie", + serialize(respCookie.key, respCookie.value, { + domain: respCookie.domain, + expires: new Date(respCookie.expires), + httpOnly: respCookie.httpOnly, + path: respCookie.path, + sameSite: respCookie.sameSite, + secure: respCookie.secure, + }) + ); + } + + baseResponse.headers.forEach((value: string, key: string) => { + didAddHeaders = true; + userResponse.headers.set(key, value); + }); + + /** + * For some deployment services (Vercel for example) production builds can return cached results for + * APIs with older header values. In this case if the session tokens have changed (because of refreshing + * for example) the cached result would still contain the older tokens and sessions would stop working. + * + * As a result, if we add cookies or headers from base response we also set the Cache-Control header + * to make sure that the final result is not a cached version. + */ + if (didAddCookies || didAddHeaders) { + if (!userResponse.headers.has("Cache-Control")) { + // This is needed for production deployments with Vercel + userResponse.headers.set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"); + } + } + return userResponse; +} + +async function handleError( + err: any, + baseRequest: PreParsedRequest, + baseResponse: CollectingResponse +): Promise { + await errorHandler()(err, baseRequest, baseResponse, (errorHandlerError: Error) => { + if (errorHandlerError) { + throw errorHandlerError; + } + }); + + // The headers in the userResponse are set twice from baseResponse, but the resulting response contains unique headers. + return new Response(baseResponse.body, { + status: baseResponse.statusCode, + headers: baseResponse.headers, + }) as UserResponseType; +} diff --git a/lib/ts/nextjs.ts b/lib/ts/nextjs.ts index 266213b22..1688175e8 100644 --- a/lib/ts/nextjs.ts +++ b/lib/ts/nextjs.ts @@ -13,21 +13,16 @@ * under the License. */ -import { serialize } from "cookie"; import { errorHandler } from "./framework/express"; -import { getUserContext } from "./utils"; +import { CollectingResponse, PreParsedRequest } from "./framework/custom"; +import { SessionContainer, VerifySessionOptions } from "./recipe/session"; +import { JWTPayload } from "jose"; import { - CollectingResponse, - PreParsedRequest, - middleware, - errorHandler as customErrorHandler, -} from "./framework/custom"; -import { HTTPMethod, UserContext } from "./types"; -import Session, { SessionContainer, VerifySessionOptions } from "./recipe/session"; -import SessionRecipe from "./recipe/session/recipe"; -import { getToken } from "./recipe/session/cookieAndHeaders"; -import { availableTokenTransferMethods } from "./recipe/session/constants"; -import { parseJWTWithoutSignatureVerification } from "./recipe/session/jwt"; + withPreParsedRequestResponse as customWithPreParsedRequestResponse, + getSessionForSSRUsingAccessToken, + withSession as customWithSession, + handleAuthAPIRequest, +} from "./customFramework"; function next( request: any, @@ -90,146 +85,19 @@ export default class NextJS { }); } - static getAppDirRequestHandler(NextResponse: typeof Response) { - const stMiddleware = middleware((req) => { - const query = Object.fromEntries(new URL(req.url).searchParams.entries()); - const cookies: Record = Object.fromEntries( - req.cookies.getAll().map((cookie) => [cookie.name, cookie.value]) - ); - - return new PreParsedRequest({ - method: req.method as HTTPMethod, - url: req.url, - query: query, - headers: req.headers, - cookies, - getFormBody: () => req.formData(), - getJSONBody: () => req.json(), - }); - }); - - return async function handleCall(req: T) { - const baseResponse = new CollectingResponse(); - - const { handled, error } = await stMiddleware(req, baseResponse); - - if (error) { - throw error; - } - if (!handled) { - return new NextResponse("Not found", { status: 404 }); - } - - for (const respCookie of baseResponse.cookies) { - baseResponse.headers.append( - "Set-Cookie", - serialize(respCookie.key, respCookie.value, { - domain: respCookie.domain, - expires: new Date(respCookie.expires), - httpOnly: respCookie.httpOnly, - path: respCookie.path, - sameSite: respCookie.sameSite, - secure: respCookie.secure, - }) - ); - } - - return new NextResponse(baseResponse.body, { - headers: baseResponse.headers, - status: baseResponse.statusCode, - }); - }; - } - - private static async commonSSRSession( - baseRequest: PreParsedRequest, - options: VerifySessionOptions | undefined, - userContext: UserContext - ): Promise<{ - session: SessionContainer | undefined; - hasToken: boolean; - hasInvalidClaims: boolean; - baseResponse: CollectingResponse; - nextResponse?: Response; - }> { - let baseResponse = new CollectingResponse(); - - const recipe = SessionRecipe.getInstanceOrThrowError(); - const tokenTransferMethod = recipe.config.getTokenTransferMethod({ - req: baseRequest, - forCreateNewSession: false, - userContext, - }); - const transferMethods = tokenTransferMethod === "any" ? availableTokenTransferMethods : [tokenTransferMethod]; - const hasToken = transferMethods.some((transferMethod) => { - const token = getToken(baseRequest, "access", transferMethod); - if (!token) { - return false; - } - - try { - parseJWTWithoutSignatureVerification(token); - return true; - } catch { - return false; - } - }); - - try { - let session = await Session.getSession(baseRequest, baseResponse, options, userContext); - return { - session, - hasInvalidClaims: false, - hasToken, - baseResponse, - }; - } catch (err) { - if (Session.Error.isErrorFromSuperTokens(err)) { - return { - hasToken, - hasInvalidClaims: err.type === Session.Error.INVALID_CLAIMS, - session: undefined, - baseResponse, - nextResponse: new Response("Authentication required", { - status: err.type === Session.Error.INVALID_CLAIMS ? 403 : 401, - }), - }; - } else { - throw err; - } - } + static getAppDirRequestHandler() { + return handleAuthAPIRequest(); } static async getSSRSession( - cookies: Array<{ name: string; value: string }>, - headers: Headers, - options?: VerifySessionOptions, - userContext?: Record + cookies: Array<{ name: string; value: string }> ): Promise<{ - session: SessionContainer | undefined; + accessTokenPayload: JWTPayload | undefined; hasToken: boolean; - hasInvalidClaims: boolean; + error: Error | undefined; }> { - let cookiesObj: Record = Object.fromEntries( - cookies.map((cookie) => [cookie.name, cookie.value]) - ); - - let baseRequest = new PreParsedRequest({ - method: "get", - url: "", - query: {}, - headers: headers, - cookies: cookiesObj, - getFormBody: async () => [], - getJSONBody: async () => [], - }); - - const { baseResponse, nextResponse, ...result } = await NextJS.commonSSRSession( - baseRequest, - options, - getUserContext(userContext) - ); - return result; + let accessToken = cookies.find((cookie) => cookie.name === "sAccessToken")?.value; + return await getSessionForSSRUsingAccessToken(accessToken); } static async withSession( @@ -238,170 +106,14 @@ export default class NextJS { options?: VerifySessionOptions, userContext?: Record ): Promise { - try { - const query = Object.fromEntries(new URL(req.url).searchParams.entries()); - const cookies: Record = Object.fromEntries( - req.cookies.getAll().map((cookie) => [cookie.name, cookie.value]) - ); - - let baseRequest = new PreParsedRequest({ - method: req.method as HTTPMethod, - url: req.url, - query: query, - headers: req.headers, - cookies: cookies, - getFormBody: () => req!.formData(), - getJSONBody: () => req!.json(), - }); - - const { session, nextResponse, baseResponse } = await NextJS.commonSSRSession( - baseRequest, - options, - getUserContext(userContext) - ); - - if (nextResponse) { - return nextResponse as NextResponse; - } - - let userResponse: NextResponse; - - try { - userResponse = await handler(undefined, session); - } catch (err) { - await customErrorHandler()(err, baseRequest, baseResponse, (errorHandlerError: Error) => { - if (errorHandlerError) { - throw errorHandlerError; - } - }); - - // The headers in the userResponse are set twice from baseResponse, but the resulting response contains unique headers. - userResponse = new Response(baseResponse.body, { - status: baseResponse.statusCode, - headers: baseResponse.headers, - }) as NextResponse; - } - - let didAddCookies = false; - let didAddHeaders = false; - - for (const respCookie of baseResponse.cookies) { - didAddCookies = true; - userResponse.headers.append( - "Set-Cookie", - serialize(respCookie.key, respCookie.value, { - domain: respCookie.domain, - expires: new Date(respCookie.expires), - httpOnly: respCookie.httpOnly, - path: respCookie.path, - sameSite: respCookie.sameSite, - secure: respCookie.secure, - }) - ); - } - - baseResponse.headers.forEach((value: string, key: string) => { - didAddHeaders = true; - userResponse.headers.set(key, value); - }); - - /** - * For some deployment services (Vercel for example) production builds can return cached results for - * APIs with older header values. In this case if the session tokens have changed (because of refreshing - * for example) the cached result would still contain the older tokens and sessions would stop working. - * - * As a result, if we add cookies or headers from base response we also set the Cache-Control header - * to make sure that the final result is not a cached version. - */ - if (didAddCookies || didAddHeaders) { - if (!userResponse.headers.has("Cache-Control")) { - // This is needed for production deployments with Vercel - userResponse.headers.set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"); - } - } - - return userResponse; - } catch (error) { - return await handler(error as Error, undefined); - } + return await customWithSession(req, handler, options, userContext); } static async withPreParsedRequestResponse( req: NextRequest, handler: (baseRequest: PreParsedRequest, baseResponse: CollectingResponse) => Promise ): Promise { - const query = Object.fromEntries(new URL(req.url).searchParams.entries()); - const cookies: Record = Object.fromEntries( - req.cookies.getAll().map((cookie) => [cookie.name, cookie.value]) - ); - - let baseRequest = new PreParsedRequest({ - method: req.method as HTTPMethod, - url: req.url, - query: query, - headers: req.headers, - cookies: cookies, - getFormBody: () => req!.formData(), - getJSONBody: () => req!.json(), - }); - - let baseResponse = new CollectingResponse(); - let userResponse: NextResponse; - - try { - userResponse = await handler(baseRequest, baseResponse); - } catch (err) { - await customErrorHandler()(err, baseRequest, baseResponse, (errorHandlerError: Error) => { - if (errorHandlerError) { - throw errorHandlerError; - } - }); - - // The headers in the userResponse are set twice from baseResponse, but the resulting response contains unique headers. - userResponse = new Response(baseResponse.body, { - status: baseResponse.statusCode, - headers: baseResponse.headers, - }) as NextResponse; - } - - let didAddCookies = false; - let didAddHeaders = false; - - for (const respCookie of baseResponse.cookies) { - didAddCookies = true; - userResponse.headers.append( - "Set-Cookie", - serialize(respCookie.key, respCookie.value, { - domain: respCookie.domain, - expires: new Date(respCookie.expires), - httpOnly: respCookie.httpOnly, - path: respCookie.path, - sameSite: respCookie.sameSite, - secure: respCookie.secure, - }) - ); - } - - baseResponse.headers.forEach((value: string, key: string) => { - didAddHeaders = true; - userResponse.headers.set(key, value); - }); - - /** - * For some deployment services (Vercel for example) production builds can return cached results for - * APIs with older header values. In this case if the session tokens have changed (because of refreshing - * for example) the cached result would still contain the older tokens and sessions would stop working. - * - * As a result, if we add cookies or headers from base response we also set the Cache-Control header - * to make sure that the final result is not a cached version. - */ - if (didAddCookies || didAddHeaders) { - if (!userResponse.headers.has("Cache-Control")) { - // This is needed for production deployments with Vercel - userResponse.headers.set("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate"); - } - } - return userResponse; + return customWithPreParsedRequestResponse(req, handler); } } export let superTokensNextWrapper = NextJS.superTokensNextWrapper; diff --git a/package.json b/package.json index fd1621acf..335447feb 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,18 @@ "types": "./nextjs/index.d.ts", "default": "./nextjs/index.js" }, + "./customframework": { + "types": "./custom/index.d.ts", + "default": "./custom/index.js" + }, + "./customframework/index": { + "types": "./custom/index.d.ts", + "default": "./custom/index.js" + }, + "./customframework/index.js": { + "types": "./custom/index.d.ts", + "default": "./custom/index.js" + }, "./types": { "types": "./types/index.d.ts", "default": "./types/index.js" diff --git a/test/customFramework.test.js b/test/customFramework.test.js new file mode 100644 index 000000000..b78966e99 --- /dev/null +++ b/test/customFramework.test.js @@ -0,0 +1,352 @@ +let assert = require("assert"); +const { + createPreParsedRequest, + handleAuthAPIRequest, + withSession, + getSessionForSSR, +} = require("../lib/build/customFramework"); +let { ProcessState } = require("../lib/build/processState"); +let SuperTokens = require("../lib/build/").default; +const Session = require("../lib/build/recipe/session"); +const EmailPassword = require("../lib/build/recipe/emailpassword"); +const { PreParsedRequest } = require("../lib/build/framework/custom"); +const { printPath, setupST, startST, killAllST, cleanST } = require("./utils"); +const { generateKeyPair, SignJWT, exportJWK, importJWK, decodeJwt } = require("jose"); + +// Helper function to create a JWKS +async function createJWKS() { + // Generate an RSA key pair + const { privateKey, publicKey } = await generateKeyPair("RS256"); + + // Export the public key to JWK format + const jwk = await exportJWK(publicKey); + + // Construct the JWKS + const jwks = { + keys: [ + { + ...jwk, + alg: "RS256", + use: "sig", + kid: "test-key-id", + }, + ], + }; + + return { privateKey, jwks }; +} + +async function createJWTVerifyGetKey(jwks) { + // Find the JWK in the set based on `kid` + const jwk = jwks.keys.find((k) => k.kid === "test-key-id"); + + if (!jwk) { + throw new Error("Key with the specified kid not found in JWKS"); + } + + // Import the JWK as a CryptoKey suitable for RS256 verification + return await importJWK(jwk, "RS256"); +} + +// Function to sign a JWT +async function signJWT(privateKey, jwks, payload, expiresIn = "2h") { + // Find the corresponding public key in the JWKS to get the `kid` and `alg` + const publicJWK = jwks.keys.find((k) => k.kid === "test-key-id"); + + if (!publicJWK) { + throw new Error("Key with the specified kid not found in JWKS"); + } + + // Sign the JWT using the private key + return new SignJWT(payload) + .setProtectedHeader({ alg: publicJWK.alg, kid: publicJWK.kid, version: "5", typ: "JWT" }) + .setIssuedAt() + .setExpirationTime(expiresIn) + .sign(privateKey); +} + +describe(`handleAuthAPIRequest ${printPath("[test/customFramework.test.js]")}`, () => { + let connectionURI; + let accessToken, accessTokenPayload; + let privateKey, jwks; + + before(async function () { + process.env.user = undefined; + await killAllST(); + await setupST(); + connectionURI = await startST(); + ProcessState.getInstance().reset(); + SuperTokens.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokens", + apiBasePath: "/api/auth", + websiteDomain: "supertokens.io", + }, + recipeList: [ + EmailPassword.init(), + Session.init({ + override: { + functions: (oI) => { + return { + ...oI, + createNewSession: async (input) => { + let session = await oI.createNewSession(input); + process.env.user = session.getUserId(); + return session; + }, + }; + }, + }, + }), + ], + }); + + const { privateKey: privateKeyGenerated, jwks: jwksGenerated } = await createJWKS(); + privateKey = privateKeyGenerated; + jwks = jwksGenerated; + }); + + after(async function () { + await killAllST(); + await cleanST(); + }); + + const CustomResponse = class extends Response {}; + + it("should sign-up successfully", async () => { + const handleCall = handleAuthAPIRequest(CustomResponse); + + const mockRequest = new Request(`${connectionURI}/api/auth/signup/`, { + method: "POST", + headers: { + rid: "emailpassword", + }, + body: JSON.stringify({ + formFields: [ + { + id: "email", + value: "john.doe@supertokens.io", + }, + { + id: "password", + value: "P@sSW0rd", + }, + ], + }), + }); + + // Call handleCall + const response = await handleCall(mockRequest); + + // Assertions for response + assert.strictEqual(response.status, 200, "Should return status 200"); + const responseBody = await response.json(); + assert.strictEqual(responseBody.status, "OK", "Response status should be OK"); + assert.ok(response.headers.get("st-access-token"), "st-access-token header should be set"); + assert.ok(response.headers.get("st-refresh-token"), "st-refresh-token header should be set"); + assert.ok(response.headers.get("front-token"), "front-token header should be set"); + }); + + it("should sign-in successfully", async () => { + const handleCall = handleAuthAPIRequest(CustomResponse); + + const mockRequest = new Request(`${connectionURI}/api/auth/signin/`, { + method: "POST", + headers: { + rid: "emailpassword", + }, + body: JSON.stringify({ + formFields: [ + { + id: "email", + value: "john.doe@supertokens.io", + }, + { + id: "password", + value: "P@sSW0rd", + }, + ], + }), + }); + + // Call handleCall + const response = await handleCall(mockRequest); + + // Assertions for response + assert.strictEqual(response.status, 200, "Should return status 200"); + const responseBody = await response.json(); + assert.strictEqual(responseBody.status, "OK", "Response status should be OK"); + assert.deepStrictEqual( + responseBody.user.emails[0], + "john.doe@supertokens.io", + "User email should be returned correctly" + ); + + accessToken = response.headers.get("st-access-token"); + accessTokenPayload = decodeJwt(accessToken); + + assert.ok(accessToken, "st-access-token header should be set"); + assert.ok(response.headers.get("st-refresh-token"), "st-refresh-token header should be set"); + assert.ok(response.headers.get("front-token"), "front-token header should be set"); + }); + + // Case 1: Successful => add session to request object. + it("withSession should create a session properly", async () => { + const mockSessionRequest = new Request(`${connectionURI}/api/user/`, { + method: "POST", + headers: { + rid: "emailpassword", + }, + headers: { + authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + formFields: [ + { + id: "email", + value: "john.doe@supertokens.io", + }, + { + id: "password", + value: "P@sSW0rd", + }, + ], + }), + }); + + const sessionResponse = await withSession(mockSessionRequest, async (err, session) => { + assert.strictEqual(err, undefined, "Error should be undefined"); + assert.ok(session, "Session should be present"); + assert.strictEqual(session.getUserId(), process.env.user, "Session user ID should match"); + + // Return success response + return new Response( + JSON.stringify({ + status: "session created", + userId: session.getUserId(), + }), + { status: 200 } + ); + }); + + // Assertions for the response + assert.strictEqual(sessionResponse.status, 200, "Should return status 200"); + const sessionResponseBody = await sessionResponse.json(); + assert.strictEqual( + sessionResponseBody.status, + "session created", + "Response status should be 'session created'" + ); + assert.strictEqual( + sessionResponseBody.userId, + process.env.user, + "Response user ID should match session user ID" + ); + }); + + // Case 2: Error => throws error when no access token is passed. + it("withSession should pass error when session fails", async () => { + const mockSessionRequest = new Request(`${connectionURI}/api/user/`, { + method: "POST", + headers: { + rid: "emailpassword", + }, + headers: {}, + body: JSON.stringify({ + formFields: [ + { + id: "email", + value: "john.doe@supertokens.io", + }, + { + id: "password", + value: "P@sSW0rd", + }, + ], + }), + }); + + const sessionResponse = await withSession(mockSessionRequest, async (err, session) => { + // No action required since the function will throw an error due to unauthorized + }); + + // Assertions for the response + assert.strictEqual(sessionResponse.status, 401, "Should return status 401"); + }); + + it("should return 404 for unhandled routes", async () => { + const handleCall = handleAuthAPIRequest(CustomResponse); + + const mockRequest = new Request(`${connectionURI}/api/auth/test/`, { + method: "GET", + headers: { + rid: "emailpassword", + }, + }); + + // Call handleCall + const response = await handleCall(mockRequest); + + assert.strictEqual(response.status, 404, "Should return status 404"); + assert.strictEqual(await response.text(), "Not found", "Should return Not found"); + }); + + it("getSessionForSSR should return session for valid token", async () => { + // Create a mock request containing the valid token as a cookie + const mockRequest = new Request("https://example.com", { + headers: { Cookie: `sAccessToken=${accessToken}` }, + }); + + // Call the getSessionForSSR function + const result = await getSessionForSSR(mockRequest); + + // Assertions + assert.strictEqual(result.hasToken, true, "hasToken should be true for a valid token"); + assert.ok(result.accessTokenPayload, "accessTokenPayload should be present for a valid token"); + assert.strictEqual(result.error, undefined, "error should be undefined for a valid token"); + assert.strictEqual(result.accessTokenPayload.sub, accessTokenPayload.sub, "User ID in payload should match"); + }); + + it("should return undefined accessTokenPayload and hasToken as false when no token is present", async () => { + // Create a request without an access token + const mockRequest = new Request("https://example.com"); + + // Call the getSessionForSSR function + const result = await getSessionForSSR(mockRequest); + + // Assertions + assert.strictEqual(result.hasToken, false, "hasToken should be false when no token is present"); + assert.strictEqual( + result.accessTokenPayload, + undefined, + "accessTokenPayload should be undefined when no token is present" + ); + assert.strictEqual(result.error, undefined, "error should be undefined when no token is present"); + }); + + it("should return an error for an invalid token", async () => { + // Assume you have an invalid token that does not match the JWKS + const invalidToken = "your-invalid-jwt-token"; + + // Create a mock request containing the invalid token as a cookie + const mockRequest = new Request("https://example.com", { + headers: { Cookie: `sAccessToken=${invalidToken}` }, + }); + + // Call the getSessionForSSR function + const result = await getSessionForSSR(mockRequest); + + // Assertions + assert.strictEqual(result.hasToken, true, "hasToken should be true for an invalid token"); + assert.strictEqual( + result.accessTokenPayload, + undefined, + "accessTokenPayload should be undefined for an invalid token" + ); + assert.strictEqual(result.error, undefined, "error should be undefined for an invalid token"); + }); +}); diff --git a/test/nextjs.test.js b/test/nextjs.test.js index 3e7744988..ac334555d 100644 --- a/test/nextjs.test.js +++ b/test/nextjs.test.js @@ -13,10 +13,21 @@ * under the License. */ +const { parseJWTWithoutSignatureVerification } = require("../lib/build/recipe/session/jwt"); + const [major, minor, patch] = process.versions.node.split(".").map(Number); if (major >= 18) { - const { printPath, setupST, startST, killAllST, cleanST, delay } = require("./utils"); + const { + printPath, + setupST, + startST, + killAllST, + cleanST, + delay, + killAllSTCoresOnly, + extractInfoFromResponse, + } = require("./utils"); let assert = require("assert"); let { ProcessState } = require("../lib/build/processState"); let SuperTokens = require("../lib/build/").default; @@ -729,58 +740,30 @@ if (major >= 18) { const authenticatedRequest = new NextRequest("http://localhost:3000/api/get-user", { headers: { - Authorization: `Bearer ${tokens.access}`, + Cookie: `sAccessToken=${tokens.access}`, }, }); - let sessionContainer = await getSSRSession( - authenticatedRequest.cookies.getAll(), - authenticatedRequest.headers - ); + let sessionContainer = await getSSRSession(authenticatedRequest.cookies.getAll()); assert.equal(sessionContainer.hasToken, true); - assert.equal(sessionContainer.session.getUserId(), process.env.user); + assert.equal(sessionContainer.accessTokenPayload.sub, process.env.user); const unAuthenticatedRequest = new NextRequest("http://localhost:3000/api/get-user"); - sessionContainer = await getSSRSession( - unAuthenticatedRequest.cookies.getAll(), - unAuthenticatedRequest.headers - ); + sessionContainer = await getSSRSession(unAuthenticatedRequest.cookies.getAll()); assert.equal(sessionContainer.hasToken, false); - assert.equal(sessionContainer.session, undefined); - - const requestWithFailedClaim = new NextRequest("http://localhost:3000/api/get-user", { - headers: { - Authorization: `Bearer ${tokens.access}`, - }, - }); - - sessionContainer = await getSSRSession( - requestWithFailedClaim.cookies.getAll(), - requestWithFailedClaim.headers, - { - overrideGlobalClaimValidators: async (globalValidators) => [ - ...globalValidators, - EmailVerification.EmailVerificationClaim.validators.isVerified(), - ], - } - ); - assert.equal(sessionContainer.hasToken, true); - assert.equal(sessionContainer.hasInvalidClaims, true); + assert.equal(sessionContainer.accessTokenPayload, undefined); await delay(3); const requestWithExpiredToken = new NextRequest("http://localhost:3000/api/get-user", { headers: { - Authorization: `Bearer ${tokens.access}`, + Cookie: `sAccessToken=${tokens.access}`, }, }); - sessionContainer = await getSSRSession( - requestWithExpiredToken.cookies.getAll(), - requestWithExpiredToken.headers - ); + sessionContainer = await getSSRSession(requestWithExpiredToken.cookies.getAll()); assert.equal(sessionContainer.session, undefined); assert.equal(sessionContainer.hasToken, true); }); @@ -841,7 +824,7 @@ if (major >= 18) { const requestWithFailedClaim = new NextRequest("http://localhost:3000/api/get-user", { headers: { - Authorization: `Bearer ${tokens.access}`, + Cookie: `sAccessToken=${tokens.access}`, }, }); @@ -893,6 +876,42 @@ if (major >= 18) { assert.equal(responseThatThrows.status, 500); }); + it("withSession with updated access token payload should be correctly returned", async () => { + const tokens = await getValidTokensAfterSignup({ tokenTransferMethod: "header" }); + + const authenticatedRequest = new NextRequest("http://localhost:3000/api/get-user", { + headers: { + Authorization: `Bearer ${tokens.access}`, + }, + }); + + const authenticatedResponse = await withSession(authenticatedRequest, async (err, session) => { + if (err) return NextResponse.json(err, { status: 500 }); + + // Update token payload + await session.mergeIntoAccessTokenPayload({ test: true }); + + return NextResponse.json({ + userId: session.getUserId(), + sessionHandle: session.getHandle(), + accessTokenPayload: session.getAccessTokenPayload(), + }); + }); + const updatedAccessToken = authenticatedResponse.headers.get("st-access-token"); + const tokenInfo = parseJWTWithoutSignatureVerification(updatedAccessToken); + + assert.strictEqual( + authenticatedResponse.headers.get("Cache-Control"), + "no-cache, no-store, max-age=0, must-revalidate", + "cache control headers should be set" + ); + assert.strictEqual( + tokenInfo.payload.test, + true, + "access token payload should have a test value that is true" + ); + }); + it("withPreParsedRequestResponse", async function () { const tokens = await getValidTokensAfterSignup({ tokenTransferMethod: "header" }); @@ -938,6 +957,92 @@ if (major >= 18) { assert.strictEqual(error, unknownError); } }); + + it("should go to next error handler when withSession is called without core", async function () { + const tokens = await getValidTokensAfterSignup({ tokenTransferMethod: "header" }); + + const authenticatedRequest = new NextRequest("http://localhost:3000/api/get-user", { + headers: { + Cookie: `sAccessToken=${tokens.access}`, + }, + }); + + // Manually kill to get error when withSession is called + await killAllSTCoresOnly(); + + const authenticatedResponse = await withSession( + authenticatedRequest, + async (err, session) => { + if (err) return NextResponse.json(`CUSTOM_ERROR: ${err}`, { status: 500 }); + return NextResponse.json({ + userId: session.getUserId(), + sessionHandle: session.getHandle(), + accessTokenPayload: session.getAccessTokenPayload(), + }); + }, + { checkDatabase: true } + ); + const responseJSON = await authenticatedResponse.json(); + assert.strictEqual( + responseJSON, + "CUSTOM_ERROR: Error: No SuperTokens core available to query", + "should return custom error from next error handler" + ); + }); + }); + + describe("session refresh test", async () => { + before(async function () { + process.env.user = undefined; + await killAllST(); + await setupST(); + const connectionURI = await startST(); + ProcessState.getInstance().reset(); + SuperTokens.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokens", + apiBasePath: "/api/auth", + websiteDomain: "supertokens.io", + }, + recipeList: [ + EmailPassword.init(), + Session.init({ + getTokenTransferMethod: () => "cookie", + }), + ], + }); + }); + + after(async function () { + await killAllST(); + await cleanST(); + }); + + it("should successfully refresh session", async () => { + const tokens = await getValidTokensAfterSignup({ tokenTransferMethod: "cookie" }); + + const authenticatedRequest = new NextRequest("http://localhost:3000/api/auth/session/refresh", { + headers: { + Cookie: `sAccessToken=${tokens.access};sRefreshToken=${tokens.refresh}`, + "st-auth-mode": "cookie", + }, + }); + + const authenticatedResponse = await withPreParsedRequestResponse( + authenticatedRequest, + async (baseRequest, baseResponse) => { + const session = await Session.getSession(baseRequest, baseResponse); + return NextResponse.json({ userId: session.getUserId() }); + } + ); + const responseJSON = await authenticatedResponse.json(); + assert.equal(authenticatedResponse.status, 200, "response should return a 200 OK"); + assert.ok(responseJSON.userId, "response should contain the user ID"); + }); }); describe(`getSSRSession:hasToken`, function () { @@ -977,32 +1082,17 @@ if (major >= 18) { const requestWithNoToken = new NextRequest("http://localhost:3000/api/get-user"); - sessionContainer = await getSSRSession(requestWithNoToken.cookies.getAll(), requestWithNoToken.headers); + sessionContainer = await getSSRSession(requestWithNoToken.cookies.getAll()); assert.equal(sessionContainer.hasToken, false); const requestWithInvalidToken = new NextRequest("http://localhost:3000/api/get-user", { headers: { - Authorization: `Bearer some-random-token`, - }, - }); - - sessionContainer = await getSSRSession( - requestWithInvalidToken.cookies.getAll(), - requestWithInvalidToken.headers - ); - assert.equal(sessionContainer.hasToken, false); - - const requestWithTokenInHeader = new NextRequest("http://localhost:3000/api/get-user", { - headers: { - Authorization: `Bearer ${tokens.access}`, + Cookie: `sAccessToken=some-random-token`, }, }); - sessionContainer = await getSSRSession( - requestWithTokenInHeader.cookies.getAll(), - requestWithTokenInHeader.headers - ); + sessionContainer = await getSSRSession(requestWithInvalidToken.cookies.getAll()); assert.equal(sessionContainer.hasToken, true); const requestWithTokenInCookie = new NextRequest("http://localhost:3000/api/get-user", { @@ -1011,10 +1101,7 @@ if (major >= 18) { }, }); - sessionContainer = await getSSRSession( - requestWithTokenInCookie.cookies.getAll(), - requestWithTokenInCookie.headers - ); + sessionContainer = await getSSRSession(requestWithTokenInCookie.cookies.getAll()); assert.equal(sessionContainer.hasToken, true); }); }); @@ -1055,7 +1142,7 @@ if (major >= 18) { const requestWithNoToken = new NextRequest("http://localhost:3000/api/get-user"); - sessionContainer = await getSSRSession(requestWithNoToken.cookies.getAll(), requestWithNoToken.headers); + sessionContainer = await getSSRSession(requestWithNoToken.cookies.getAll()); assert.equal(sessionContainer.hasToken, false); @@ -1065,11 +1152,8 @@ if (major >= 18) { }, }); - sessionContainer = await getSSRSession( - requestWithInvalidToken.cookies.getAll(), - requestWithInvalidToken.headers - ); - assert.equal(sessionContainer.hasToken, false); + sessionContainer = await getSSRSession(requestWithInvalidToken.cookies.getAll()); + assert.equal(sessionContainer.hasToken, true); const requestWithTokenInHeader = new NextRequest("http://localhost:3000/api/get-user", { headers: { @@ -1077,10 +1161,7 @@ if (major >= 18) { }, }); - sessionContainer = await getSSRSession( - requestWithTokenInHeader.cookies.getAll(), - requestWithTokenInHeader.headers - ); + sessionContainer = await getSSRSession(requestWithTokenInHeader.cookies.getAll()); assert.equal(sessionContainer.hasToken, false); const requestWithTokenInCookie = new NextRequest("http://localhost:3000/api/get-user", { @@ -1089,90 +1170,74 @@ if (major >= 18) { }, }); - sessionContainer = await getSSRSession( - requestWithTokenInCookie.cookies.getAll(), - requestWithTokenInCookie.headers - ); + sessionContainer = await getSSRSession(requestWithTokenInCookie.cookies.getAll()); assert.equal(sessionContainer.hasToken, true); }); }); + }); - describe("tokenTransferMethod = header", function () { - before(async function () { - process.env.user = undefined; - await killAllST(); - await setupST(); - const connectionURI = await startST(); - ProcessState.getInstance().reset(); - SuperTokens.init({ - supertokens: { - connectionURI, - }, - appInfo: { - apiDomain: "api.supertokens.io", - appName: "SuperTokens", - apiBasePath: "/api/auth", - websiteDomain: "supertokens.io", - }, - recipeList: [ - EmailPassword.init(), - Session.init({ - getTokenTransferMethod: () => "header", - }), - ], - }); - }); - - after(async function () { - await killAllST(); - await cleanST(); + describe("with email verification should throw st-ev claim has expired", async () => { + before(async function () { + process.env.user = undefined; + await killAllST(); + await setupST(); + const connectionURI = await startST(); + ProcessState.getInstance().reset(); + SuperTokens.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokens", + apiBasePath: "/api/auth", + websiteDomain: "supertokens.io", + }, + recipeList: [ + EmailPassword.init(), + Session.init({ + getTokenTransferMethod: () => "any", + }), + EmailVerification.init({ + mode: "REQUIRED", + }), + ], }); + }); - it("should return hasToken value correctly", async function () { - const tokens = await getValidTokensAfterSignup({ tokenTransferMethod: "header" }); - - const requestWithNoToken = new NextRequest("http://localhost:3000/api/get-user"); - - sessionContainer = await getSSRSession(requestWithNoToken.cookies.getAll(), requestWithNoToken.headers); - - assert.equal(sessionContainer.hasToken, false); - - const requestWithInvalidToken = new NextRequest("http://localhost:3000/api/get-user", { - headers: { - Authorization: `Bearer some-random-token`, - }, - }); - - sessionContainer = await getSSRSession( - requestWithInvalidToken.cookies.getAll(), - requestWithInvalidToken.headers - ); - assert.equal(sessionContainer.hasToken, false); - - const requestWithTokenInHeader = new NextRequest("http://localhost:3000/api/get-user", { - headers: { - Authorization: `Bearer ${tokens.access}`, - }, - }); + after(async function () { + await killAllST(); + await cleanST(); + }); - sessionContainer = await getSSRSession( - requestWithTokenInHeader.cookies.getAll(), - requestWithTokenInHeader.headers - ); - assert.equal(sessionContainer.hasToken, true); + it("should throw st-ev claim has expired for unverified email", async () => { + const tokens = await getValidTokensAfterSignup(); + const authenticatedRequest = new NextRequest("http://localhost:3000/api/get-user", { + headers: { + Cookie: `sAccessToken=${tokens.access}`, + }, + }); - const requestWithTokenInCookie = new NextRequest("http://localhost:3000/api/get-user", { - headers: { - Cookie: `sAccessToken=${tokens.access}`, - }, + const authenticatedResponse = await withSession(authenticatedRequest, async (err, session) => { + if (err) return NextResponse.json(`CUSTOM_ERROR: ${err}`, { status: 500 }); + return NextResponse.json({ + userId: session.getUserId(), + sessionHandle: session.getHandle(), + accessTokenPayload: session.getAccessTokenPayload(), }); - - sessionContainer = await getSSRSession( - requestWithTokenInCookie.cookies.getAll(), - requestWithTokenInCookie.headers - ); - assert.equal(sessionContainer.hasToken, false); }); + const responseJSON = await authenticatedResponse.json(); + assert.strictEqual(responseJSON.message, "invalid claim", "should return message: invalid claim"); + assert.strictEqual( + responseJSON.claimValidationErrors.length, + 1, + "should return claim validation errors of length 1" + ); + assert.strictEqual( + responseJSON.claimValidationErrors[0].id, + "st-ev", + "should return claim validation error id as st-ev" + ); }); }); @@ -1229,6 +1294,8 @@ if (major >= 18) { } } + tokens.antiCsrf = response.headers.get("anti-csrf"); + return tokens; } } diff --git a/test/with-typescript/index.ts b/test/with-typescript/index.ts index b9be6ec15..97dc2dfbf 100644 --- a/test/with-typescript/index.ts +++ b/test/with-typescript/index.ts @@ -2258,13 +2258,13 @@ async function handleCall(req: NextRequest): Promise { }); } -NextJS.getAppDirRequestHandler(NextResponse); +NextJS.getAppDirRequestHandler(); customVerifySession({ checkDatabase: true })(new PreParsedRequest({} as any), new CollectingResponse()); const nextRequest = new NextRequest("http://localhost:3000/api/user"); -NextJS.getSSRSession(nextRequest.cookies.getAll(), nextRequest.headers); +NextJS.getSSRSession(nextRequest.cookies.getAll()); NextJS.withSession(nextRequest, async function test(session): Promise { return NextResponse.json({}); });