January 12, 2024

Authentication for multi-tenant SaaS

Securing user data is today an absolute must.
Users want privacy and laws required it.
Still we hear about breaches and stolen passwords.
What can we do about it?

Today we will look at the “key” part of security which is allowing each user access to only the data they have permission to use.

SecureLogin

Who am I?

The very first step is making sure the user who wants to use our app/web/service, is who she says she is.
Technically: authenticating the user.

If you look around the internet, the way this usually works is that we require the user to present something she and only she knows or has.
This is a long way to say, we ask for a password (something she knows).
Or we ask for something she has (like access to the phone or email, where we send a secret key).
With biometrics this gets even trickier but let’s keep that for later.

Passwords

Email and password login is the dinosaur of authentication. Widely regarded as not really secure anymore.
Because we all just reuse password… (even if we know we shouldn’t and there are password managers).
And because it is often implemented incorrectly, allowing one hacked service to leak all our reused passwords.
And because “management” forces us to change passwords regularly so we end up with predictable passwords like January24.

Now for all the shortcommings you will have to do it… so just make sure you use

  1. Good policy (length and characters, prefer length to special characters).
  2. Good algorithm (pbkdf2, argon2)
  3. Always use salt.
  4. Never ever store the user's password anywhere.

When creating a user record:

  1. Validate password strength
  2. Generate crypto random salt (20 bytes)
  3. Use a hashing alg (pbkdf2, argon2) to produce a hash from the user password + salt
  4. Store the salt and the hash in the user record in the database.
const createUser = async (orgName: string, username: string, email: string, password: string) => {
	
	const user = await getUser(orgName, email);
	if (user) {
		throw Error("User already exists");
	}

	const hh = await pbkdf2_makeHash(password);

	const makeParam = (value:string, index:number) => ({name: "P" + index, type:"string", value: value});
	const db = await DatabaseManager.connect(orgName);
	await db.nonQuery("INSERT INTO users (name, emailaddress, pwdhash, pwdsalt) VALUES (@P0, @P1,@P2,@P3)",
			[username, email, hh.hash, hh.salt].map(makeParam));
}

When you want to authenticate the user

  1. User sends their name&password (make absolutely sure it is over https!)
  2. Retrieve the user record from database by email.
  3. Use the salt from the user record, then run the same hashing alg as when we created the user.
  4. Compare the generated hash with the database record hash
const loadAndVerifyUser = async (orgName: string, email: string, password: string): Promise<IUser|undefined> => {
	let resultUser: IUser|undefined = undefined;
	try {
		const user = await getUser(orgName, email);
		if (user) {
			let ok = false;
			if (user.pwdhash && user.pwdsalt) {
				ok = await pbkdf2_verifyHash(password, { hash: user.pwdhash, salt: user.pwdsalt });
			}
			
			if (ok) {
				resultUser = dbUserToSessionUser(user);
			}
		}
	} catch (err) {
		// FIXME: proper log. And send emails if too many of these!
		console.log("login err:" + err);
	}
	await sleep(randomInt(1000, 1500));
	return resultUser;
}
const getUser = async (orgName: string, email: string) => {
	// 1. Validate and normalize orgName and email if you haven't already
	// 	  Usually you want length<100 and ASCII.
	// 2. Get db handle for orgName
	const db = await DatabaseManager.connect(orgName);
	// 3. Get info user from db 
	const users = await db.query("SELECT * FROM users WHERE email=@P0", {param: "P0", type: "string", value: email});
	return users[0]; // undefined if not found.
}
const dbUserToSessionUser = (dbUser: any) => {
	// filter field we don't need
	return {id: dbUser.id, name: dbUser.name, email: dbUser.email};
}

Oneway hashing functions. Adjust the defaults for faster/slower runtime.

import crypto from "crypto";

export interface ICredentials {
	salt: string;
	hash: string;
}

