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>
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"
}
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.
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.
For a user to land in their personal organization at login, there are two missing pieces:
- A way to create a personal organization for the user when they sign up.
- 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:
- Get the users' handles (email address, phone number).
- Get the users attributes, check if there is a
default_org_id
stored there.- If yes: personal org exists, return
{ defaultOrgId: default_org_id }
.
- If yes: personal org exists, return
- 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
.
- You'll give this organization a name which matches the users
- Store the new organization id in the user attributes as
default_org_id
for later, you'll use theend_user_no_access
default bucket for this. - Add the user to the their personal organization, give them the
"collaborator"
group. - 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,
};
};
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)
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>
)
}
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/>
</>
)
}
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.
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.
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:
- Get the users handles (email address, phone number).
- 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
- 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>
.
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:
- Get the requesting users' groups and check if they're admin of the organization.
- If no: return an error.
- 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)