Skip to content

Commit

Permalink
refactor: Expose (and Use) BOTH headers and cookies from `SuperTo…
Browse files Browse the repository at this point in the history
…kensOutput`

After chatting with Rich Harris (creator of Svelte) on
sveltejs/kit#8409, it seems that it's better to expose
any necessary auth cookies _explicitly_ rather than wrap
them in `Headers`. The short reasoning for this is that
it provides more options to developers using SSR
frameworks. (The previous implementation locked some
developers out of using `SuperTokens` in the framework
of their choice.)

Note that the `SuperTokensOutput` utility class now
exposes the `responseHeaders` as a `Map` instead of
a `Headers` object in order to support frameworks
(or servers) that do not have a proper `Headers`
class that they can use. (Node.js doesn't quite
support `Headers` out of the box yet. See the
MDN compatibility table.)
  • Loading branch information
ITenthusiasm committed Jan 11, 2023
1 parent f50eabc commit bffa41d
Show file tree
Hide file tree
Showing 6 changed files with 68 additions and 36 deletions.
11 changes: 7 additions & 4 deletions app/routes/auth.session.refresh.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import type { LoaderFunction } from "@remix-run/node";
import { SuperTokensHelpers } from "~/utils/supertokens/index.server";
import { SuperTokensHelpers, setCookiesFromMap, setHeadersFromMap } from "~/utils/supertokens/index.server";
import { commonRoutes } from "~/utils/constants";