export const pbkdf2_makeHash = async (password: string, optionsIn?: HashingOptions): Promise<ICredentials> => {

	const p = new Promise<ICredentials>((res, rej) => {
		const options = defaultOptions(optionsIn);
  
		crypto.randomBytes(options.saltlen, function (err, buf) {
			if (err) { return rej(err); }
  
			var salt = buf.toString(options.encoding);
  
			crypto.pbkdf2(password, salt, options.iterations, options.keylen, options.digestAlgorithm, (err, hashRaw) => {
				if (err) { return rej(err); }
  
				var hash = hashRaw.toString(options.encoding);
  
				res({ salt: salt, hash: hash });
			});
		});
	});
	return p;
}
  
export const pbkdf2_verifyHash = async (password: string, credentials: ICredentials, optionsIn?: HashingOptions) => {
  
	const p = new Promise<boolean>((res, rej) => {

		const options = defaultOptions(optionsIn);
  
		try {
			crypto.pbkdf2(password, credentials.salt, options.iterations, options.keylen, options.digestAlgorithm, function (err, hashRaw) {
				if (err) { return rej(err); }
				try {
					const sourceHashRaw = Buffer.from(credentials.hash, options.encoding);
					const result = crypto.timingSafeEqual(hashRaw, sourceHashRaw);
					res(result);
				} catch (e) {
					rej(e);
				}
			});
		}
		catch (e) {
			rej(e);
		}
	});
	return p;
}
  
export interface HashingOptions {
	saltlen: number;
	encoding: BufferEncoding;
	iterations: number;
	keylen: number;
	digestAlgorithm: string;
}
  
function defaultOptions(optionsIn?: HashingOptions) {
	const options: HashingOptions = {
		saltlen: optionsIn?.saltlen || 32,
		encoding: optionsIn?.encoding || 'hex',
		iterations: optionsIn?.iterations || 32000,
		keylen: optionsIn?.keylen || 512,
		digestAlgorithm: optionsIn?.digestAlgorithm || 'SHA256',
	}
	return options;
}

Important

Do not send a special error to the browser if you haven't found the user.
Use a single user not found or invalid password 403 response.

Keep a log of user authentications with the user name, IP and the User-Agent.

Hardening Tips

Use a rate-limiter for your login function. A single IP (or username) should not be allowed to call the endpoint more than X times a second (5 is a nice number). Of course this only applies if you cache the authenticated state in a cookie.
If your case requires authentication of every request this would be not practical.
Just keep in mind that the hash algs are designed to be slow!

Always add a 1 second (or more) random delay (whether we succeed or fail!) This will make it harder for an attacker to just try many alternatives.
Make sure you add the delay, even in the user-record not found case or the success-case.

Magic links

Marketing name aside, these are just simple one time passwords sent to the user’s email.
You want to make them hard to guess (obviously) and you want to make them valid for a short time.
If this sounds like a glorified password reset scheme, that is exactly what it is (minus the new password typing).

Since you already built a forget password functionality, this should be easy to do.
Recipe is very similar to the createUser function.

  1. Generate crypto random bytes at least 40 bytes
  2. Use a key derive function (pbkdf2,…)
  3. Write the hash and salt to the user record (overwrite existing) and write a validity date
  4. Send the generated bytes to user’s email. You will want to embed the bytes in a nice clickable link for a seamless UX.
