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:
This ensures that your application is only available for your enterprise 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:
Copy 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 .
Copy 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.
Copy 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.
Copy 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.
Copy 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.
Copy 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.
Copy ...
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
Copy /**
* 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;
}
}
Copy /**
* 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
Copy /**
* 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 ,
});
}
Copy /**
* 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
Copy ...
workosId : {
type : DataTypes .STRING ( 255 ) ,
allowNull : true ,
} ,
...
Copy ...
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:
Copy ` ${ 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.
Copy 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
Copy 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
Copy //...
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.