Skip to main content

Guide: How to Build a Multi-Tenant App featuring Organization Switching using React, Fastify and SlashID

Introduction

In this guide you'll learn how to build SlashID Notes, our multi-tenant example app featuring organization switching. The SlashID Notes frontend uses React and @slashid/react - the SlashID React SDK. The backend uses Node.js, Fastify, and an API client generated from the SlashID OpenAPI specification using openapi-typescript-codegen.

This guide assumes you have some existing knowledge of this tech stack and will focus on the key components needed to build a multi-tenant web app with organization switching.

A simplified reproduction of the SlashID Notes app can be found here, we suggest that you use it to follow along with this guide.

What is Fastify?

In case you've never heard of it: Fastify is a modern web framework for Node.js that aims to provide the best developer experience with the least overhead and a powerful plugin architecture. It was inspired by Hapi, Restify and Express.

When compared side-by-side with Express, Fastify is a faster & more efficient alternative. What we love about Fastify: it's fast, it has first-party support for async/await and it supports validation & serialization using the JSON Schema standard.

Features

At the end of this guide you'll get a full-stack multi-tenant web application with the following features:

  • All users login & signup in a single place
  • Every user has a personal organization, which acts as the users home
  • Users can create new organizations
  • Users can invite others to collaborate and share content
  • Users can switch between organizations they are a member of

1. Set up your project

Frontend

Install the SlashID libraries.

npm install @slashid/slashid @slashid/react

In your React main file implement the <SlashIDProvider>.

// client/main.tsx
import { SlashIDProvider } from '@slashid/react'

<SlashIDProvider
oid="YOUR_ROOT_ORG_ID"
baseApiUrl='https://api.slashid.com'
tokenStorage='localStorage'
>
<App />
</SlashIDProvider>
info

Your organization id can be found in the SlashID Console after signing up.

Throughout this guide "YOUR_ROOT_ORG_ID" is used to represent the id of your parent organization, all user suborganizations are created as children.

Next you need to conditionally display the login view depending on if the user is logged in. To keep things simple you will not implement a router, instead you can use the <LoggedIn> and <LoggedOut> conditional rendering helpers from @slashid/react.

// client/App.tsx
import { LoggedIn, LoggedOut } from '@slashid/react'
import Login from './Login'
import Dashboard from './Dashboard'

export default function App = () => {
return (
<>
<LoggedIn>
<Dashboard />
</LoggedIn>
<LoggedOut>
<Login />
</LoggedOut>
</>
)
}

That's it! Now you're ready to start building the React app, you'll implement <Dashboard> and <Login> later.

Backend

Generate an API client using the SlashID OpenAPI Specification, you can do this with npx and the openapi-typescript-codegen library.

npx openapi-typescript-codegen --input https://cdn.slashid.com/slashid-openapi-latest.yaml --output ./slashid --client axios

This generates a folder /slashid containing the API client.

The generated code has two dependencies axios and form-data, install them:

npm install axios form-data

In your server main file configure the API client to send the SlashID-API-Key header with every request:

// server/index.ts
import { OpenAPI } from '../slashid'

OpenAPI.HEADERS = {
'SlashID-API-Key': "YOUR_ROOT_ORG_API_KEY"
}
info

Your organization API key can be found in the SlashID Console after signing up.

Finally before you get started you need to create Groups, these will allow you to grant user permissions later. This only needs to be done once but for simplicity in this demo you'll do it on each startup.

Create a function createGroups that creates two groups "collaborator" and "admin".

// server/groups.ts
import { GroupsService } from '../slashid'

export const createGroups = async () => {
const groups = [
{ name: "collaborator", description: "Can edit content" },
{ name: "admin", description: "Can manage users and edit content"}
]

await Promise.all(
groups.map(group => GroupsService.postGroups("YOUR_ROOT_ORG_ID", group))
)
}

In your server main file call this before starting the server:

// server/index.ts
import { Fastify } from 'fastify'
import { createGroups } from './groups'

