On-Prem Deployment Guide | PropelAuth BYO Documentation
On-Prem Deployment Guide
Deploying to on-prem environments is often a requirement for customers with strict security and compliance needs. These environments are designed to either eliminate or restrict external network requests, making authentication with third-party auth providers virtually impossible.
PropelAuth BYO is designed to solve this problem. It runs entirely within your customer’s infrastructure, ensuring their data never leaves their environment. This guide walks you through deploying PropelAuth BYO in an on-prem environment using three core features: SSO, Sessions, and SCIM.
What We’ll Use
This guide leverages three core PropelAuth BYO features:
- SSO: Integrate with your customer’s internal Identity Provider (IdP) for authentication.
- Sessions: Remember authenticated users without needing to call an external auth provider.
- SCIM (optional): Automate user provisioning and de-provisioning with your customer’s IdP.
Prerequisites
- An Express application. If you do not have one, follow the guide here.
- The
@propelauth/byo-nodelibrary installed.
Configuring SSO
Your customers will authenticate through their internal Identity Provider—typically Okta, Entra ID (formerly Azure AD), or OneLogin. But this creates a chicken-and-egg problem: users need to log in to configure SSO, but they can’t log in without SSO being configured first.
Creating an Admin Dashboard
We’ll solve this by using a SETUP_SECRET environment variable. This secret allows users to access a special setup page where they can configure SSO without needing an existing account.
SETUP_SECRET=some-very-secure-random-string
When a user visits the admin dashboard in your app, they will be prompted to enter this secret. If it matches, they will gain access to the setup page where they can configure SSO.
import { Request, Response, NextFunction } from "express";
const MINIMUM_SECRET_LENGTH = 16;
export const requireSetupSecret = (req: Request, res: Response, next: NextFunction) => {
const setupSecret = process.env.SETUP_SECRET;
if (!setupSecret) {
return res.status(401).json({
error: "Authorization failed. The provided value must match the SETUP_SECRET environment variable (minimum 16 characters).",
});
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({
error: "Authorization failed. The provided value must match the SETUP_SECRET environment variable (minimum 16 characters).",
});
}
const providedSecret = authHeader.substring(7);
if (providedSecret !== setupSecret) {
return res.status(401).json({
error: "Authorization failed. The provided value must match the SETUP_SECRET environment variable (minimum 16 characters).",
});
}
next();
};
Creating an SSO Client
Now that we have a way for users to access the admin dashboard we can start setting up SSO support. This starts with creating an SSO client. If you haven’t already, check out the SSO overview to understand how PropelAuth BYO handles SSO connections.
Creating an SSO client begins with collecting information from your customer about their IdP. This includes the following:
- Client ID: The unique identifier for their application.
- Client Secret: The secret key used to authenticate their application.
- Authorize URL: The URL used to initiate the authorization request.
- Token URL: The URL used to exchange the authorization code for an access token.
- Userinfo URL: The URL used to retrieve user information.
However, we provide IdP-specific configurations for Okta and Entra/Azure that you can use to streamline the setup process for your users. These configurations automatically populate the necessary fields based on the identity provider selected by the user.
We recommend creating a UI that collects this information from your customer such as the one below:
We also need to provide the customer with a Redirect URL (also known as a Callback URL) that they need to provide to their IdP. This is where users will be redirected to after authenticating with their identity provider and will finish the login process.
router.post("/sso", async (req: Request, res: Response) => {
const parseResult = SsoCreateRequestSchema.safeParse(req.body);
if (!parseResult.success) {
throw createError(400, "Invalid request", { details: parseResult.error.flatten() });
}
const createResult = await auth.sso.management.createOidcClient({
idpInfoFromCustomer: {
idpType: "Generic",
clientId: "0oaulhbkt9YBiT3Pn697",
clientSecret: "MHppDLafzd...",
authUrl: "https://example-idp.com/oauth2/v2/auth",
tokenUrl: "https://example-idp.com/oauth2/v2/token",
userinfoUrl: "https://example-idp.com/oauth2/v2/userinfo",
usesPkce: true
},
customerId: "on_prem_customer",
redirectUrl: "https://yourapp.com/api/auth/finish-login",
});
if (createResult.ok) {
res.status(201).json({ message: "SSO configuration created successfully" });
} else {
console.error("Failed to create SSO configuration:", createResult.error);
throw createError(500, "Failed to create SSO configuration");
}
});
Adding an SSO Login Flow
In a cloud environment, you’ll have more than one customer and each customer can have their own SSO connection. By contrast, in an on-prem environment, there’s only one customer and only one SSO connection, simplifying the login flow significantly.
When a user wants to log in, initiate the login flow using the Initiate SSO Login function. This function requires a Customer ID to determine which SSO connection to use.
router.get("/login", async (_req: Request, res: Response) => {
const loginResult = await auth.sso.initiateOidcLogin({
customerId: "on_prem_customer",
});
if (loginResult.ok) {
res.cookie("__sso_state", loginResult.data.stateForCookie, { ...COOKIE_OPTIONS, maxAge: 5 * 60 * 1000 });
res.json({ redirectUrl: loginResult.data.sendUserToIdpUrl });
} else if (loginResult.error.type === "ClientNotFound") {
res.json({ error: "SSO_NOT_CONFIGURED", message: "Single Sign-On has not been configured yet. Please contact your administrator." });
} else {
console.error("Unexpected error during login:", loginResult.error);
throw UnexpectedError();
}
});
Handling the SSO Callback
After the user authenticates with their IdP they will be redirected back to your application at the Redirect URL you provided when creating the SSO client. We can create a backend endpoint to handle this callback using the Complete SSO Login function.
router.get("/finish-login", async (req: Request, res: Response) => {
const callbackResult = await auth.sso.completeOidcLogin({
stateFromCookie: req.cookies["__sso_state"],
callbackPathAndQueryParams: req.originalUrl,
});
if (callbackResult.ok) {
// TODO: Create a session for the user
} else {
console.error("Unexpected error during login callback:", callbackResult.error);
res.redirect("/login?error=login_failed");
}
});
Managing Sessions
Now that the user has authenticated with their IdP, we need to create a session for them in our application to maintain their authenticated status.
Creating Sessions
Creating a session typically happens right after a successful SSO login. We can use the user information returned from the Complete SSO Login function above to create a session using the Create Session function.
const COOKIE_OPTIONS: CookieOptions = {
httpOnly: true,
sameSite: "lax",
secure: true,
};
router.get("/finish-login", async (req: Request, res: Response) => {
const callbackResult = await auth.sso.completeOidcLogin({
stateFromCookie: req.cookies["__sso_state"],
callbackPathAndQueryParams: req.originalUrl,
});
if (callbackResult.ok) {
const { oidcUserId, scimUser, dataFromSso, email } = callbackResult.data;
const user = await getOrCreateUserFromSso(oidcUserId, scimUser, dataFromSso, email);
const sessionResult = await auth.session.create({
userId: user.id,
ipAddress: req.ip,
userAgent: req.headers["user-agent"],
});
if (!sessionResult.ok) {
console.error("Failed to create session:", sessionResult.error);
throw UnexpectedError();
}
res.cookie("__session", sessionResult.data.sessionToken, COOKIE_OPTIONS);
res.clearCookie("__sso_state");
res.redirect("/");
} else {
console.error("Unexpected error during login callback:", callbackResult.error);
res.redirect("/login?error=login_failed");
}
});
Validating Session
To validate the session, we can create middleware that checks for the session cookie and validates it using the Validate Session function.
import { Request, Response, NextFunction } from "express";
import { auth } from "../auth";
import { UnexpectedError } from "../utils/errors";
export const requireSessionOr401 = async (req: Request, res: Response, next: NextFunction) => {
if (req.sessionData) {
return next();
}
const validateResult = await auth.session.validate({
sessionToken: req.cookies.__session,
ipAddress: req.ip,
userAgent: req.headers["user-agent"] || "unknown",
});
if (validateResult.ok) {
req.sessionData = validateResult.data;
next();
} else if (validateResult.error.type === "InvalidSessionToken" || validateResult.error.type === "IpAddressError") {
res.clearCookie("__session");
res.status(401).json({ error: "Unauthorized" });
} else {
console.error("Unexpected error validating session:", validateResult.error);
throw UnexpectedError();
}
};
Logout Endpoint
router.post("/logout", async (req: Request, res: Response) => {
const sessionToken = req.cookies["__session"];
if (sessionToken) {
await auth.session.invalidateByToken({ sessionToken });
}
res.clearCookie("__session");
res.json({ success: true });
});
Configuring SCIM
Combining SCIM and SSO is a powerful way to manage user access in an on-prem environment. SCIM allows your customer’s IdP to automatically create, update, and deactivate users in your application based on their internal user directory.
Creating a SCIM Connection
Creating a SCIM connection does not require as much of an exchange of data between you and your customer as creating an SSO Client. Instead, there are just two values you need to provide your customer: a SCIM Base URL and SCIM API Key.
router.post("/scim", async (req: Request, res: Response) => {
const parseResult = ScimCreateRequestSchema.safeParse(req.body);
if (!parseResult.success) {
throw createError(400, "Invalid request", { details: parseResult.error.flatten() });
}
const createResult = await auth.scim.management.createScimConnection({
customerId: "on_prem_customer",
displayName: parseResult.data.displayName,
scimApiKeyExpiration: parseResult.data.scimApiKeyExpiration,
});
if (createResult.ok) {
const response: ScimCreateResponse = {
connectionId: createResult.data.connectionId,
scimApiKey: createResult.data.scimApiKey,
};
res.status(201).json(response);
} else {
console.error("Failed to create SCIM connection:", createResult.error);
throw createError(500, "Failed to create SCIM connection");
}
});
Handling SCIM Requests
When a SCIM request is received, you typically need to validate the request, determine the type of operation (create, update, delete, etc.), perform the corresponding action in your application’s user database, and return the appropriate SCIM response.
export const scimRoutes = async (req: Request, res: Response) => {
const scimResult = await auth.scim.scimRequest({
method: req.method.toUpperCase() as HttpMethod,
pathAndQueryParams: req.originalUrl,
body: req.body,
scimApiKey: req.headers["authorization"] as string,
});
if (scimResult.ok) {
await handleSuccessfulScimResponse(scimResult.data, res);
} else {
handleErrorScimResponse(scimResult.error, res);
}
};
Wrapping Up
Congratulations! Your application can now authenticate users via SSO, manage sessions, and optionally handle user provisioning via SCIM, all without relying on any external network calls.