I’m using a custom SAML login flow in my app with passport-saml and MultiSamlStrategy (Node.js/NestJS). Each org can upload its own IdP metadata (Okta, Microsoft, onelogin), and everything works fine — except in one edge case with Okta:
If the user starts SAML login from my app (SP), they’re redirected to the Okta login page.
If they log in with email/password, they’re correctly redirected back to my app.
But if they click “Need help signing in?” → “Sign in with Google”, they get authenticated, but Okta redirects them to the Okta dashboard, not back to my app.
I suspect this is because the Google login bypasses the original SAML request and doesn’t preserve the RelayState.
Is this expected behavior? How can I force users to complete the SAML flow or disable the Google login option entirely to avoid this?
my saml.strategy.service.ts file:
Blockquote
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Request } from 'express';
import * as fs from 'fs';
import { MultiSamlStrategy, SamlConfig } from 'passport-saml';
import { Config } from 'src/config';
import { DatabaseService } from 'src/database/database.service';
@Injectable()
export class SamlStrategy extends PassportStrategy(MultiSamlStrategy, 'saml') {
constructor(private readonly databaseService: DatabaseService) {
super({
private_key: fs.readFileSync('./certs/sp-private-key.pem'), // You should generate this based on your system's requirements
certificate: fs.readFileSync('./certs/sp-certificate.pem'), // You can use a self-signed certificate or a public key
assert_endpoint: Config.SAML.CALLBACK_URL,
passReqToCallback: true, // Ensures we can access the request inside `getSamlOptions`,
getSamlOptions: async (req: Request, done) => {
try {
// let existingRelayState = {};
const relayState = req?.body?.RelayState
? JSON.parse(decodeURIComponent(req?.body?.RelayState))
: {};
console.log('relayState parsed', relayState);
// console.log('req', req?.query);
const email =
req.body?.email || req.query?.email || relayState?.email;
const relayStateToPass = { email };
const returnTo = req?.query?.returnTo || '';
const redirectTo = req?.query?.redirectTo || '';
if (!!returnTo) {
relayStateToPass['returnTo'] = returnTo;
}
if (!!redirectTo) {
relayStateToPass['redirectTo'] = redirectTo;
}
req.query.RelayState = encodeURIComponent(JSON.stringify(relayStateToPass));
const user = await this.databaseService.getUserFromDB({
userIdEmailPhone: email,
});
// console.log("user", user);
if (!user || !user.orgID) {
return done(null, {
errorMessage: 'User or Organization not found',
});
}
const organization = await this.databaseService.getOrganization({
orgID: user.orgID,
});
const idpConfig = organization?.idpConfig;
// console.log('idpConfig', idpConfig);
if (!organization || !idpConfig?.entryPoint || !idpConfig?.cert) {
return done(null, {
errorMessage: 'Organization or SSO Configuration not found!',
});
}
const samlOptions: SamlConfig = {
issuer: Config.SAML.ISSUER,
entryPoint: idpConfig?.entryPoint,
cert: idpConfig?.cert,
callbackUrl: Config.SAML.CALLBACK_URL,
idpIssuer: idpConfig?.issuer,
logoutCallbackUrl: Config.SAML.LOGOUT_CALLBACK_URL,
logoutUrl: idpConfig?.logoutUrl,
forceAuthn: true,
};
return done(null, samlOptions);
} catch (error) {
console.error('SAML Config Error:', error);
return done(error);
}
},
});
}
// This method will be called after successful authentication
async validate(req: Request, profile, done) {
console.log('SAML Authenticated Profile:', profile);
// console.log('done', done);
let returnTo = '';
let redirectTo = '';
const relayState = req?.body?.RelayState
? JSON.parse(decodeURIComponent(req?.body?.RelayState))
: {};
if (relayState?.returnTo) returnTo = relayState.returnTo;
if (relayState?.redirectTo) redirectTo = relayState.redirectTo;
if (relayState?.email !== profile?.email) {
return done(null, {
errorMessage:
'Something went wrong, Please logout from your IDP and try again!',
});
}
req.query.returnTo = returnTo;
req.query.redirectTo = redirectTo;
return done(null, profile);
}
}