const fastify = Fastify()
const start = async () => {
try {
await createGroups()
await fastify.listen()
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}

start()

2. Add sign up and login

It's time to implement the <Login> component from step 1.

Create client/Login.tsx, then implement the <Form> component from @slashid/react.

// client/Login.tsx
import '@slashid/react/style.css'
import { Form, ConfigurationProvider } from '@slashid/react'

export default function Login() {
return (
<ConfigurationProvider>
<Form />
</ConfigurationProvider>
)
}

At this point the user can login but not to their own personal organization.

personal organization pattern

The personal organization pattern allows for you to implement a single-player variant of your product, usually most or all product features are available but others can't be invited to join.

This personal space provides a place for users to use the product in private and familise themselves with the features.

Some examples of this pattern: GitHub, Figma, Notion.

For a user to land in their personal organization at login, there are two missing pieces:

  1. A way to create a personal organization for the user when they sign up.
  2. A way to flag a particular organization as the users default organization, and then resolve to this one at login.

You'll tackle these problems in the next section.

3. Add the personal organization feature

In this section you're going to do two things:

  • a. Create an API endpoint which returns the users personal organization id.
    • For users who do not yet have a personal organization, create one using the API client and store the organization id in the users attributes.
  • b. Implement the defaultOrganization() middleware for <Form>, this middleware routes the user to a preferred organization at login. It will be responsibile for getting the organization id from the server.

3a. Getting a personal organization id

Create a Fastify route handler ./server/endpoints/get-default-org.ts.

import { RouteHandlerMethod } from 'fastify'

export const getDefaultOrg: RouteHandlerMethod = async (request, reply) => {

return {
defaultOrgId: "hello world!"
}
}

The logic of this route handler will be the following:

  1. Get the users' handles (email address, phone number).
  2. Get the users attributes, check if there is a default_org_id stored there.
    • If yes: personal org exists, return { defaultOrgId: default_org_id }.
  3. Create a new organization sharing a person pool with the root organization (i.e. "YOUR_ROOT_ORG_ID"). This is the users personal organization.
    • You'll give this organization a name which matches the users person_id.
  4. Store the new organization id in the user attributes as default_org_id for later, you'll use the end_user_no_access default bucket for this.
  5. Add the user to the their personal organization, give them the "collaborator" group.
  6. Finally, return { defaultOrgId: newOrganization.id }.
import { RouteHandlerMethod } from "fastify";
import {
PersonAttributesService,
PersonHandlesService,
OrganizationsService,
PersonsService,
} from "../../slashid";

export const getDefaultOrg: RouteHandlerMethod = async (request, reply) => {
// the attribute bucket where the default org will live
const END_USER_NO_ACCESS = "end_user_no_access";

// the user token claims
const { person_id } = request.user as { person_id: string };

// Get the users attributes and handle
const [{ result: attrs }, { result: handles }] = await Promise.all([
PersonAttributesService.getPersonsPersonIdAttributes(
person_id,
"YOUR_ROOT_ORG_ID"
),
PersonHandlesService.getPersonsPersonIdHandles(
person_id,
"YOUR_ROOT_ORG_ID"
),
]);

// check if `default_org_id` exists in the attributes
const defaultOrgId = attrs?.[END_USER_NO_ACCESS]?.default_org_id;

// if yes: you're done, the user has a personal org
if (defaultOrgId) return { defaultOrgId };

if (!handles) {
reply.code(400);
return {
message: "User must have at least one handle",
};
}

// create the users personal org
const { result: personalOrganization } =
await OrganizationsService.postOrganizationsSuborganizations(
"YOUR_ROOT_ORG_ID",
"all_regions",
30,
{
admins: [],
persons_org_id: "YOUR_ROOT_ORG_ID",
groups_org_id: "YOUR_ROOT_ORG_ID",
sub_org_name: person_id,
}
);

if (!personalOrganization) {
reply.code(500);
return {
message: "There was a problem creating your default org",
};
}

// the user will have read/write but not admin permissions for their personal org
const groups = ["collaborator"];

// the users personal org id will live in the `end_user_no_access` bucket
const attributes = {
[END_USER_NO_ACCESS]: { default_org_id: personalOrganization.id },
};

await Promise.all([
// add the user to their personal org
PersonsService.postPersons(
personalOrganization.id,
{ handles, groups }
),

// save their personal org id in user attributes for next time
PersonAttributesService.putPersonsPersonIdAttributes(
person_id,
"YOUR_ROOT_ORG_ID",
attributes
),
]);

return {
defaultOrgId: personalOrganization.id,
};
};

tip

In this snippet request.user are the verified user token claims, you can see how this works in the bonus section Verifying and decoding the user token with Fastify.

Don't forget to register your new route handler:

// server/index.ts
import { Fastify } from 'fastify'
import { createAuthenticateHook } from './auth'
import { getDefaultOrg } from './endpoints/get-default-org'

const fastify = Fastify()
const { authenticate } = createAuthenticateHook(app)

fastify.get('/api/default-org', { onRequest: [authenticate] }, getDefaultOrg)
tip

In this snippet createAuthenticateHook is a fastify hook which verifies the user token, you can see how this works in the bonus section Verifying and decoding the user token with Fastify.

3b. Routing the user to their personal organization

At login you will need to get the users personal organization id from the server and switch to that organization in your React app. You can do that using the defaultOrganization() middleware.

In @slashid/react middleware provides you with a post-login lifecycle hook, it fires before the login operation completes. You can use this to allow the user to login at your root organization but land in their personal organization once authenticated.

Add the defaultOrganization() middleware in client/Login.tsx:

// client/Login.tsx
import '@slashid/react/style.css'
import { Form, ConfigurationProvider, defaultOrganization } from '@slashid/react'
import { User } from '@slashid/slashid'

const getDefaultOrgId = async (user: User): string => {
const headers: HeadersInit = {
authorization: `Bearer ${user.token}`
}

const result = await fetch('/api/default-org', { headers })
const { defaultOrgId } = await result.json()

return defaultOrgId
}

export default function Login() {
return (
<ConfigurationProvider>
<Form
middleware={[
defaultOrganization(async ({ user }) => {
const defaultOrgId = await getDefaultOrgId(user)

return defaultOrgId
})
]}
/>
</ConfigurationProvider>
)
}
info

The defaultOrganization() middleware exchanges the user token for one where the oid claim contains to the default organization.

4. Add the dashboard

Now that the user is authenticated and in their personal organization, the <LoggedIn> component from earlier in the guide will show its children, but <Dashboard> does not yet exist.

You might remember you configured this when setting up the project:

<LoggedIn>
<Dashboard />
</LoggedIn>

Create client/Dashboard.tsx and implement the <OrganizationSwitcher> component from @slashid/react.

import { OrganizationSwitcher } from '@slashid/react'

export default function Dashboard() {
return (
<>
<OrganizationSwitcher/>
</>
)
}
info

By default <OrganizationSwitcher> will show all organizations the user is a member of, that includes the root organization from this example.

4a. Your personal organization

When you render the dashboard you'll find that <OrganizationSwitcher> displays the organization name as-is.

img

This isn't the best user experience. You can override any organization name using the renderLabel prop of <OrganizationSwitcher>. Since we named the organization with the users person_id it's very easy to identify and override the name of their personal organization.

<OrganizationSwitcher
renderLabel={(org: OrganizationDetails) => {
if (user.ID === org.org_name) return "Your personal organization"
return org.org_name
}}
/>

The <OrganizationSwitcher> will now display a much more user friendly organization name.

img

4b. Hiding the root organization

In this example the root organization only exists as a parent organization to link everything together, it doesn't make semantic sense to allow the user to switch here.

You can hide the root organization using the filter prop of <OrganizationSwitcher>.

<OrganizationSwitcher
// ...
filter={org => org.id !== "YOUR_ROOT_ORG_ID"}
/>

The user can now use the organization switcher in the dashboard to switch between organizations they're a member of.

Currently, they're only a member of their personal organization, lets change that.

5. Add organization creation

In this example, a user's personal organization is for their eyes only. However users may want to create another collaborative organization which is shared among their team, company or project members.

In this section you will create an endpoint that allows organization creation. Unlike personal organizations, users who create an organization in this way will be appointed as administrator - later in the guide you will allow administrators to invite others to join their organization.

Create a Fastify route handler ./server/endpoints/create-org.ts.

import { RouteHandlerMethod } from 'fastify'

export const createOrg: RouteHandlerMethod = async (request, reply) => {

return {
message: "Hello world!"
}
}

The logic of this route handler will be the following:

  1. Get the users handles (email address, phone number).
  2. Attempt to create an organization with a given name, sharing a person pool with the root organization (i.e. "YOUR_ROOT_ORG_ID").
    • If the name conflicts with another organization, return an error
  3. Add the user to the organization, give them the "admin" group.
import { FastifySchema, RouteHandlerMethod } from "fastify";
import {
PersonHandlesService,
OrganizationsService,
PersonsService,
} from "../../slashid";

interface CreateOrgBody {
name: string
}

export const createOrg: RouteHandlerMethod = async (request, reply) => {
// the request body, an organization name
const { name } = request.body as CreateOrgBody

// the user token claims
const { person_id } = request.user as { person_id: string };

// get the users handles
const { result: handles } = await PersonHandlesService
.getPersonsPersonIdHandles(person_id, "YOUR_ROOT_ORG_ID")

if (!handles) {
reply.code(400);
return {
message: "User must have at least one handle",
};
}

// try to create an organization, capture http errors
type RequestError = { body: Awaited<ReturnType<typeof OrganizationsService.postOrganizationsSuborganizations>> }
const { result: org, errors } =
await OrganizationsService.postOrganizationsSuborganizations(
"YOUR_ROOT_ORG_ID",
"local_region",
30,
{
admins: [],
persons_org_id: "YOUR_ROOT_ORG_ID",
groups_org_id: "YOUR_ROOT_ORG_ID",
sub_org_name: name,
}
)
.catch((e: RequestError) => e.body)

// 409: there was a name conflict, orgs must have a unique name
const nameConflict = errors?.some(error => error.httpcode === 409)
if (nameConflict) {
reply.code(409)
return {
message: `Organization '${name}' already exists`
}
}

if (!org) {
reply.code(400);
return {
message: `There was a problem creating the organization`,
};
}

// the user will have the admin group
const groups = ["admin"];

// add the user to the new organization
await PersonsService.postPersons(org.id, { handles, groups })

return {
message: `Organization '${name}' was created successfully`,
};
};

Again, don't forget to register your new route handler:

// server/index.ts
import { Fastify } from 'fastify'
import { createAuthenticateHook } from './auth'
import { createOrg } from './endpoints/create-org'

const fastify = Fastify()
const { authenticate } = createAuthenticateHook(app)

fastify.post('/api/org', { onRequest: [authenticate] }, createOrg)

Call the endpoint in your react app:

import { User } from '@slashid/slashid'

const createOrg = async (user: User, name: string) => {
const headers: HeadersInit = {
authorization: `Bearer ${user.token}`,
"content-type": "application/json"
}
const body: BodyInit = JSON.stringify({ name })
const init: RequestInit = {
method: 'POST',
headers,
body
}

const result = await fetch('/api/org', init)

if (result.ok) {
alert("Organization created!")
return
}

if (result.status === 409) {
alert(`Error: organization with ${name} already exists`)
return
}

alert("Error: problem creating organization")
}

export default function Dashboard() {
const { user } = useSlashID()

return (
<>
// ...
<button onClick={() => createOrg(user, "ORG_NAME")}>
Create organization
</button>
</>
)
}

Once you've created another organization, refresh the page to see it in the <OrganizationSwitcher>.

Once the user has more than one organization they can switch between organizations using the <OrganizationSwitcher>.

info

Each time the user switches organizations their user token is exchanged for a new one where the oid claim contains the current organization id. You can use this to personalise what content they're shown in the dashboard.

6. Add collaborator invites

In this section you'll create an endpoint which adds a user with a given handle to an organization as collaborator.

Create a Fastify route handler ./server/endpoints/add-collaborator.ts.

import { RouteHandlerMethod } from 'fastify'

export const addCollaborator: RouteHandlerMethod = async (request, reply) => {

return {
message: "Hello world!"
}
}

The logic of this route handler will be the following:

  1. Get the requesting users' groups and check if they're admin of the organization.
    • If no: return an error.
  2. Attempt to add the given handle to the organization, give them the "collaborator" group.
    • If the user is already in the organization: return an error.
import { RouteHandlerMethod } from "fastify";
import { GroupsService, PersonHandleType, PersonHandlesService, PersonsService } from "../../slashid";

interface AddCollaboratorBody {
email: string
}

export const addCollaborator: RouteHandlerMethod = async (request, reply) => {
// the request body, an email address
const { email } = request.body as AddCollaboratorBody

// the user token claims
const user = request.user as { person_id: string, oid: string }

// get the requesting users groups
const { result: groups } = await GroupsService.getPersonsPersonIdGroups(user.person_id, user.oid)

// if user is not admin, error
if (!groups.includes("admin")) {
reply.code(403)
return {
message: "Only admins can add collaborators"
}
}

const handle = {
type: PersonHandleType.EMAIL_ADDRESS,
value: email
}

// the user will have the collaborator group
const newGroups = ["collaborator"]

// add the user to the organization
try {
await PersonsService.postPersons(user.oid, { handles: [handle], newGroups })
} catch {
// if they already exist, API will error.
// Return already exists error.
reply.code(400)
return {
message: `${handle.value} is already a collaborator`
}
}

return {
message: `${handle.value} added as collaborator`
}
}

Register your new route handler:

// server/index.ts
import { Fastify } from 'fastify'
import { createAuthenticateHook } from './auth'
import { addCollaborator } from './endpoints/add-collaborator'

const fastify = Fastify()
const { authenticate } = createAuthenticateHook(app)

fastify.post('/api/collaborator', { onRequest: [authenticate] }, addCollaborator)

Call the endpoint in your react app:

import { User } from '@slashid/slashid'

const inviteCollaborator = async (user: User, email: string) => {
const headers: HeadersInit = {
authorization: `Bearer ${user.token}`,
"content-type": "application/json"
}
const body: BodyInit = JSON.stringify({ email })
const init: RequestInit = {
method: 'POST',
headers,
body
}

const result = await fetch('/api/collaborator', init)

if (result.ok) {
alert(`${email} added as collaborator`)
return
}

alert(`Error: ${(await result.json()).message}`)
}

export default function Dashboard() {
const { user } = useSlashID()

return (
<>
// ...
<button onClick={() => inviteCollaborator(user, "EMAIL_ADDRESS")}>
Invite collaborator
</button>
</>
)
}

Conclusion

In this guide you learned how to build a full-stack multi-tenant app with personal & default organizations, user created organizations, collaborator invites and organization switching.

Bonus: Verifying and decoding the SlashID user token with Fastify

In this bonus section you'll learn how to locally verify SlashID user token claims using @fastify/jwt and get-jwks.

Context

The SlashID user token is a JSON Web Token (JWT), the validity of a JWT can be verified using whats known as a JSON Web Key (JWK).

SlashID uses the JSON Web Key specification to represent the cryptographic keys used for signing RS256 tokens.

SlashID exposes a JWK Set (JWKS) endpoint at https://api.slashid.com/.well-known/jwks.json, and you can use this to verify SlashID user tokens locally on your server. Once verified you can trust that the user token claims are unmodified and true, and use them in your code with confidence.

Code

You'll need @fastify/jwt and get-jwks, install them:

npm i @fastify/jwt get-jwks

get-jwks is a library which fetches jwks.json for a given domain and helps you convert the JWKS into a public key which you can use to verify user tokens. This behaves as a middleware for @fastify/jwt which does most of the heavy lifting.

@fastify/jwt parses the authorization header of incoming requests and verifies the token using a secret. If the verification fails it returns an error, the request never reaches your route handlers.

Once verified you can also instruct @fastify/jwt to decode the token and set the claims on the request object, seemlessly making the token claims available to you in your route handlers as request.user.

First create the get-jwks based middleware:

import buildGetJwks from 'get-jwks'
import { Secret, TokenOrHeader } from '@fastify/jwt'

const domain = "https://api.slashid.com/.well-known/jwks.json"
const getJwks = buildGetJwks()

const secret: Secret = async (request: FastifyRequest, token: TokenOrHeader) => {
if (!('header' in token)) throw Error()

const { kid, alg } = token.header

// fetch the JWk with matching key id (kid) and algorithm (alg) from api.slashid.com
return getJwks
.getPublicKey({ kid, domain, alg })
}

Next register @fastify/jwt as a Fastify plugin, use the previous snippet as the secret argument:

import { Fastify } from 'fastify'
import fastifyJwt from '@fastify/jwt'

const fastify = Fastify()
fastify.register(fastifyJwt, {
// decode the token
decode: { complete: true },
// verify with this secret
secret
})

This decorates the Fastify request object with a jwtVerify() method which can be called to verify the JWT of a given request, on success request.user is set with the token claims. While you could call this manually in every request handler which needs auth, there is a better way.

Create a Fastify Hook which can be added to your request handler definitions. This hook will be used to intercept and verify the tokens of any incoming request before your request handler is invoked and return errors for bad requests.

import { onRequestHookHandler } from 'fastify'

const authenticate: onRequestHookHandler = async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.send(err)
}
}

