Enterprise sign-in with WorkOS

WorkOS is a platform to make your app enterprise-ready, quickly adding common features like SSO/SAML, Directory Sync, Audit Trail, and more. It's like "Stripe for enterprise features."

In this recipe, I’ll show you how to integrate a ScaffoldHub generated application with WorkOS.

For the sake of this recipe, I’ll use the G Suite and Okta single sign-on integrations, but WorkOS allows you to integrate with many other providers.

Thanks to WorkOS, from the ScaffoldHub perspective, what differs from one provider to another is only the domain variable we send to WorkOS.

The integrated application

The integrated application will have those two ways of sign-in/sign-up:

  • Okta

  • G Suite

This ensures that your application is only available for your enterprise users.

Okta
GSuite
ScaffoldHub Users

GitHub repository

Repository:

Differences:

If you own a ScaffoldHub license and don't have access to the repository yet, please email us at support@scaffoldhub.io with your GitHub profile.

Requirements

Please follow the Setup section and make sure your application is up and running on the localhost.

Create a WorkOS account

Go to https://workos.com/ and create a new account.

Single Sign-On (SAML) setup

Once you sign-in, on the Single Sign-On (SAML), click on Get Started.

Install the WorkOS SDK

Open a new console at the backend folder of your project and run:

npm install --save @workos-inc/node

Add your SSO Redirects URIs

Add this as the Redirect URI:

http://localhost:8080/api/workos/callback

We will configure this callback later.

Set up your Identity Providers

Configuring the backend

This section describes the changes needed on the backend for the integration. The direction of changes is from the API -> Database.

Environment Variables

First things first, we must set the environment variables with the WorkOS configuration.

More about WorkOS keys: https://workos.com/docs/keys.

WORKOS_SECRET_KEY = "..."
WORKOS_PROJECT_ID = "..."
WORKOS_REDIRECT_URI = "http://localhost:8080/api/workos/callback"

Endpoints

backend/src/api/workos/workosSignIn.ts

The sign-in endpoint is responsible for getting the authorization URL of the provider - G Suite, Okta, etc - and redirect the user to its sign-in page.

Note the req.query.domain that receives the domain the user wants to authenticate from the front-end.

import ApiResponseHandler from '../apiResponseHandler';
import WorkOS from '@workos-inc/node';
import { getConfig } from '../../config';
import { databaseCloseIfIndividualConnectionPerRequest } from '../../database/databaseConnection';
const client = new WorkOS(getConfig().WORKOS_SECRET_KEY);
export default async (req, res) => {
try {
const url = client.sso.getAuthorizationURL({
domain: req.query.domain,
redirectURI: getConfig().WORKOS_REDIRECT_URI,
projectID: getConfig().WORKOS_PROJECT_ID,
});
// This is unrelated to WorkOS, but because we are not calling
// the ApiResponseHandler, we must close the database connection
// if needed
await databaseCloseIfIndividualConnectionPerRequest(
req,
);
res.redirect(url);
} catch (error) {
await ApiResponseHandler.error(req, res, error);
}
};

backend/src/api/workos/workosCallback.ts

The callback endpoint receives a code that enables us to fetch the user profile of the authenticated user. This allows us to find or create the user on our database using the WorkOS information: https://workos.com/docs/sso/api-reference#example-response.

The AuthService.signinWithWorkos method performs the operations needed on the database and returns a JWT token.

The user is then redirected to the frontend page with the auth token.

If an error occurs, it is redirected to the application sign-in page with the error message passed as a parameter.

import WorkOS from '@workos-inc/node';
import { getConfig } from '../../config';
import { databaseCloseIfIndividualConnectionPerRequest } from '../../database/databaseConnection';
import AuthService from '../../services/auth/authService';
const client = new WorkOS(getConfig().WORKOS_SECRET_KEY);
export default async (req, res) => {
try {
const { code } = req.query;
const profile = await client.sso.getProfile({
code,
projectID: getConfig().WORKOS_PROJECT_ID,
});
const authToken = await AuthService.signinWithWorkos(
profile,
req,
);
await databaseCloseIfIndividualConnectionPerRequest(
req,
);
res.redirect(
`${
getConfig().FRONTEND_URL
}?workos=true&authToken=${authToken}`,
);
} catch (error) {
res.redirect(
`${
getConfig().FRONTEND_URL
}/auth/sign-in?errorMessage=${encodeURIComponent(
error.message,
)}`,
);
}
};

backend/src/api/workos/workosOnboard.ts

