Add SAML SSO to Remix App
Let's look at how to authenticate users in a remix app using Enterprise Single-Sign-On (SSO).
If you wish to dive straight into the source, Checkout: https://github.com/boxyhq/jackson-remix-auth
This guide shows how to embed Single Sign-On (SSO) feature using Jackson, and thereby does not require you to host Jackson separately.
Getting Started
Create remix app
npx create-remix@latest
You can go with the Remix App Server as the deployment target. Feel free to choose either 'Typescript' or "Javascript". All the code samples in this guide are in typescript.
Install the dependencies
npm i remix-auth @boxyhq/remix-auth-sso
remix-auth
is a complete open-source authentication solution for Remix applications. @boxyhq/remix-auth-sso
provides a remix-auth strategy to interact with the SSO Service provider.
Set up remix-auth
First, we need an Authenticator
instance from remix-auth
. Authenticator
exposes the API for login and logout.
Create sessionStorage for Authenticator
app/sessions.server.ts: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/sessions.server.ts
**NOTE: We will be relying on cookie-based sessions See: createCookieSessionStorage from remix. **
import { createCookieSessionStorage } from 'remix';
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
path: '/',
sameSite: 'lax',
secrets: process.env.COOKIE_SECRETS!.split(','),
secure: process.env.NODE_ENV === 'production',
},
});
const { getSession, commitSession, destroySession } = sessionStorage;
const JACKSON_ERROR_COOKIE_KEY = 'jackson_error';
export default sessionStorage;
export { getSession, commitSession, destroySession, JACKSON_ERROR_COOKIE_KEY };
Create the Authenticator
instance
app/auth.server.ts: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/auth.server.ts
NOTE: We haven't initialised the strategy use yet. That will be done in following sections
import { Authenticator } from 'remix-auth';
import {
BoxyHQSSOStrategy,
type BoxyHQSSOProfile,
} from '@boxyhq/remix-auth-sso';
import invariant from 'tiny-invariant';
import sessionStorage from './sessions.server';
let auth: Authenticator;
declare global {
var __auth: Authenticator | undefined;
}
function createAuthenticator() {
const auth = new Authenticator<BoxyHQSSOProfile>(sessionStorage);
// Strategy use for the hosted saml service provider goes here
// Strategy use for the embedded saml service provider goes here
return auth;
}
if (process.env.NODE_ENV === 'production') {
auth = createAuthenticator();
} else {
// In development we don't want to recreate the Authenticator for every change
if (!global.__auth) {
global.__auth = createAuthenticator();
}
auth = global.__auth;
}
export { auth };
Set up Jackson SSO feature
We'll be using SAML Jackson npm to setup some API routes (resource routes in remix terminology).
Install jackson
Install @boxyhq/saml-jackson
first:
npm i @boxyhq/saml-jackson
Before you proceed,set up a database for jackson. Refer to db environment variables for the npm library options.
Setup JacksonProvider
app/auth.jackson.server.ts: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/auth.jackson.server.ts
NOTE: clientSecretVerifier set below will be matched against client_secret coming from Authenticator
const opts = {
...
db: {
engine: "sql",
url: "postgresql://postgres:postgres@localhost:5432/postgres",
type: "postgres",
},
clientSecretVerifier: process.env.CLIENT_SECRET_VERIFIER
...
}
...
async function JacksonProvider({
appBaseUrl,
}: {
appBaseUrl: string;
}): Promise<{
connectionAPIController: IConnectionAPIController;
oauthController: IOAuthController;
}> {
const _opts = { ...opts, externalUrl: appBaseUrl, samlAudience: appBaseUrl };
// this is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the DB with every change either.
if (process.env.NODE_ENV === "production") {
const controllers = await jackson(_opts);
connectionAPIController = controllers.connectionAPIController;
oauthController = controllers.oauthController;
} else {
if (!global.__connectionAPIController && !global.__oauthController) {
const controllers = await jackson(_opts);
global.__connectionAPIController = controllers.connectionAPIController;
global.__oauthController = controllers.oauthController;
}
connectionAPIController = global.__connectionAPIController;
oauthController = global.__oauthController;
}
return { connectionAPIController, oauthController };
}
...
Resource routes to handle OAuth 2.0 and SSO Connections
Next, create the api files for OAuth 2.0 flow and SSO Connection:
app/routes $ mkdir api && cd api
routes/api $ touch oauth.\$slug.ts v1.connections.ts
oauth.$slug.ts: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/routes/api/oauth.%24slug.ts
...
// Handles GET /api/oauth/authorize, GET /api/oauth/userinfo
export const loader: LoaderFunction = async ({ params, request }) => {
... // Some validation logic
const operation = params.slug;
const url = new URL(request.url);
const { oauthController } = await JacksonProvider({
appBaseUrl: url.origin,
});
// rightmost query param will win in case of multiple ones with same name
const queryParams = Object.fromEntries(url.searchParams.entries());
switch (operation) {
case "authorize": {
...
try {
const { redirect_url, authorize_form } =
await oauthController.authorize(
queryParams as unknown as OAuthReqBody
);
...
} catch (err: any) {
... // error handling, redirect to /error page
}
}
case "oidc": {
try {
const { redirect_url } = await oauthController.oidcAuthzResponse(
queryParams as unknown as OIDCAuthzResponsePayload
);
if (redirect_url) {
return redirect(redirect_url, 302);
}
} catch (err: any) {
... // error handling, redirect to /error page
}
}
case "userinfo": {
... // token validation
try {
const profile = await oauthController.userInfo(token);
return json(profile);
} catch (error: any) {
... // error handling
}
}
}
};
// Handles POST /api/oauth/saml, POST /api/oauth/token
export const action: ActionFunction = async ({ params, request }) => {
... // Some validation logic
const operation = params.slug;
const url = new URL(request.url);
const { oauthController } = await JacksonProvider({
appBaseUrl: url.origin,
});
switch (operation) {
case "saml": {
try {
const { redirect_url } = await oauthController.samlResponse(body);
return redirect(redirect_url, 302);
} catch (err: any) {
... // error handling
}
}
case "token": {
try {
const tokenRes = await oauthController.token(body);
return json(tokenRes);
} catch (error: any) {
... // error handling
}
}
}
};
v1.connections.ts: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/routes/api/v1.connections.ts
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const queryParams = Object.fromEntries(
url.searchParams.entries()
) as unknown as { clientID?: string; tenant?: string; product?: string };
...// Validate apiKey
const { connectionAPIController } = await JacksonProvider({ appBaseUrl: url.origin });
try {
return json(await connectionAPIController.getConnections(queryParams));
} catch (error: any) {
... // error handling
}
};
export const action: ActionFunction = async ({ request }) => {
const url = new URL(request.url);
const contentType = request.headers.get("Content-Type");
... // Validate body,apiKey
const { connectionAPIController } = await JacksonProvider({ appBaseUrl: url.origin });
try {
switch (request.method) {
case "POST":
return json(await connectionAPIController.createSAMLConnection(body));
case "PATCH":
await connectionAPIController.updateSAMLConnection(body);
return new Response(null, { status: 204 });
case "DELETE":
await connectionAPIController.deleteConnections(body);
return new Response(null, { status: 204 });
}
} catch (error: any) {
... // error handling
}
};
Initialise BoxyHQSSOStrategy
Use the strategy with the Authenticator
as shown below. The clientID/Secret
values are expected to be set dynamically from the client side. For now set them to the value dummy
.
Point the issuer
to the app url.
auth.server.ts: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/auth.server.ts
BOXYHQSSO_ISSUER in env is set to point to the app url: http://localhost:3366 or the actual url once hosted. Take a look at .env.example file
invariant(process.env.BASE_URL, 'Expected BASE_URL to be set in env');
invariant(
process.env.BOXYHQSSO_ISSUER,
'Expected BOXYHQSSO_ISSUER to be set in env'
);
const BASE_URL = process.env.BASE_URL;
const BOXYHQSSO_ISSUER = process.env.BOXYHQSSO_ISSUER;
// Strategy use for the embedded sso service provider goes here
auth.use(
new BoxyHQSSOStrategy(
{
issuer: BOXYHQSSO_ISSUER, //same as the APP URL
clientID: 'dummy',
clientSecret: process.env.CLIENT_SECRET_VERIFIER || 'dummy',
callbackURL: new URL('/auth/sso/embed/callback', BASE_URL).toString(),
},
async ({ profile }) => {
return profile;
}
),
'boxyhq-sso-embed'
);
Routes to handle login and callback from IdP
We need 2 routes:
~> /auth/sso/embed - Action handler for login
~> /auth/sso/embed/callback - After successful authorization, user is redirected here with the authorization code. The code
is then exchanged to get the token
and further the user profile.
Create the following files under app/routes
:
app/routes/auth.sso.embed.tsx: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/routes/auth.sso.embed.tsx
...
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const email = formData.get("email");
const product = formData.get("product");
... // Add some validation logic
// extracting the tenant from email is one way to set it
const tenant = email.split("@")[1];
return await auth.authenticate("boxyhq-sso-embed", request, {
successRedirect: "/private",
failureRedirect: "/login",
context: {
clientID: `tenant=${tenant}&product=${product}`,
clientSecret: process.env.CLIENT_SECRET_VERIFIER || "dummy",
},
});
};
app/routes/auth.sso.embed.callback.tsx: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/routes/auth.sso.embed.callback.tsx
...
export const loader: LoaderFunction = async ({ request, params }) => {
return auth.authenticate("boxyhq-sso-embed", request, {
successRedirect: "/private",
failureRedirect: "/login",
});
};
Add an SSO Connection
Add a SAML SSO connection for mocksaml.com. You can start the app and call the connection API as shown below:
Below adds a SAML SSO connection for https://mocksaml.com
curl --location --request POST 'http://localhost:3366/api/v1/connections' --header 'Authorization: Api-Key JACKSON_API_KEY' --header 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'encodedRawMetadata=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxFbnRpdHlEZXNjcmlwdG9yIHhtbG5zOm1kPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6bWV0YWRhdGEiIGVudGl0eUlEPSJodHRwczovL3NhbWwuZXhhbXBsZS5jb20vZW50aXR5aWQiIHZhbGlkVW50aWw9IjIwMjYtMDYtMjJUMTg6Mzk6NTMuMDAwWiI+CiAgPElEUFNTT0Rlc2NyaXB0b3IgV2FudEF1dGhuUmVxdWVzdHNTaWduZWQ9ImZhbHNlIiBwcm90b2NvbFN1cHBvcnRFbnVtZXJhdGlvbj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIj4KICAgIDxLZXlEZXNjcmlwdG9yIHVzZT0ic2lnbmluZyI+CiAgICAgIDxLZXlJbmZvIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj4KICAgICAgICA8WDUwOURhdGE+CiAgICAgICAgICA8WDUwOUNlcnRpZmljYXRlPk1JSUM0akNDQWNvQ0NRQzMzd255YlQ1UVpEQU5CZ2txaGtpRzl3MEJBUXNGQURBeU1Rc3dDUVlEVlFRR0V3SlYKU3pFUE1BMEdBMVVFQ2d3R1FtOTRlVWhSTVJJd0VBWURWUVFEREFsTmIyTnJJRk5CVFV3d0lCY05Nakl3TWpJNApNakUwTmpNNFdoZ1BNekF5TVRBM01ERXlNVFEyTXpoYU1ESXhDekFKQmdOVkJBWVRBbFZMTVE4d0RRWURWUVFLCkRBWkNiM2g1U0ZFeEVqQVFCZ05WQkFNTUNVMXZZMnNnVTBGTlREQ0NBU0l3RFFZSktvWklodmNOQVFFQkJRQUQKZ2dFUEFEQ0NBUW9DZ2dFQkFMR2ZZZXR0TXNjdDFUNnRWVXdUdWROSkg1UG5iOUdHbmtYaTlady9lNng0NUREMApSdVJPTmJGbEoyVDRSakFFL3VHK0FqWHhYUThvMlNaZmI5K0dnbUNIdVRKRk5nSG9aMW5GVlhDbWIvSGc4SHBkCjR2T0FHWG5kaXhhUmVPaXEzRUg1WHZwTWpNa0ozKzgrOVZZTXpNWk9qa2dRdEFxTzM2ZUFGRmZOS1g3ZFRqM1YKcHdMa3Z6Ni9LRkNxOE9Bd1krQVVpNGVabTVKNTdEMzFHempId2ZqSDlXVGVYME15bmRtbk5CMXFWNzVxUVIzYgoyL1c1c0dIUnYrOUFhcmdnSmtGK3B0VWtYb0x0VkE1MXdjZlltNmhJTHB0cGRlNUZRQzhSV1kxWXJzd0JXQUVaCk5meXJSNEplU3dlRWxOSGc0TlZPczRUd0dqT1B3V0dxelRmZ1RsRUNBd0VBQVRBTkJna3Foa2lHOXcwQkFRc0YKQUFPQ0FRRUFBWVJsWWZsU1hBV29acEZmd05pQ1FWRTVkOXpaMERQek5kV2hBeWJYY1R5TWYwejVtRGY2RldCVwo1R3lvaTl1M0VNRURuekxjSk5rd0pBQWMzOUFwYTRJMi90bWwrSnkyOWRrOGJUeVg2bTkzbmdtQ2dkTGg1WmE0CmtodVUzQU0zTDYzZzdWZXhDdU83a3dramgvK0xxZGNJWHNWR082WERmdTJRT3MxWHBlOXpJekxwd20vUk5ZZVgKVWpiU2o1Y2UvamVrcEF3N3F5VlZMNHhPeWg4QXRVVzFlazN3SXcxTUp2RWdFUHQwZDE2b3NoV0pwb1MxT1Q4TApyLzIyU3ZZRW8zRW1TR2RUVkdnazN4M3MrQTBxV0FxVGN5anI3UTRzL0dLWVJGZm9tR3d6MFRaNEl3MVpOOTlNCm0wZW8yVVNsU1JUVmw3UUhSVHVpdVNUaEhwTEtRUT09CjwvWDUwOUNlcnRpZmljYXRlPgogICAgICAgIDwvWDUwOURhdGE+CiAgICAgIDwvS2V5SW5mbz4KICAgIDwvS2V5RGVzY3JpcHRvcj4KICAgIDxOYW1lSURGb3JtYXQ+dXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6MS4xOm5hbWVpZC1mb3JtYXQ6ZW1haWxBZGRyZXNzPC9OYW1lSURGb3JtYXQ+CiAgICA8U2luZ2xlU2lnbk9uU2VydmljZSBCaW5kaW5nPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YmluZGluZ3M6SFRUUC1SZWRpcmVjdCIgTG9jYXRpb249Imh0dHBzOi8vbW9ja3NhbWwuY29tL2FwaS9zYW1sL3NzbyIvPgogICAgPFNpbmdsZVNpZ25PblNlcnZpY2UgQmluZGluZz0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmJpbmRpbmdzOkhUVFAtUE9TVCIgTG9jYXRpb249Imh0dHBzOi8vbW9ja3NhbWwuY29tL2FwaS9zYW1sL3NzbyIvPgogIDwvSURQU1NPRGVzY3JpcHRvcj4KPC9FbnRpdHlEZXNjcmlwdG9yPg==' --data-urlencode 'defaultRedirectUrl=http://localhost:3366' --data-urlencode 'redirectUrl=["http://localhost:3366/*"]' --data-urlencode 'tenant=boxyhq.com' --data-urlencode 'product=saml-demo.boxyhq.com' --data-urlencode 'name=demo-config' --data-urlencode 'description=Demo SAML config'
App routes
Login/Logout
For the Login
page we need a form that can accept email (for deriving tenant) and product. We also change the form action for the embedded SAML provider
button.
login.tsx: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/routes/login.tsx
export const loader: LoaderFunction = async ({ request }) => {
// check if authenticated then redirect to /private
await auth.isAuthenticated(request, { successRedirect: "/private" });
// return form error data from session
}
export default function Login() {
return (
...
<Form
method="post"
action="/auth/sso"
reloadDocument
>
... // input fields for email and product
<button type="submit">Continue with SAML SSO (Hosted SAML Provider)</button>
<button type="submit" formAction="/auth/sso/embed">Continue with SAML SSO (Embedded SAML Provider)</button>
</Form>
...
)
logout.ts: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/routes/logout.ts
export const loader: LoaderFunction = async ({ request }) => {
await auth.logout(request, { redirectTo: '/login' });
};
Private
This page renders the logged-in user profile.
private.tsx: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/routes/private.tsx
export const loader: LoaderFunction = async ({ request }) => {
const profile = await auth.isAuthenticated(request, {
failureRedirect: '/login',
});
return json<LoaderData>({ profile });
};
export default function Private() {
const { profile } = useLoaderData<LoaderData>();
return (
<>
<h1 className="text-primary mb-4 font-bold md:text-3xl">Raw profile</h1>
<pre>
<code>{JSON.stringify(profile, null, 2)}</code>
</pre>
</>
);
}
Error page
For errors occuring in the SAML flow (/api/oauth/authorize and /api/oauth/saml), the user get's redirected to an error page.
Create one at /error
:
error.tsx: https://github.com/boxyhq/jackson-remix-auth/blob/main/app/routes/error.tsx
export const loader: LoaderFunction = async ({ request }) => {
const session = await getSession(request.headers.get('Cookie'));
const { statusCode, message } = session.get(JACKSON_ERROR_COOKIE_KEY) || {
statusCode: null,
message: '',
};
return json({ statusCode, message });
};
export default function Error() {
const { statusCode, message } = useLoaderData<LoaderData>();
let statusText = '';
if (typeof statusCode === 'number') {
if (statusCode >= 400 && statusCode <= 499) {
statusText = 'client-side error';
}
if (statusCode >= 500 && statusCode <= 599) {
statusText = 'server error';
}
}
if (statusCode === null) {
return null;
}
return (
<div className="h-full">
<div className="h-[20%] translate-y-[100%] px-[20%] text-[hsl(152,56%,40%)]">
<svg
className="mb-5 h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 className="text-xl font-extrabold md:text-6xl">{statusCode}</h1>
<h2 className="uppercase">{statusText}</h2>
<p className="mt-6 inline-block">SAML error: </p>
<p className="mr-2 text-xl font-bold">{message}</p>
</div>
</div>
);
}
Ready to go
That's it, your remix app is ready for Single Sign-On. 🎉
Next steps
- Got a question? Ask here