Bring in hapi-nextauth and dev support libs

This commit is contained in:
Abel Luck 2023-03-13 08:24:20 +00:00
parent feab3f90d7
commit 7aa1ec74eb
45 changed files with 14633 additions and 1141 deletions

View file

@ -0,0 +1,363 @@
import * as Hapi from "@hapi/hapi";
import HapiBasic from "@hapi/basic";
import NextAuthPlugin from ".";
describe("plugin option validation", () => {
let server;
beforeEach(async () => {
server = new Hapi.Server();
});
it("should throw when options contain no next auth adapter", async () => {
expect(server.register(NextAuthPlugin)).rejects.toThrow();
});
});
describe("plugin runtime", () => {
let server;
const user = { id: "abc", email: "abc@abc.abc" };
const session = {
id: "zyx",
userId: "abc",
expires: Date.now(),
sessionToken: "foo",
accessToken: "bar",
};
const start = async (mock) => {
await server.register(HapiBasic);
await server.register({
plugin: NextAuthPlugin,
options: {
nextAuthAdapterFactory: () => mock,
},
});
await server.start();
return server;
};
beforeEach(async () => {
server = new Hapi.Server({ port: 0 });
});
afterEach(async () => {
await server.stop();
});
it("createUser", async () => {
const createUser = jest.fn(() => user);
const profile = { email: "abc@abc.abc" };
await start({ createUser });
const { statusCode, result } = await server.inject({
method: "post",
url: "/api/nextauth/createUser",
payload: profile,
});
expect(statusCode).toBe(200);
expect(createUser).toHaveBeenCalledWith(profile);
expect(result).toStrictEqual(user);
});
it("createUser fails with invalid payload", async () => {
const createUser = jest.fn(() => user);
const profile = { name: "name" };
await start({ createUser });
const { statusCode } = await server.inject({
method: "post",
url: "/api/nextauth/createUser",
payload: profile,
});
expect(statusCode).toBe(400);
});
it("getUser", async () => {
const getUser = jest.fn(() => user);
await start({ getUser });
const { statusCode, result } = await server.inject({
method: "get",
url: "/api/nextauth/getUser/abc",
});
expect(statusCode).toBe(200);
expect(getUser).toHaveBeenCalledWith("abc");
expect(result).toBe(user);
});
it("getUserByEmail", async () => {
const getUserByEmail = jest.fn(() => user);
await start({ getUserByEmail });
const { statusCode, result } = await server.inject({
method: "get",
url: "/api/nextauth/getUserByEmail/abc@abc.abc",
});
expect(statusCode).toBe(200);
expect(getUserByEmail).toHaveBeenCalledWith("abc@abc.abc");
expect(result).toBe(user);
});
it("getUserByEmail fails with invalid email", async () => {
const getUserByEmail = jest.fn(() => user);
await start({ getUserByEmail });
const { statusCode } = await server.inject({
method: "get",
url: "/api/nextauth/getUserByEmail/notanemail@foo",
});
expect(statusCode).toBe(400);
});
it("getUserByProviderAccountId", async () => {
const getUserByProviderAccountId = jest.fn(() => user);
await start({ getUserByProviderAccountId });
const { statusCode, result } = await server.inject({
method: "get",
url: "/api/nextauth/getUserByProviderAccountId/foo/bar",
});
expect(statusCode).toBe(200);
expect(getUserByProviderAccountId).toHaveBeenCalledWith("foo", "bar");
expect(result).toBe(user);
});
it("updateUser", async () => {
const updateUser = jest.fn(() => user);
await start({ updateUser });
const { statusCode, result } = await server.inject({
method: "put",
url: "/api/nextauth/updateUser",
payload: user,
});
expect(statusCode).toBe(200);
expect(updateUser).toHaveBeenCalledWith(user);
expect(result).toStrictEqual(user);
});
it("updateUser fails with invalid payload", async () => {
const updateUser = jest.fn(() => user);
await start({ updateUser });
const { statusCode } = await server.inject({
method: "put",
url: "/api/nextauth/updateUser",
payload: {
// id not specified
email: "abc@abc.abc",
},
});
expect(statusCode).toBe(400);
});
it("linkUser", async () => {
const linkAccount = jest.fn(() => undefined);
const args = {
userId: "abc",
providerId: "foo",
providerType: "something",
providerAccountId: "bar",
refreshToken: "refreshToken",
accessToken: "accessToken",
accessTokenExpires: 10,
};
await start({ linkAccount });
const { statusCode } = await server.inject({
method: "put",
url: "/api/nextauth/linkAccount",
payload: args,
});
expect(statusCode).toBe(204);
expect(linkAccount.mock.calls.length).toBe(1);
});
it("createSession", async () => {
const createSession = jest.fn(() => session);
await start({ createSession });
const { statusCode, result } = await server.inject({
method: "post",
url: "/api/nextauth/createSession",
payload: user,
});
expect(statusCode).toBe(200);
expect(createSession).toHaveBeenCalledWith(user);
expect(result).toStrictEqual(session);
});
it("getSession", async () => {
const getSession = jest.fn(() => session);
await start({ getSession });
const { statusCode, result } = await server.inject({
method: "get",
url: "/api/nextauth/getSession/xyz",
});
expect(statusCode).toBe(200);
expect(getSession).toHaveBeenCalledWith("xyz");
expect(result).toBe(session);
});
it("updateSession", async () => {
const updateSession = jest.fn(() => session);
await start({ updateSession });
const { statusCode, result } = await server.inject({
method: "put",
url: "/api/nextauth/updateSession",
payload: session,
});
expect(statusCode).toBe(200);
expect(updateSession).toHaveBeenCalledWith(
{
...session,
expires: new Date(session.expires),
},
false
);
expect(result).toStrictEqual(session);
});
it("updateSession - force", async () => {
const updateSession = jest.fn(() => session);
await start({ updateSession });
const { statusCode, result } = await server.inject({
method: "put",
url: "/api/nextauth/updateSession?force=true",
payload: session,
});
expect(statusCode).toBe(200);
expect(updateSession).toHaveBeenCalledWith(
{
...session,
expires: new Date(session.expires),
},
true
);
expect(result).toStrictEqual(session);
});
it("deleteSession", async () => {
const deleteSession = jest.fn(() => undefined);
await start({ deleteSession });
const { statusCode } = await server.inject({
method: "delete",
url: "/api/nextauth/deleteSession/xyz",
});
expect(statusCode).toBe(204);
expect(deleteSession).toHaveBeenCalledWith("xyz");
});
});
describe("plugin authentication", () => {
const user = { id: "abc", email: "abc@abc.abc" };
const sharedSecret = "secret";
let server;
const start = async (mock) => {
await server.register(HapiBasic);
await server.register({
plugin: NextAuthPlugin,
options: {
nextAuthAdapterFactory: () => mock,
sharedSecret,
},
});
await server.start();
return server;
};
const basicHeader = (username, password) =>
"Basic " +
Buffer.from(username + ":" + password, "utf8").toString("base64");
beforeEach(async () => {
server = new Hapi.Server({ port: 0 });
});
afterEach(async () => {
await server.stop();
});
it("getUser - no auth header fails", async () => {
const getUser = jest.fn(() => user);
await start({ getUser });
const { statusCode } = await server.inject({
method: "get",
url: "/api/nextauth/getUser/abc",
});
expect(statusCode).toBe(401);
expect(getUser).toHaveBeenCalledTimes(0);
});
it("getUser - with auth header suceeds", async () => {
const getUser = jest.fn(() => user);
await start({ getUser });
const { statusCode, result } = await server.inject({
method: "get",
url: "/api/nextauth/getUser/abc",
headers: {
authorization: basicHeader(sharedSecret, ""),
},
});
expect(statusCode).toBe(200);
expect(getUser).toHaveBeenCalledWith("abc");
expect(result).toBe(user);
});
it("getUser - with invalid credentials fails", async () => {
const getUser = jest.fn(() => user);
await start({ getUser });
const { statusCode } = await server.inject({
method: "get",
url: "/api/nextauth/getUser/abc",
headers: {
authorization: basicHeader("wrong secret", ""),
},
});
expect(statusCode).toBe(401);
expect(getUser).toHaveBeenCalledTimes(0);
});
it("getUser - with secret in password field fails", async () => {
const getUser = jest.fn(() => user);
await start({ getUser });
const { statusCode } = await server.inject({
method: "get",
url: "/api/nextauth/getUser/abc",
headers: {
authorization: basicHeader("", "sharedSecret"),
},
});
expect(statusCode).toBe(401);
expect(getUser).toHaveBeenCalledTimes(0);
});
});