That's it! Implement it in your request handler definitions:

import { Fastify } from 'fastify'

const fastify = Fastify()

fastify.get('/top/secret', { onRequest: [authenticate] }, getTopSecret)

Here is everything together as seen in the guide:

import fastifyJwt, { Secret, TokenOrHeader } from "@fastify/jwt"
import { FastifyInstance, FastifyRequest, onRequestHookHandler } from "fastify"
import buildGetJwks from "get-jwks"

export const createAuthenticateHook = (app: FastifyInstance) => {
const API_ENDPOINT = "https://api.slashid.com"
const getJwks = buildGetJwks()

const secret: Secret = async (request: FastifyRequest, token: TokenOrHeader) => {
if (!('header' in token)) throw Error()

const { kid, alg } = token.header

return getJwks
.getPublicKey({ kid, domain: API_ENDPOINT, alg })
}

app.register(fastifyJwt, {
decode: { complete: true },
secret
})

const authenticate: onRequestHookHandler = async (request, reply) => {
try {
await request.jwtVerify()
} catch (err) {
reply.send(err)
}
}

return {
authenticate
}
}

It can be implemented like this:

import { Fastify } from 'fastify'
import { createAuthenticateHook } from '...'

const fastify = Fastify()

const { authenticate } = createAuthenticateHook(fastify)

fastify.get('/top/secret', { onRequest: [authenticate] }, getTopSecret)