This is specific to ScaffoldHub, but once a user is created, it must pass through the onboarding process, where it joins the user on the workspace.

import ApiResponseHandler from '../apiResponseHandler';
import AuthService from '../../services/auth/authService';
export default async (req, res, next) => {
try {
const payload = await AuthService.handleOnboard(
req.currentUser,
req.body.invitationToken,
req.body.tenantId,
req,
);
await ApiResponseHandler.success(req, res, payload);
} catch (error) {
await ApiResponseHandler.error(req, res, error);
}
};

backend/src/api/workos/index.ts

We must declare the endpoints we created at the workos/index.ts file.

export default (app) => {
app.get(
`/workos/callback`,
require('./workosCallback').default,
);
app.get(
`/workos/sign-in`,
require('./workosSignIn').default,
);
app.post(
`/workos/onboard`,
require('./workosOnboard').default,
);
};

backend/src/api/index.ts

And also add the WorkOS index to the main API index.

...
require('./workos').default(routes);
...

backend/src/api/auth/index.ts

We will no longer use the standard email/password sign-up, so remove those routes.

Authentication Service

Once we have the profile of the authenticated user, we must fetch them from the database using the email, which is the unique key for the user.

If the user has one email, but a differentworkosId, it means that the user is trying to sign-in using a different provider from the first one he used.

If the user does not exist on the database yet, we must create it.

The application then generates a JWT token and returns it to the endpoint.

backend/src/services/auth/authService.ts

SQL
MongoDB
SQL
/**
* Signs in a user from WorkOS.
* @param {*} email
* @param {*} password
* @param {*} [options]
*/
static async signinWithWorkos(
profile,
options: any = {},
) {
const transaction = await SequelizeRepository.createTransaction(
options.database,
);
try {
let user = await UserRepository.findByEmail(
profile.email,
options,
);
if (user && user.workosId !== profile.id) {
throw new Error400(
options.language,
'auth.invalidProvider',
);
}
if (!user) {
user = await UserRepository.createFromWorkos(
profile,
options,
);
}
const token = jwt.sign(
{ id: user.id },
getConfig().AUTH_JWT_SECRET,
{ expiresIn: getConfig().AUTH_JWT_EXPIRES_IN },
);
await SequelizeRepository.commitTransaction(
transaction,
);
return token;
} catch (error) {
await SequelizeRepository.rollbackTransaction(
transaction,
);
throw error;
}
}
MongoDB
/**
* Signs in a user from WorkOS.
* @param {*} email
* @param {*} password
* @param {*} [options]
*/
static async signinWithWorkos(
profile,
options: any = {},
) {
const session = await MongooseRepository.createSession(
options.database,
);
try {
let user = await UserRepository.findByEmail(
profile.email,
options,
);
if (user && user.workosId !== profile.id) {
throw new Error400(
options.language,
'auth.invalidProvider',
);
}
if (!user) {
user = await UserRepository.createFromWorkos(
profile,
options,
);
}
const token = jwt.sign(
{ id: user.id },
getConfig().AUTH_JWT_SECRET,
{ expiresIn: getConfig().AUTH_JWT_EXPIRES_IN },
);
await MongooseRepository.commitTransaction(session);
return token;
} catch (error) {
await MongooseRepository.abortTransaction(session);
throw error;
}
}

Create User from WorkOS profile

After successful sign-in, WorkOS returns a profile object with the information of the user:

With this information, we can create a user on the database.

backend/src/database/repositories/userRepository.ts

SQL
MongoDB
SQL
/**
* Creates the user based on the auth information.
*
* @param {*} data
* @param {*} [options]
*/
static async createFromWorkos(profile, options) {
let data = {
email: profile.email,
firstName: profile.first_name,
lastName: profile.last_name,
workosId: profile.id,
};
const transaction = SequelizeRepository.getTransaction(
options,
);
const user = await options.database.user.create(data, {
transaction,
});
delete user.password;
await AuditLogRepository.log(
{
entityName: 'user',
entityId: user.id,
action: AuditLogRepository.CREATE,
values: {
...user.get({ plain: true }),
},
},
options,
);
return this.findById(user.id, {
...options,
bypassPermissionValidation: true,
});
}
MongoDB
/**
* Creates the user based on the WorkOS profile.
*
* @param {*} profile
* @param {*} [options]
*/
static async createFromWorkos(profile, options) {
let data = {
email: profile.email,
firstName: profile.first_name,
lastName: profile.last_name,
workosId: profile.id,
};
data = this._preSave(data);
let [user] = await User(options.database).create(
[data],
MongooseRepository.getSessionOptionsIfExists(options),
);
await AuditLogRepository.log(
{
entityName: 'user',
entityId: user.id,
action: AuditLogRepository.CREATE,
values: user,
},
options,
);
return this.findById(user.id, {
...options,
bypassPermissionValidation: true,
});
}