View file

@ -0,0 +1,105 @@
import * as Hapi from "@hapi/hapi";
import * as Hoek from "@hapi/hoek";
import * as Joi from "joi";
import { NextAuthPluginOptions } from "./types";
import * as Routes from "./routes";
const minimumProfileSchema = Joi.object()
.keys({
email: Joi.string().email().required(),
})
.unknown(true);
const minimumUserSchema = Joi.object()
.keys({
id: Joi.string().required(),
email: Joi.string().email().required(),
})
.unknown(true);
const minimumSessionSchema = Joi.object()
.keys({
id: Joi.string().required(),
userId: Joi.string().required(),
expires: Joi.number().required(),
sessionToken: Joi.string().required(),
accessToken: Joi.string().required(),
})
.unknown(true);
const defaultOptions = {
basePath: "/api/nextauth",
validators: {
userId: Joi.string().required(),
profile: minimumProfileSchema,
user: minimumUserSchema,
session: minimumSessionSchema,
},
tags: [],
};
const validateAuth = (sharedSecret) => (request, username, password) => {
// we follow stripe's lead here for authenticating with basic auth
// the shared secret should be bassed as the basic auth username, the password should be empty
if (password !== "") {
console.error(
"hapi-nextauth: attempted authentication with basic auth password. only the username should be defined."
);
return { isValid: false, credentials: {} };
}
const isValid = username === sharedSecret;
const credentials = {
id: "nextauth-frontend",
};
return { isValid, credentials };
};
const register = async <TUser, TProfile, TSession>(
server: Hapi.Server,
pluginOpts?: NextAuthPluginOptions<TUser, TProfile, TSession>
): Promise<void> => {
const options: NextAuthPluginOptions<
TUser,
TProfile,
TSession
> = Hoek.applyToDefaults(
// a little type gymnastics here to workaround poor typing
defaultOptions as unknown,
pluginOpts
) as NextAuthPluginOptions<TUser, TProfile, TSession>;
if (!options.nextAuthAdapterFactory) {
throw new Error(
"You must pass a NextAuthAdapterFactory instance to hapi-nextauth."
);
}
server.validator(Joi);
let auth = "hapi-nextauth";
if (options.sharedSecret) {
server.dependency(["@hapi/basic"]);
server.auth.strategy(auth, "basic", {
validate: validateAuth(options.sharedSecret),
});
} else {
console.warn(
"hapi-nextauth: AUTHENTICATION OF FRONTEND TO NEXTAUTH ENDPOINTS DISABLED!"
);
auth = undefined;
}
await Routes.register(server, options, auth);
};
const nextAuthPlugin = {
register,
name: "@digiresilience/hapi-nextauth",
version: "0.0.3",
};
export * from "./types";
export default nextAuthPlugin;