// See our NOTES on Session Refreshing via Browser Navigation for more info.
// TODO: Do we need to handle error cases?
export const loader: LoaderFunction = async ({ request }) => {
try {
const responseHeaders = await SuperTokensHelpers.refreshToken(request.headers);
const { cookies, responseHeaders } = await SuperTokensHelpers.refreshToken(request.headers);

responseHeaders.set("Location", new URL(request.url).searchParams.get("returnUrl") || "/");
return new Response(null, { status: 302, statusText: "OK", headers: responseHeaders });
const headers = new Headers({ Location: new URL(request.url).searchParams.get("returnUrl") || "/" });
cookies.forEach(setCookiesFromMap(headers));
responseHeaders.forEach(setHeadersFromMap(headers));
return new Response(null, { status: 302, statusText: "OK", headers });
} catch (error) {
// TODO: Are there better ways to handle error cases?
return new Response(null, { status: 302, statusText: "OK", headers: { Location: commonRoutes.login } });
Expand Down
10 changes: 6 additions & 4 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { LoaderFunction, ActionFunction, LinksFunction } from "@remix-run/n
import { Form, Link, useLoaderData, useActionData, useLocation } from "@remix-run/react";
import { useEffect } from "react";
import useFormErrors from "~/hooks/useFormErrors";
import { SuperTokensHelpers } from "~/utils/supertokens/index.server";
import { SuperTokensHelpers, setCookiesFromMap, setHeadersFromMap } from "~/utils/supertokens/index.server";
import { validateEmail, validatePassword } from "~/utils/validation";
import { commonRoutes } from "~/utils/constants";

Expand Down Expand Up @@ -141,7 +141,7 @@ export const action: ActionFunction = async ({ request }) => {

// Attempt Sign In / Sign Up
const normalizedMode: LoaderData["mode"] = mode === "signup" ? "signup" : "signin";
const { status, responseHeaders } = await SuperTokensHelpers[normalizedMode](email, password);
const { status, cookies, responseHeaders } = await SuperTokensHelpers[normalizedMode](email, password);

// Auth failed
if (status === "WRONG_CREDENTIALS_ERROR") {
Expand All @@ -153,6 +153,8 @@ export const action: ActionFunction = async ({ request }) => {
}

// Auth succeeded
responseHeaders.set("Location", new URL(request.url).searchParams.get("returnUrl") || "/");
return new Response(null, { status: 302, statusText: "OK", headers: responseHeaders });
const headers = new Headers({ Location: new URL(request.url).searchParams.get("returnUrl") || "/" });
cookies.forEach(setCookiesFromMap(headers));
responseHeaders.forEach(setHeadersFromMap(headers));
return new Response(null, { status: 302, statusText: "OK", headers });
};
13 changes: 9 additions & 4 deletions app/routes/logout.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import type { LoaderFunction } from "@remix-run/node";
import { SuperTokensHelpers } from "~/utils/supertokens/index.server";
import { SuperTokensHelpers, setCookiesFromMap, setHeadersFromMap } from "~/utils/supertokens/index.server";
import { commonRoutes } from "~/utils/constants";

// TODO: Do we need to handle error cases?
export const loader: LoaderFunction = async ({ request }) => {
const responseHeaders = await SuperTokensHelpers.logout(request.headers, request.method.toLowerCase() as "get");
const { cookies, responseHeaders } = await SuperTokensHelpers.logout(
request.headers,
request.method.toLowerCase() as "get"
);

responseHeaders.set("Location", commonRoutes.login);
return new Response(null, { status: 302, statusText: "OK", headers: responseHeaders });
const headers = new Headers({ Location: commonRoutes.login });
cookies.forEach(setCookiesFromMap(headers));
responseHeaders.forEach(setHeadersFromMap(headers));
return new Response(null, { status: 302, statusText: "OK", headers });
};
32 changes: 18 additions & 14 deletions app/utils/supertokens/SuperTokensDataOutput.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,35 @@ import { serialize } from "cookie"; // WARNING: This is a dependency of `SuperTo
*/
class SuperTokensDataOutput {
// SuperTokens Hijacks
#wrapperUsed: true = true;
#wrapperUsed = true as const;

// Custom Data
#headers = new Headers();
#headers = new Map<string, string | string[]>();
#cookies = new Map<string, string>();

/** Internal getter used strictly for communicating with SuperTokens */
get wrapperUsed(): true {
return this.#wrapperUsed;
}

/** Exposes the auth response `Headers` generated by `SuperTokens` */
get responseHeaders(): Headers {
/** Exposes the auth response headers generated by `SuperTokens` */
get responseHeaders() {
return this.#headers;
}

/** Exposes the cookies generated by `SuperTokens`. Include these in your response. */
get cookies() {
return this.#cookies;
}

/** Internal method used strictly for communicating with `SuperTokens`. */
setHeader(key: string, value: string, allowDuplicateKey: boolean): void {
if (this.#headers.has(key) && allowDuplicateKey) this.#headers.append(key, value);
// Append Header
if (this.#headers.has(key) && allowDuplicateKey) {
const currentValue = this.#headers.get(key) as string | string[];
this.#headers.set(key, Array.isArray(currentValue) ? currentValue.concat(value) : [currentValue, value]);
}
// Set or Reset Header
else this.#headers.set(key, value);
}

Expand All @@ -47,17 +57,11 @@ class SuperTokensDataOutput {
sameSite: "strict" | "lax" | "none"
): void {
// Derive cookie details
const serializeOpts = { domain, secure, httpOnly, expires: new Date(expires), path, sameSite };
const cookie = serialize(key, value, serializeOpts);
const cookieOpts = { domain, secure, httpOnly, expires: new Date(expires), path, sameSite };
const cookie = serialize(key, value, cookieOpts);

// Add (or replace) new (or old) cookie
const [cookieKey, ...cookieValueAndConfig] = cookie.split("=");
this.#cookies.set(cookieKey, cookieValueAndConfig.join("="));

// Re-assign cookie header
const COOKIE_HEADER = "Set-Cookie";
this.#headers.delete(COOKIE_HEADER);
this.#cookies.forEach((valueAndConfig, key) => this.#headers.append(COOKIE_HEADER, `${key}=${valueAndConfig}`));
this.#cookies.set(key, cookie);
}

/*
Expand Down
14 changes: 14 additions & 0 deletions app/utils/supertokens/headersHelpers.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** A `Map.forEach` helper that sets cookies to the provided `Headers` */
export function setCookiesFromMap(headers: Headers) {
return function setCookies(value: string): void {
headers.append("Set-Cookie", value);
};
}

/** A `Map.forEach` helper that sets headers for the provided `Headers` */
export function setHeadersFromMap(headers: Headers) {
return function setHeaders(value: string | string[], name: string): void {
if (typeof value === "string") headers.set(name, value);
else value.forEach((v) => headers.append(name, v));
};
}
24 changes: 14 additions & 10 deletions app/utils/supertokens/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@ import { commonRoutes } from "~/utils/constants";
import SuperTokensDataInput from "./SuperTokensDataInput.server";
import SuperTokensDataOutput from "./SuperTokensDataOutput.server";

export * from "./headersHelpers.server";

/* -------------------- Utility Classes -------------------- */
export const SuperTokensData = { Input: SuperTokensDataInput, Output: SuperTokensDataOutput };

/* -------------------- Utility Functions -------------------- */
type HeadersDetails = { cookies: Map<string, string>; responseHeaders: Map<string, string | string[]> };

type SignInResult =
| { status: "WRONG_CREDENTIALS_ERROR"; responseHeaders?: undefined }
| { status: "OK"; responseHeaders: Headers };
| ({ status: "WRONG_CREDENTIALS_ERROR" } & { [K in keyof HeadersDetails]?: undefined })
| ({ status: "OK" } & HeadersDetails);

type SignUpResult =
| { status: "EMAIL_ALREADY_EXISTS_ERROR"; responseHeaders?: undefined }
| { status: "OK"; responseHeaders: Headers };
| ({ status: "EMAIL_ALREADY_EXISTS_ERROR" } & { [K in keyof HeadersDetails]?: undefined })
| ({ status: "OK" } & HeadersDetails);

type ResetPasswordStatus = Awaited<ReturnType<typeof EmailPassword["resetPasswordUsingToken"]>>["status"];
const recipeId = "emailpassword";
Expand All @@ -28,7 +32,7 @@ export const SuperTokensHelpers = {
const { status, user } = signinResult;
const output = new SuperTokensData.Output();
await Session.createNewSession(output, user.id);
return { status, responseHeaders: output.responseHeaders };
return { status, cookies: output.cookies, responseHeaders: output.responseHeaders };
},

async signup(email: string, password: string): Promise<SignUpResult> {
Expand All @@ -38,7 +42,7 @@ export const SuperTokensHelpers = {
const { status, user } = signupResult;
const output = new SuperTokensData.Output();
await Session.createNewSession(output, user.id);
return { status, responseHeaders: output.responseHeaders };
return { status, cookies: output.cookies, responseHeaders: output.responseHeaders };
},

async emailExists(email: string): Promise<boolean> {
Expand All @@ -49,24 +53,24 @@ export const SuperTokensHelpers = {
* @param headers The headers from the request object
* @param method The HTTP method of the request
*/
async logout(headers: Headers, method: HTTPMethod): Promise<Headers> {
async logout(headers: Headers, method: HTTPMethod): Promise<HeadersDetails> {
const input = new SuperTokensData.Input({ headers, method });
const output = new SuperTokensData.Output();

const session = await Session.getSession(input, output, { sessionRequired: false });
await session?.revokeSession(); // This implicitly clears the auth cookies from `output`
return output.responseHeaders;
return { cookies: output.cookies, responseHeaders: output.responseHeaders };
},

/**
* @param headers The headers from the request object
*/
async refreshToken(headers: Headers): Promise<Headers> {
async refreshToken(headers: Headers): Promise<HeadersDetails> {
const input = new SuperTokensData.Input({ headers });
const output = new SuperTokensData.Output();

await Session.refreshSession(input, output);
return output.responseHeaders;
return { cookies: output.cookies, responseHeaders: output.responseHeaders };
},

// NOTE: Fails silently for unknown emails intentionally
Expand Down

0 comments on commit bffa41d

Please sign in to comment.