Add workosId to User

For the integration to work, we must relate the WorkOS user to the user on the database. We will use the workosId for this relation.

backend/src/database/models/user.ts

SQL
MongoDB
SQL
...
workosId: {
type: DataTypes.STRING(255),
allowNull: true,
},
...
MongoDB
...
workosId: {
type: String,
maxlength: 255,
},
...

SQL note: If you already have the database created, you must also create this field on the database.

Configuring the frontend

For this example, I'm using the React Ant Design version, but the concept is the same for all versions.

Sign-in page

The key here is the buttons that redirect the user to the WorkOS sign-in endpoint:

`${config.backendUrl}/workos/sign-in?domain=foo-corp.com`

The domain for each button must correspond to the ones you configured on the WorkOS platform.

Note that it also checks for the errorMessage on the query and displays it to the user in case it exists.

import { Button } from 'antd';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import config from 'src/config';
import { i18n } from 'src/i18n';
import selectors from 'src/modules/auth/authSelectors';
import Content from 'src/view/auth/styles/Content';
import Logo from 'src/view/auth/styles/Logo';
import Wrapper from 'src/view/auth/styles/Wrapper';
import I18nFlags from 'src/view/layout/I18nFlags';
import queryString from 'query-string';
import Message from 'src/view/shared/message';
import { useLocation } from 'react-router-dom';
const SigninPage = (props) => {
const location = useLocation();
const backgroundImageUrl = useSelector(
selectors.selectBackgroundImageUrl,
);
const logoUrl = useSelector(selectors.selectLogoUrl);
const { errorMessage } = queryString.parse(
location.search,
);
useEffect(() => {
if (errorMessage) {
Message.error(errorMessage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Wrapper
style={{
backgroundImage: `url(${
backgroundImageUrl || '/images/signin.jpg'
})`,
}}
>
<Content>
<Logo>
{logoUrl ? (
<img
src={logoUrl}
width="240px"
alt={i18n('app.title')}
/>
) : (
<h1>{i18n('app.title')}</h1>
)}
</Logo>
<Button
size="large"
block
href={`${config.backendUrl}/workos/sign-in?domain=foo-corp.com`}
type="primary"
>
{i18n('auth.okta')}
</Button>
<Button
style={{ marginTop: '16px' }}
size="large"
block
href={`${config.backendUrl}/workos/sign-in?domain=scaffoldhub.io`}
>
{i18n('auth.gsuite')}
</Button>
<I18nFlags
style={{
marginTop: '24px',
}}
/>
</Content>
</Wrapper>
);
};
export default SigninPage;

Don't forget to add the I18n labels on the I18n files.

Onboard

After the user creation, it must pass through an onboard process, where he joins the tenant or accepts and invitation.

On the frontend, we must do those modifications:

frontend/src/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import { i18n, init as i18nInit } from 'src/i18n';
import { AuthToken } from './modules/auth/authToken';
import TenantService from './modules/tenant/tenantService';
import SettingsService from './modules/settings/settingsService';
import AuthService from './modules/auth/authService';
(async function () {
const isWorkosOnboardRequested = AuthService.isWorkosOnboardRequested();
AuthToken.applyFromLocationUrlIfExists();
await TenantService.fetchAndApply();
if (isWorkosOnboardRequested) {
await AuthService.workosOnboard();
}
SettingsService.applyThemeFromTenant();
await i18nInit();
document.title = i18n('app.title');
const App = require('./App').default;
ReactDOM.render(<App />, document.getElementById('root'));
})();

frontend/src/modules/auth/authService.tsx

//...
static async workosOnboard() {
const invitationToken = AuthInvitationToken.get();
const response = await authAxios.post(
'/workos/onboard',
{
invitationToken,
tenantId: tenantSubdomain.isSubdomain
? AuthCurrentTenant.get()
: undefined,
},
);
AuthInvitationToken.clear();
return response.data;
}
static isWorkosOnboardRequested() {
const urlParams = new URLSearchParams(
window.location.search,
);
return Boolean(urlParams.get('workos'));
}
//...

Test your integration

At this point, you have probably already tested the integration via the application, but you can also run this on the WorkOS setup process, and it will work.

Finish

Well done, your application is now enterprise-ready.