View file

@ -0,0 +1,262 @@
/* eslint-disable unicorn/no-null */
import Toys from "@hapipal/toys";
import * as Joi from "joi";
import * as Hapi from "@hapi/hapi";
import { NextAuthPluginOptions } from "./types";
export const register = async <TUser, TProfile, TSession>(
server: Hapi.Server,
opts: NextAuthPluginOptions<TUser, TProfile, TSession>,
auth?: string
): Promise<void> => {
const withDefaults = Toys.withRouteDefaults({
options: {
auth,
tags: opts.tags,
},
});
server.route([
withDefaults({
method: "POST",
path: `${opts.basePath}/createUser`,
options: {
validate: {
payload: opts.validators.profile,
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<TUser> => {
const payload: TProfile = request.payload as TProfile;
const r = await opts
.nextAuthAdapterFactory(request)
.createUser(payload);
return h.response(r);
},
description: "Create a user from a profile",
},
}),
withDefaults({
method: "GET",
path: `${opts.basePath}/getUser/{userId}`,
options: {
validate: {
params: {
userId: opts.validators.userId,
},
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<TUser | null> => {
const id = request.params.userId;
const r = await opts.nextAuthAdapterFactory(request).getUser(id);
if (!r) return h.response().code(404);
return h.response(r);
},
description: "Get a user by id",
},
}),
withDefaults({
method: "GET",
path: `${opts.basePath}/getUserByEmail/{userEmail}`,
options: {
validate: {
params: {
userEmail: Joi.string().email(),
},
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<TUser | null> => {
const email = request.params.userEmail;
const r = await opts
.nextAuthAdapterFactory(request)
.getUserByEmail(email);
if (!r) return h.response().code(404);
return h.response(r);
},
description: "Get a user by email",
},
}),
withDefaults({
method: "GET",
path: `${opts.basePath}/getUserByProviderAccountId/{providerId}/{providerAccountId}`,
options: {
validate: {
params: {
providerId: Joi.string(),
providerAccountId: Joi.string(),
},
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<TUser | null> => {
const { providerId, providerAccountId } = request.params;
const r = await opts
.nextAuthAdapterFactory(request)
.getUserByProviderAccountId(providerId, providerAccountId);
if (!r) return h.response().code(404);
return h.response(r);
},
description: "Get a user by provider id and provider account id",
},
}),
withDefaults({
method: "PUT",
path: `${opts.basePath}/updateUser`,
options: {
validate: {
payload: opts.validators.user,
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<TUser> => {
const payload: TUser = request.payload as TUser;
const r = await opts
.nextAuthAdapterFactory(request)
.updateUser(payload);
if (!r) return h.response().code(404);
return h.response(r);
},
description: "Update a user's data",
},
}),
withDefaults({
method: "PUT",
path: `${opts.basePath}/linkAccount`,
options: {
validate: {
payload: Joi.object({
userId: opts.validators.userId,
providerId: Joi.string(),
providerType: Joi.string(),
providerAccountId: Joi.string(),
refreshToken: Joi.string().optional().allow(null),
accessToken: Joi.string().optional().allow(null),
accessTokenExpires: Joi.number().optional().allow(null),
}).options({ presence: "required" }),
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<void> => {
const {
userId,
providerId,
providerType,
providerAccountId,
refreshToken,
accessToken,
accessTokenExpires,
} = request.payload;
await opts
.nextAuthAdapterFactory(request)
.linkAccount(
userId,
providerId,
providerType,
providerAccountId,
refreshToken,
accessToken,
accessTokenExpires
);
return h.response().code(204);
},
description: "Link a provider account with a user",
},
}),
withDefaults({
method: "POST",
path: `${opts.basePath}/createSession`,
options: {
validate: {
payload: opts.validators.user,
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<TSession> => {
const payload: TUser = request.payload as TUser;
const r = await opts
.nextAuthAdapterFactory(request)
.createSession(payload);
return h.response(r);
},
description: "Create a new session for a user",
},
}),
withDefaults({
method: "GET",
path: `${opts.basePath}/getSession/{sessionToken}`,
options: {
validate: {
params: {
sessionToken: Joi.string(),
},
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<TSession | null> => {
const token = request.params.sessionToken;
const r = await opts
.nextAuthAdapterFactory(request)
.getSession(token);
if (!r) return h.response().code(404);
return h.response(r);
},
description: "Get a session by its token",
},
}),
withDefaults({
method: "PUT",
path: `${opts.basePath}/updateSession`,
options: {
validate: {
payload: opts.validators.session,
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<TSession> => {
const payload = {
...request.payload,
expires: new Date(request.payload.expires),
};
const force = Boolean(request.query.force);
const r = await opts
.nextAuthAdapterFactory(request)
.updateSession(payload, force);
if (!r) return h.response().code(204);
return h.response(r);
},
description: "Update a session for a user",
},
}),
withDefaults({
method: "DELETE",
path: `${opts.basePath}/deleteSession/{sessionToken}`,
options: {
validate: {
params: {
sessionToken: Joi.string(),
},
},
handler: async (
request: Hapi.Request,
h: Hapi.Toolkit
): Promise<void> => {
const token = request.params.sessionToken;
await opts.nextAuthAdapterFactory(request).deleteSession(token);
return h.response().code(204);
},
description: "Delete a user's session",
},
}),
]);
};

View file

@ -0,0 +1,21 @@
import type { AdapterInstance } from "next-auth/adapters";
import type { NumberSchema, StringSchema, ObjectSchema } from "joi";
import type { Request } from "@hapi/hapi";
export type AdapterFactory<TUser, TProfile, TSession> = (
request: Request
) => AdapterInstance<TUser, TProfile, TSession>;
export interface NextAuthPluginOptions<TUser, TProfile, TSession> {
nextAuthAdapterFactory: AdapterFactory<TUser, TProfile, TSession>;
validators?: {
profile: ObjectSchema;
userId: StringSchema | NumberSchema;
user: ObjectSchema;
session: ObjectSchema;
};
sharedSecret?: string;
basePath?: string;
tags?: string[];
}