const generateMagicLogin = async (orgName: string, email: string, redirect: string|undefined, reason: string|undefined) => {
	const tempPwd = crypto.randomBytes(50);
	const pwd = tempPwd.toString("hex");

	// Generate one time password
	const hh = await pbkdf2_makeHash(pwd);
	const maxValid = (new Date((new Date().valueOf()) + 300 * 1000)).toISOString();

	// Get user
	const user = await getUser(orgName, email);
	if (!user) {
		sleep(randomInt(2000, 3000));
		return; // Do not report an error! Just tell the browser an email was sent.
	}

	// Update user
	const makeParam = (value: string, index: number) => ({name: "P" + index, type: "string", value: value});
	const db = await DatabaseManager.connect(orgName);
	await db.nonQuery("UPDATE table_name SET magic_hash=@P1, magic_salt=@P2, magic_date=@P3 WHERE id=@P0",
			[user.id, hh.hash, hh.salt, maxValid]);

	// Create link
	let url = "https://yourservice.com/magic?q=" + encodeURIComponent(pwd) + "&u=" + encodeURIComponent(email) + "&o=" + encodeURIComponent(orgName);
	if (redirect)
		url += "&ret=" + encodeURIComponent(redirect);
	if (reason)
		url += "&reason=" + encodeURIComponent(reason);
	
	const body = `Hi ${user.name}

	You have requested a magic link login.
	If you haven't disregard this email.

	click here to login to ${orgName} 
	${url}
	`;

	sendEmail([user.emailaddress], [], [], "Sign in to " + orgName, body);
}

OAuth

I prefer not to be responsible for educating users on security. Especially whether or not it is good to have passsords on a sticky note.

Security is a though process of technology, education and constant vigilance. Therefore I try to defer to the experts whenever possible.

OAuth allows us to defer all the password stuff to security experts.
It is convenient for users, since they don't need to remeber one more password.
OAuth is widely used by organizations, since it helps them centralize user management.

The idea is quite straightforward.

  1. User with an gmail (john@gmail.com) wants to login to our service.
  2. We will ask Google (redirect) to verify this user owns the email address (can login into it).
  3. Google reports back to us: yes-she can, no-she cannot.

I'm sure that at this point almost everyone used or at least seen a "Sign-in with Google" button. The alg above is what is going on.

Providers

Who provides this OAuth functionality?

  1. Google
  2. Microsoft (corporate and personal)
  3. Apple (appleid)
  4. Facebook
  5. Twitter
  6. Github

...

Implementation

We will implement OAuth for Nodejs and Express with the help of the great Passportjs package.

First we need to put a button for Sign-in with Google (and/or other providers) on our login page. This button should call this:

const doLogin = (provider: string = "google") => {
	let origin = window.location.origin;
	let orgName = "user_multi_tenant_organization"; // this is usually the subdomain eg: some_company.yourservice.com
	// debug code for when origin is localhost
	const url = origin + "/auth/" + provider + "/" + orgName + "/?origin=" + encodeURIComponent(window.location.origin);
	window.location.assign(url);
}

Then in nodejs we will handle the call.

Please read the custom state part carefully.
We have to work-around a problem here.

With a multitenant service we usually want a domain for each customer like company.yourservice.com.
But, no provider will let us add a redirect url for every customer we have!
To fix this we have to:

  1. Pass the customer domain in the OAuth custom state.
  2. Let google (or other provider) call a single callback.
  3. In the callback, extract the customer domain and redirect.

Google

const app: Express = express();

// Redirect the user to the Google signin page 
app.get("/auth/google/:orgname", (req, res, next) => {
	executeGoogleAuth(req, res, next);
});

const executeGoogleAuth = (req:Request, res: Response, next: NextFunction, signup?: boolean) => {
	const nonce = crypto.randomBytes(19).toString("hex");

	const currentUrlStr = (req.header("x-forwarded-proto") || req.protocol) + 
							"://" + 
							(req.header("x-forwarded-host") || req.headers.host) || "";

	// Store the original domain in the state param (eg: user_company.myservice.com)
	const customState = nonce + (req.query["origin"] || currentUrlStr);

	const session = req.session as any;
	session["orgName"] = req.params.orgname;
	session["redirectUrl"] = req.query["redirectUrl"];
	session["google_nonce"] = nonce;
	if (signup) {
		session["signup"] = 1;
	}

	passport.authenticate("google", { scope: ["email"], state: customState } as any)(req, res, next);
}

