Skip to content

Commit

Permalink
Allow customization of the session key used to store the user data (#2)
Browse files Browse the repository at this point in the history
* Allow customization of the session key used to store the user data

* Fix tests

* Change getter with readonly public property
  • Loading branch information
sergiodxa committed Aug 13, 2021
1 parent 5eafa45 commit 61816d7
Show file tree
Hide file tree
Showing 16 changed files with 272 additions and 51 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export let action: ActionFunction = async ({ request }) => {
// logged-in, if not a redirect will be performed to the login URL
return authenticator.authenticate("local", request, async (user) => {
let session = await getSession(request.headers.get("Cookie"));
session.set(authenticator.sessionKey, user);
return redirect("/dashboard", {
headers: {
"Set-Cookie": await commitSession(session),
Expand Down
2 changes: 1 addition & 1 deletion docs/authenticator.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export let action: ActionFunction = async ({ request }) => {
// get the session from the cookie
let session = await getSession(request.headers.get("Cookie"));
// store the user in the session (it's important that you do this)
session.set("user", user);
session.set(authenticator.sessionKey, user);
// commit the session and redirect to another route of your app
return redirect("/dashboard", {
headers: {
Expand Down
2 changes: 2 additions & 0 deletions docs/strategies/basic.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# BasicStrategy

TBD
2 changes: 2 additions & 0 deletions docs/strategies/custom.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# CustomStrategy

TBD
2 changes: 2 additions & 0 deletions docs/strategies/local.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# LocalStrategy

TBD
3 changes: 2 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@ Since the Authenticator read from a session storage object you created and the s
```ts
import { Request } from "remix";
import { sessionStorage } from "./session.server";
import { authenticator } from "./authenticator.server";
import { loader } from "./routes/dashboard";

describe("Dashboard", () => {
test("Loader - is signed in", () => {
let session = await sessionStorage.getSession(); // get a new Session object
session.set("user", fakeUser); // set a fake user in the session
session.set(authenticator.sessionKey, fakeUser); // set a fake user in the session
let request = new Request("/dashboard", {
// Add a cookie header to the request with the session committed
headers: { Cookie: await sessionStorage.commitSession(session) },
Expand Down
127 changes: 123 additions & 4 deletions src/authenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,56 +4,175 @@ export interface AuthenticateCallback<User> {
(user: User): Promise<Response>;
}

/**
* Extra options for the authenticator.
*/
export interface AuthenticatorOptions {
sessionKey?: string;
}

/**
* Extra information from the Authenticator to the strategy
*/
export interface StrategyOptions {
sessionKey: string;
}

export interface Strategy<User> {
/**
* The name of the strategy.
* This will be used by the Authenticator to identify and retrieve the
* strategy.
*/
name: string;

/**
* The authentication flow of the strategy.
*
* This method receives the Request to authenticator and the session storage
* to use from the Authenticator. It may receive a custom callback.
*
* At the end of the flow, it will return a Response be use used by the
* application. This response could be a redirect or a custom one returned by
* the optional callback.
*/
authenticate(
request: Request,
sessionStorage: SessionStorage,
options: StrategyOptions,
callback?: AuthenticateCallback<User>
): Promise<Response>;
}

export class AuthorizationError extends Error {}

export class Authenticator<User = unknown> {
/**
* A map of the configured strategies, the key is the name of the strategy
* @private
*/
private strategies = new Map<string, Strategy<User>>();

constructor(private sessionStorage: SessionStorage) {}
public readonly sessionKey: string;

/**
* Create a new instance of the Authenticator.
*
* It receives a instance of the SessionStorage. This session storage could
* be created using any method exported by Remix, this includes:
* - `createSessionStorage`
* - `createFileSystemSessionStorage`
* - `createCookieSessionStorage`
* - `createMemorySessionStorage`
*
* It optionally receives an object with extra options. The supported options
* are:
* - `sessionKey`: The key used to store and red the user in the session storage.
* @example
* import { sessionStorage } from "./session.server";
* let authenticator = new Authenticator(sessionStorage);
* @example
* import { sessionStorage } from "./session.server";
* let authenticator = new Authenticator(sessionStorage, {
* sessionKey: "token",
* });
*/
constructor(
private sessionStorage: SessionStorage,
options: AuthenticatorOptions = {}
) {
this.sessionKey = options.sessionKey || "user";
}

/**
* Call this method with the Strategy, the optional name allows you to setup
* the same strategy multiple times with different names.
* It returns the Authenticator instance for concatenation.
* @example
* authenticator
* .use(new SomeStrategy({}, (user) => Promise.resolve(user)))
* .use(new SomeStrategy({}, (user) => Promise.resolve(user)), "another");
*/
use(strategy: Strategy<User>, name?: string): Authenticator {
this.strategies.set(name ?? strategy.name, strategy);
return this;
}

/**
* Call this method with the name of the strategy you want to remove.
* It returns the Authenticator instance for concatenation.
* @example
* authenticator.unuse("another").unuse("some");
*/
unuse(name: string): Authenticator {
this.strategies.delete(name);
return this;
}

/**
* Call this to authenticate a request using some strategy. You pass the name
* of the strategy you want to use and the request to authenticate.
* The optional callback allows you to do something with the user object
* before returning a new Response. In case it's not provided the strategy
* will return a new Response and set the user to the session.
* @example
* let action: ActionFunction = ({ request }) => {
* return authenticator.authenticate("some", request);
* };
* @example
* let action: ActionFunction = ({ request }) => {
* return authenticator.authenticate("some", request, async user => {
* let session = await getSession(request.headers.get("Cookie"));
* session.set(authenticator.key, user);
* return redirect("/private", {
* "Set-Cookie": await commitSession(session),
* });
* });
* };
*/
authenticate(
strategy: string,
request: Request,
callback?: AuthenticateCallback<User>
): Promise<Response> {
const strategyObj = this.strategies.get(strategy);
if (!strategyObj) throw new Error(`Strategy ${strategy} not found.`);
let options: StrategyOptions = {
sessionKey: this.sessionKey,
};
if (!callback) {
return strategyObj.authenticate(request.clone(), this.sessionStorage);
return strategyObj.authenticate(
request.clone(),
this.sessionStorage,
options
);
}
return strategyObj.authenticate(
request.clone(),
this.sessionStorage,
options,
callback
);
}

/**
* Call this to check if the user is authenticated. It will return a Promise
* with the user object or null, you can use this to check if the user is
* logged-in or not withour triggering the whole authentication flow.
* @example
* let loader: LoaderFunction = async ({ request }) => {
* let user = await authenticator.isAuthenticated(request);
* if (!user) return redirect("/login");
* // do something with the user
* return json(data);
* }
*/
async isAuthenticated(request: Request): Promise<User | null> {
let session = await this.sessionStorage.getSession(
request.clone().headers.get("Cookie")
request.headers.get("Cookie")
);

let user: User | null = session.get("user");
let user: User | null = session.get(this.sessionKey);

if (user) return user;
return null;
Expand Down
7 changes: 6 additions & 1 deletion src/strategies/basic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Request, Response, SessionStorage } from "@remix-run/node";
import { AuthenticateCallback, Strategy } from "../authenticator";
import {
AuthenticateCallback,
Strategy,
StrategyOptions,
} from "../authenticator";

export interface BasicStrategyOptions {
realm?: string;
Expand Down Expand Up @@ -63,6 +67,7 @@ export class BasicStrategy<User> implements Strategy<User> {
async authenticate(
request: Request,
_sessionStorage: SessionStorage,
_options: StrategyOptions,
callback?: AuthenticateCallback<User>
): Promise<Response> {
if (!callback) {
Expand Down
17 changes: 13 additions & 4 deletions src/strategies/custom.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { Request, Response, SessionStorage } from "@remix-run/node";
import { AuthenticateCallback, Strategy } from "../authenticator";
import {
AuthenticateCallback,
Strategy,
StrategyOptions,
} from "../authenticator";

export interface CustomStrategyVerifyCallback<User> {
(request: Request, sessionStorage: SessionStorage): Promise<User>;
(
request: Request,
sessionStorage: SessionStorage,
options: StrategyOptions
): Promise<User>;
}

/**
Expand Down Expand Up @@ -31,14 +39,15 @@ export class CustomStrategy<User> implements Strategy<User> {
async authenticate(
request: Request,
sessionStorage: SessionStorage,
callback?: AuthenticateCallback<User>
options: StrategyOptions,
callback: AuthenticateCallback<User>
): Promise<Response> {
if (!callback) {
throw new TypeError(
"The authenticate callback on CustomStrategy is required."
);
}

return callback(await this.verify(request, sessionStorage));
return callback(await this.verify(request, sessionStorage, options));
}
}
4 changes: 3 additions & 1 deletion src/strategies/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AuthenticateCallback,
AuthorizationError,
Strategy,
StrategyOptions,
} from "../authenticator";

export interface LocalStrategyOptions {
Expand Down Expand Up @@ -40,6 +41,7 @@ export class LocalStrategy<User> implements Strategy<User> {
async authenticate(
request: Request,
sessionStorage: SessionStorage,
options: StrategyOptions,
callback?: AuthenticateCallback<User>
): Promise<Response> {
if (new URL(request.url).pathname !== this.loginURL) {
Expand Down Expand Up @@ -83,7 +85,7 @@ export class LocalStrategy<User> implements Strategy<User> {

// Because a callback was not provided, we are going to store the user
// data on the session and commit it as a cookie.
session.set("user", user);
session.set(options.sessionKey, user);
let cookie = await sessionStorage.commitSession(session);
return redirect("/", { headers: { "Set-Cookie": cookie } });
} catch (error: unknown) {
Expand Down
6 changes: 4 additions & 2 deletions src/strategies/oauth2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AuthenticateCallback,
AuthorizationError,
Strategy,
StrategyOptions,
} from "../authenticator";

export interface OAuth2Profile {
Expand Down Expand Up @@ -123,14 +124,15 @@ export class OAuth2Strategy<
async authenticate(
request: Request,
sessionStorage: SessionStorage,
options: StrategyOptions,
callback?: AuthenticateCallback<User>
): Promise<Response> {
let url = new URL(request.url);
let session = await sessionStorage.getSession(
request.headers.get("Cookie")
);

let user: User | null = session.get("user") ?? null;
let user: User | null = session.get(options.sessionKey) ?? null;

// User is already authenticated
if (user) return callback ? callback(user) : redirect("/");
Expand Down Expand Up @@ -168,7 +170,7 @@ export class OAuth2Strategy<

// Because a callback was not provided, we are going to store the user data
// on the session and commit it as a cookie.
session.set("user", user);
session.set(options.sessionKey, user);
let cookie = await sessionStorage.commitSession(session);
return redirect("/", { headers: { "Set-Cookie": cookie } });
}
Expand Down
16 changes: 10 additions & 6 deletions test/authenticator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ describe(Authenticator, () => {
});

expect(
new Authenticator(sessionStorage).isAuthenticated(request)
new Authenticator(sessionStorage, { sessionKey: "user" }).isAuthenticated(
request
)
).resolves.toEqual(user);
});

Expand All @@ -34,15 +36,17 @@ describe(Authenticator, () => {

test("should be able to add a new strategy calling use", async () => {
let request = new Request("/");
let response = new Response("It works!");
let response = new Response("It works!", {
url: "/",
});

let authenticator = new Authenticator(sessionStorage);

expect(authenticator.use(new MockStrategy(response))).toBe(authenticator);
expect(authenticator.authenticate("mock", request)).resolves.toBe(response);
expect(await authenticator.authenticate("mock", request)).toEqual(response);
});

test("should be able to remove a strategy calling use", async () => {
test("should be able to remove a strategy calling unuse", async () => {
let response = new Response("It works!");

let authenticator = new Authenticator(sessionStorage);
Expand All @@ -51,11 +55,11 @@ describe(Authenticator, () => {
expect(authenticator.unuse("mock")).toBe(authenticator);
});

test.skip("should throw if the strategy was not found", async () => {
test("should throw if the strategy was not found", async () => {
let request = new Request("/");
let authenticator = new Authenticator(sessionStorage);

await expect(authenticator.authenticate("unknown", request)).toThrow(
expect(() => authenticator.authenticate("unknown", request)).toThrow(
"Strategy unknown not found."
);
});
Expand Down

0 comments on commit 61816d7

Please sign in to comment.