Skip to content

Commit

Permalink
feat: Multi tenant authentication (#355)
Browse files Browse the repository at this point in the history
  • Loading branch information
dipendraupreti committed May 10, 2023
1 parent cc6f970 commit 9b3f6d7
Show file tree
Hide file tree
Showing 43 changed files with 948 additions and 95 deletions.
1 change: 1 addition & 0 deletions packages/mercurius/src/buildContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const buildContext = async (request: FastifyRequest, reply: FastifyReply) => {
const context = {
config: request.config,
database: request.slonik,
dbSchema: request.dbSchema,
} as MercuriusContext;

if (plugins) {
Expand Down
1 change: 1 addition & 0 deletions packages/mercurius/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ declare module "mercurius" {
interface MercuriusContext {
config: ApiConfig;
database: Database;
dbSchema: string;
}
}

Expand Down
18 changes: 15 additions & 3 deletions packages/multi-tenant/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ When registered on a Fastify instance, the plugin will:
## Requirements

* `@dzangolab/fastify-config`
* `@dzangolab/fastify-mailer`
* `@dzangolab/fastify-mercurius`
* `@dzangolab/fastify-slonik`
* `@dzangolab/fastify-user`

## Tenants table

Expand All @@ -30,13 +33,13 @@ The table should contain the following columns:
In a simple repo:

```bash
npm install @dzangolab/fastify-config @dzangolab/fastify-slonik @dzangolab/fastify-multi-tenant
npm install @dzangolab/fastify-config @dzangolab/fastify-mailer @dzangolab/fastify-mercurius @dzangolab/fastify-slonik @dzangolab/fastify-multi-tenant @dzangolab/fastify-user
```

If using in a monorepo with pnpm:

```bash
pnpm add --filter "myrepo" @dzangolab/fastify-config @dzangolab/fastify-slonik @dzangolab/fastify-multi-tenant
pnpm add --filter "myrepo" @dzangolab/fastify-config @dzangolab/fastify-mailer @dzangolab/fastify-mercurius @dzangolab/fastify-slonik @dzangolab/fastify-multi-tenant @dzangolab/fastify-user
```

## Usage
Expand All @@ -62,11 +65,20 @@ const fastify = Fastify({
});

// Register fastify-config plugin
fastify.register(configPlugin, { config });
await fastify.register(configPlugin, { config });

// Register mailer plugin
await api.register(mailerPlugin);

// Register database plugin
await api.register(slonikPlugin);

// Register mercurius plugin
await api.register(mercuriusPlugin);

// Register user plugin
await api.register(userPlugin);

await fastify.register(multiTenantPlugin);

await fastify.listen({
Expand Down
10 changes: 9 additions & 1 deletion packages/multi-tenant/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,17 @@
"dependencies": {
"@dzangolab/postgres-migrations": "5.4.1",
"humps": "2.0.1",
"pg": "8.8.0"
"pg": "8.8.0",
"lodash.merge": "4.6.2"
},
"devDependencies": {
"@dzangolab/fastify-config": "0.31.3",
"@dzangolab/fastify-mailer": "0.31.3",
"@dzangolab/fastify-mercurius": "0.31.3",
"@dzangolab/fastify-slonik": "0.31.3",
"@dzangolab/fastify-user": "0.31.3",
"@types/humps": "2.0.2",
"@types/lodash.merge": "4.6.7",
"@types/node": "18.15.11",
"@types/pg": "8.6.6",
"@typescript-eslint/eslint-plugin": "5.59.2",
Expand All @@ -52,6 +56,7 @@
"mercurius": "12.2.0",
"prettier": "2.8.8",
"slonik": "33.1.4",
"supertokens-node": "12.1.6",
"tsconfig": "0.31.3",
"typescript": "4.9.5",
"vite": "4.3.5",
Expand All @@ -61,12 +66,15 @@
},
"peerDependencies": {
"@dzangolab/fastify-config": "0.31.3",
"@dzangolab/fastify-mailer": "0.31.3",
"@dzangolab/fastify-mercurius": "0.31.3",
"@dzangolab/fastify-slonik": "0.31.3",
"@dzangolab/fastify-user": "0.31.3",
"fastify": ">=4.9.2",
"fastify-plugin": ">=4.3.0",
"mercurius": ">=12.2.0",
"slonik": ">=33.1.4",
"supertokens-node": ">=12.1.6",
"zod": ">=3.21.4"
},
"engines": {
Expand Down
2 changes: 2 additions & 0 deletions packages/multi-tenant/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export { default } from "./plugin";

export { default as TenantService } from "./model/tenants/service";

export { default as thirdPartyEmailPassword } from "./supertokens/recipes";

export type {
MultiTenantConfig,
Tenant,
Expand Down
5 changes: 3 additions & 2 deletions packages/multi-tenant/src/lib/discoverTenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { ApiConfig } from "@dzangolab/fastify-config";

import TenantService from "../model/tenants/service";

import type { Tenant } from "../types";
import type { Database } from "@dzangolab/fastify-slonik";

const discoverTenant = async (
config: ApiConfig,
database: Database,
host: string
) => {
): Promise<Tenant | null> => {
const reservedSlugs = config.multiTenant?.reserved?.slugs;
const reservedDomains = config.multiTenant?.reserved?.domains;

Expand All @@ -32,7 +33,7 @@ const discoverTenant = async (
const tenant = await tenantService.findByHostname(host);

if (tenant) {
return tenant;
return tenant as Tenant;
}

throw new Error("Tenant not found");
Expand Down
31 changes: 31 additions & 0 deletions packages/multi-tenant/src/lib/getUserService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { UserService } from "@dzangolab/fastify-user";

import getMultiTenantConfig from "./getMultiTenantConfig";

import type { Tenant } from "../types/tenant";
import type { ApiConfig } from "@dzangolab/fastify-config";
import type { Database } from "@dzangolab/fastify-slonik";
import type {
User,
UserCreateInput,
UserUpdateInput,
} from "@dzangolab/fastify-user";
import type { QueryResultRow } from "slonik";

const getUserService = (
config: ApiConfig,
slonik: Database,
tenant?: Tenant
) => {
const multiTenantConfig = getMultiTenantConfig(config);

const dbSchema = tenant ? tenant[multiTenantConfig.table.columns.slug] : "";

return new UserService<
User & QueryResultRow,
UserCreateInput,
UserUpdateInput
>(config, slonik, dbSchema);
};

export default getUserService;
44 changes: 40 additions & 4 deletions packages/multi-tenant/src/lib/updateContext.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,48 @@
import type { FastifyRequest } from "fastify";
import { wrapResponse } from "supertokens-node/framework/fastify";
import Session from "supertokens-node/recipe/session";
import UserRoles from "supertokens-node/recipe/userroles";

import getUserService from "../lib/getUserService";

import type { FastifyRequest, FastifyReply } from "fastify";
import type { MercuriusContext } from "mercurius";

const updateContext = async (
context: MercuriusContext,
request: FastifyRequest
request: FastifyRequest,
reply: FastifyReply
) => {
if (request.config.mercurius.enabled) {
context.tenant = request.tenant;
const { config, slonik, tenant } = request;

context.tenant = tenant;

const session = await Session.getSession(request, wrapResponse(reply), {
sessionRequired: false,
});

const userId = session?.getUserId();

if (userId && !context.user) {
const service = getUserService(config, slonik, tenant);

/* eslint-disable-next-line unicorn/no-null */
let user;

try {
user = await service.findById(userId);
} catch {
// FIXME [OP 2022-AUG-22] Handle error properly
// DataIntegrityError
}

if (!user) {
throw new Error("Unable to find user");
}

const { roles } = await UserRoles.getRolesForUser(userId);

context.user = user;
context.roles = roles;
}
};

Expand Down
3 changes: 2 additions & 1 deletion packages/multi-tenant/src/migratePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import FastifyPlugin from "fastify-plugin";

import changeSchema from "./lib/changeSchema";
import getDatabaseConfig from "./lib/getDatabaseConfig";
import getMultiTenantConfig from "./lib/getMultiTenantConfig";
import initializePgPool from "./lib/initializePgPool";
import getMultiTenantConfig from "./lib/multiTenantConfig";
import runMigrations from "./lib/runMigrations";
import Service from "./model/tenants/service";

Expand Down Expand Up @@ -51,6 +51,7 @@ const plugin = async (
);
}
} catch (error: unknown) {
/* eslint-disable-next-line unicorn/consistent-destructuring */
fastify.log.error("🔴 multi-tenant: Failed to run tenant migrations");
throw error;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const createConfig = (multiTenantConfig: Partial<MultiTenantConfig>) => {
},
},
version: "0.1",
};
} as ApiConfig;

return config;
};
Expand Down
2 changes: 1 addition & 1 deletion packages/multi-tenant/src/model/tenants/service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { BaseService } from "@dzangolab/fastify-slonik";

import getMultiTenantConfig from "./../../lib/getMultiTenantConfig";
import SqlFactory from "./sqlFactory";
import getDatabaseConfig from "../../lib/getDatabaseConfig";
import getMultiTenantConfig from "../../lib/multiTenantConfig";
import runMigrations from "../../lib/runMigrations";

import type { Tenant as BaseTenant } from "../../types";
Expand Down
13 changes: 13 additions & 0 deletions packages/multi-tenant/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import FastifyPlugin from "fastify-plugin";
import merge from "lodash.merge";

import updateContext from "./lib/updateContext";
import migratePlugin from "./migratePlugin";
import thirdPartyEmailPasswordConfig from "./supertokens/recipes";
import tenantDiscoveryPlugin from "./tenantDiscoveryPlugin";

import type { MercuriusEnabledPlugin } from "@dzangolab/fastify-mercurius";
Expand All @@ -20,6 +22,17 @@ const plugin = async (
// Register domain discovery plugin
await fastify.register(tenantDiscoveryPlugin);

const { config } = fastify;

const supertokensConfig = {
recipes: {
thirdPartyEmailPassword: thirdPartyEmailPasswordConfig,
},
};

// merge supertokens config
config.user.supertokens = merge(supertokensConfig, config.user.supertokens);

done();
};

Expand Down
33 changes: 33 additions & 0 deletions packages/multi-tenant/src/supertokens/recipes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
emailPasswordSignIn,
emailPasswordSignUp,
emailPasswordSignUpPOST,
thirdPartySignInUp,
thirdPartySignInUpPOST,
sendEmail,
emailPasswordSignInPOST,
generatePasswordResetTokenPOST,
getUserById,
} from "./third-party-email-password";

import type { ThirdPartyEmailPasswordRecipe } from "@dzangolab/fastify-user";

const thirdPartyEmailPasswordConfig: ThirdPartyEmailPasswordRecipe = {
override: {
apis: {
emailPasswordSignInPOST,
emailPasswordSignUpPOST,
generatePasswordResetTokenPOST,
thirdPartySignInUpPOST,
},
functions: {
emailPasswordSignIn,
emailPasswordSignUp,
getUserById,
thirdPartySignInUp,
},
},
sendEmail,
};

export default thirdPartyEmailPasswordConfig;
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { formatDate } from "@dzangolab/fastify-user";

import getUserService from "../../../lib/getUserService";
import Email from "../../utils/email";

import type { AuthUser } from "@dzangolab/fastify-user";
import type { FastifyInstance } from "fastify";
import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpassword";

const emailPasswordSignIn = (
originalImplementation: RecipeInterface,
fastify: FastifyInstance
): RecipeInterface["emailPasswordSignIn"] => {
const { config, log, slonik } = fastify;

return async (input) => {
input.email = Email.addTenantPrefix(
config,
input.email,
input.userContext.tenant
);

const originalResponse = await originalImplementation.emailPasswordSignIn(
input
);

if (originalResponse.status !== "OK") {
return originalResponse;
}

const userService = getUserService(
config,
slonik,
input.userContext.tenant
);

const user = await userService.findById(originalResponse.user.id);

if (!user) {
log.error(`User record not found for userId ${originalResponse.user.id}`);

return { status: "WRONG_CREDENTIALS_ERROR" };
}

user.lastLoginAt = Date.now();

await userService
.update(user.id, {
lastLoginAt: formatDate(new Date(user.lastLoginAt)),
})
/*eslint-disable-next-line @typescript-eslint/no-explicit-any */
.catch((error: any) => {
log.error(
`Unable to update lastLoginAt for userId ${originalResponse.user.id}`
);
log.error(error);
});

const authUser: AuthUser = {
...originalResponse.user,
...user,
};

return {
status: "OK",
user: authUser,
};
};
};

export default emailPasswordSignIn;

0 comments on commit 9b3f6d7

Please sign in to comment.