// Called by google
app.get("/auth/google/callback", (req, res, next) => {
	const customState = (req.query.state as string || "");
	const redirect = customState.substring(38); // First 19 bytes * 2 to Hex are a non-reply nonce, skip.
	const nonce = customState.substring(0, 38);

	oauthCommonHandleSubDomainRedirect(redirect, req, res, () => {
		if ((req.session as any)['google_nonce'] !== nonce) {
			console.log("nonce error: got:" + (req.session as any)['google_nonce'] + "\nExpected: " + nonce);
			next(new Error("Authentication Failed"));
		}
		next();
	});
},
	(req, res, next) => {
		passport.authenticate("google", { session: true })(req, res, next);
},
	(req, res) => {
		// User is logged in!
		res.redirect("/dashboard"); // your service home page
	}
);

const oauthCommonHandleSubDomainRedirect = (customState: string, req: Request, res: Response, next: ()=>void) => {
	if (customState) {
		const customerOrigin = new URL(customState);
		const currentUrlStr = (req.header("x-forwarded-proto") || req.protocol) + 
									"://" + 
									(req.header("x-forwarded-host") || req.headers.host) || "";
		const currentUrl = new URL(currentUrlStr);

		// called on myservice.com instead of usercompany.mysevice.com -> redirect to 
		if (currentUrl.origin !== customerOrigin.origin) {
			let path = req.originalUrl;
			if (path[0] !== '/')
				path = "/" + path;
			//console.log("OAUTH subdomain redirect: " + path);
			res.redirect(307, customState + path);
			return; // Do not call next(). Auth will resume in the redirect.
		} 
	}
	next();
}

Let's setup google auth with Passportjs.

import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth2";

app.use(passport.initialize());
app.use(passport.session());

passport.use(new GoogleStrategy({
	clientID: GOOGLE_CLIENT_ID,
	clientSecret: GOOGLE_CLIENT_SECRET,
	callbackURL: process.env.NODE_ENV === "dev" ? "http://localhost:3001/auth/google/callback" : "https://yourservice.com/auth/google/callback",
	passReqToCallback: true
},
	async (req: any, accessToken: string, refreshToken: string, profile: any, done: any) => {
		try {
			const orgName = sanitizeOrgName((req.session as any)["orgName"]);
			const user = await getUser(orgName, profile.email);
			if (user) {
				return done(null, { googleId: profile.id, id: user.id, username: user.name, orgName: orgName });
			}

			// Uer not found? Registering a new user?
			if (!(req.session as any)["signup"]) {
				return done(new Error("User not found!"), false);
			}

			const pwd = crypto.randomBytes(20).toString("hex");
			const userId = await createUser(orgName, profile.displayName, profile.email, pwd);
			return done(null, { googleId: profile.id, id: userId, username: profile.displayName, orgName: orgName });
		} catch (error) {	
			return done(error, false)
		}
	}));

Microsoft

Add a Sign-in with Microsoft button and call doLogin("azure").

In nodejs we will handle it, in the code below. Please note, that azure allows us to get the callback with POST (great for debugging).

// Redirect the user to the Google signin page 
app.get("/auth/azure/:orgname", (req, res, next) => {
	// Pass user_company.myservice.com origin as custom state.
	// The azure passport package will add a nonce for us.
	const customState = req.query["origin"];
	(req.session as any)["orgName"] = req.params.orgname;
	(req.session as any)["redirectUrl"] = req.query["redirectUrl"];
	passport.authenticate("azure", { customState: customState } as any)(req, res, next);
});
// Retrieve user data using the access token received 
// OBSERVE we use POST here
app.post("/auth/azure/callback", (req, res, next) => {
	const customState = (req.body.state || "").substring(38); // Skip nonce random chars
	oauthCommonHandleSubDomainRedirect(customState, req, res, next);
}, passport.authenticate("azure", { session: true }),
	(req, res) => {
		// User is logged in!
		res.redirect("/dashboard"); // your service home page
	}
);

Passportjs setup for Azure.

import { OIDCStrategy } from "passport-azure-ad";

// Register your app in Azure to get the configuration. In production, load this from env instead.
const AZURE_CLIENT_ID = "";
const AZURE_CLIENT_SECRET = "";
// https://stackoverflow.com/questions/57665008/where-do-i-find-the-issuer-url-in-azure-active-directory
const AZURE_ISSUER_ID = "https://sts.windows.net/your-tenant-id";

passport.use("azure", new OIDCStrategy({
	identityMetadata: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
	clientID: AZURE_CLIENT_ID,
	clientSecret: AZURE_CLIENT_SECRET,
	responseType: 'code id_token',
	issuer: AZURE_ISSUER_ID,
	// We want azure to call our post endpoint
	responseMode: 'form_post',
	redirectUrl: process.env.NODE_ENV === "dev" ? "http://localhost:3001/auth/azure/callback" : "https://yourservice.com/auth/azure/callback",
	allowHttpForRedirectUrl: process.env.NODE_ENV === "dev",
	// Choose the scopes that make sense for your service.
	scope: "openid profile email offline_access Files.Read Files.ReadWrite.All Sites.ReadWrite.All User.Read Calendars.Read.Shared",
	passReqToCallback: true,
	validateIssuer: false,
}, async function (req: Request, iss: any, sub: any, profile: any, accessToken: any, refreshToken: any, done: VerifyCallback) {
	
	try {
		const orgName = sanitizeOrgName((req.session as any)["orgName"]);
		const user = await getUser(orgName, profile._json.email || profile._json.unique_name);
		if (user) {
			return done(null, { googleId: profile.id, id: user.id, username: user.name, orgName: orgName, refreshToken: refreshToken });
		}
		if (!(req.session as any)["signup"]) {
			return done(new Error("User not found!"), false);
		}

		const pwd = crypto.randomBytes(20).toString("hex");
		const userId = await createUser(orgName, profile.displayName, profile.email, pwd);
		return done(null, { googleId: profile.id, id: userId, username: profile.displayName, orgName: orgName });
	} catch (error) {
		return done(error, false)
	}
}));

As you can see the underlying algorithm is 99% similar.
But there is some non-trivial setup for each of the provider.
So decide which ones make sense for your service and users.

Register with providers

Before users can login with a provider's OAuth you will have to register your service with each provider.

Google

  1. Login to Google cloud console
  2. If you don't have a project yet create it
  3. In your project got to Credentials here
  4. In the toolbar at the top choose + Create Credentials and OAuth client ID
  5. Choose Web Application
  6. Fill out required information. For redirect put: http://localhost:3001/auth/google/callback https://yourservice.com/auth/google/callback
  7. In your project got to OAuth consent screen here

Test that login works and then record a short video and put in on youtube (private link). In the video:

  1. start on your home page
  2. go to the sign-in page
  3. click the "Sign-in with Google" button
  4. complete login
  5. show the profile page of the user in your service (or some dashboard page).

Once the video is uploaded, add the link in the OAuth consent screen. Google will review and approve your app.

Microsoft / Azure

  1. Login to azure
  2. Register your app here
  3. For supported account types choose: Accounts in any organizational directory
  4. Register application

Now it gets a little hairy.
For multitenant apps, you will have to become an office Microsoft Partner.
The partnership is free (as of 2024), but it might take a few days to complete.
Register here

Once you are a MS partner, create the app registration.
It might complain that you need MFA enabled for your azure organization.
It is enough to start a free trail and enable MFA.
(Yes this is quite painful.)

Apple Update from the future, checkout our Sign in with Apple blog.

Secure at last?

I hope the code was useful on your way to a secure multitenant SaaS service.
(Btw. I would love to hear about it.)

Security is a process

Please keep it in mind. No matter which solution(s) you will end up using.
You always have to educate (yourself and your users), monitor and improve.

Happy hacking!