NodeJS/ExpressJS/passport-saml ADFS SingleLogout implementation - node.js

I did not know where to go next so I'm going to post my issue here, as I've already seen some related issues on this matter. Unfortunately the solutions provided did not work in my case, and I do not know what to try more.
So some background: I have a NodeJS/ExpressJS/passport-saml application that authenticates against an ADFS system. The SSO part of the matter works perfectly, but I can't seem to get the SLO part working.
What happens is that when I initiate either a SP-initiated or IdP-initiated logout it hangs on the first SP. This first SP is being logged out correctly, but it is then redirected to the login page of the first SP and keeps waiting for the credentials to be entered, effectively halting the redirect chain that has to happen.
What I've tried so far is a lot, including using both POST and HTTP-Redirect bindings on my SLO ADFS endpoint/NodeJS server, modifying the routes etc.
Current implementation is as follows:
SLO endpoint configuration (equal for each SP, the blacked out part contains ):
The passport-saml configuration is as follows on the SP server:
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ IMPORTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
// NodeJS native
const path = require('path');
const fs = require('fs');
// NodeJS packages
const SamlStrategy = require('passport-saml').Strategy;
const { Database } = require('../../Database');
// Custom imports
const { ApplicationConfiguration } = require('../../ApplicationConfiguration');
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ CONSTANTS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
let strategy = {};
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ INIT ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
/**
* Initialise the passport saml strategy with the necessary configuration parameters.
*/
const initStrategy = () => {
// Get additional required configuration
const config = ApplicationConfiguration.getProperties([
['CGS_HOST'],
['AUTH_PORT'],
['SSO', 'host'],
['SSO', 'identifier'],
['SSO', 'cert'],
['SSO', 'algorithm'],
['HTTPS_CERT_PRIVATE_PATH'],
]);
// Define the SAML strategy based on configuration
strategy = new SamlStrategy(
{
// URL that should be configured inside the AD FS as return URL for authentication requests
callbackUrl: `https://${<sp_host_name>}:${<sp_port_value>}/sso/callback`,
// URL on which the AD FS should be reached
entryPoint: <idp_host_name>,
// Identifier for the CIR-COO application in the AD FS
issuer: <sp_identifier_in_idp>,
identifierFormat: null,
// CIR-COO private certificate
privateCert: fs.readFileSync(<sp_server_private_cert_path>, 'utf8'),
// Identity Provider's public key
cert: fs.readFileSync(<idp_server_public_cert_path>, 'utf8'),
authnContext: ['urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'],
// AD FS signature hash algorithm with which the response is encrypted
signatureAlgorithm: <idp_signature_algorithm>,
// Single Log Out URL AD FS
logoutUrl: <idp_host_name>,
// Single Log Out callback URL
logoutCallbackUrl: `https://${<sp_host_name>}:${<sp_port_value>}/slo/callback`,
// skew that is acceptable between client and server when checking validity timestamps
acceptedClockSkewMs: -1,
},
async (profile, done) => {
// Map ADFS groups to Group without ADFS\\ characters
const roles = profile.Roles.map(role => role.replace('ADFS\\', ''));
// Get id's from the roles
const queryResult = await Database.executeQuery('auth-groups', 'select_group_ids_by_name', [roles]);
// Map Query result to Array for example: [1,2]
const groupIds = queryResult.map(group => group.id);
done(null,
{
sessionIndex: profile.sessionIndex,
nameID: profile.nameID,
nameIDFormat: profile.nameIDFormat,
id: profile.DistinguishedName,
username: profile.DistinguishedName,
displayName: profile.DisplayName,
groups: profile.Roles,
mail: profile.Emailaddress,
groupIds,
});
},
);
// Return the passport strategy
return strategy;
};
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ PASSPORT CONFIG ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
/**
* Initialise the passport instance and add the saml passport strategy to it for authentication
* #param {Object} passport - Passport object
*/
const initPassport = (passport) => {
// (De)serialising
passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((user, done) => {
done(null, user);
});
// Initialise the strategy
const passportStrategy = initStrategy();
// Addition strategy to passport
passport.use('saml', passportStrategy);
};
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ HELPERS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
/**
* Get the metadata from the Service Provider (this server).
* #param {String} publicPath - Path to public certificate
* #return {Promise<any>} - Metadata object for this application
*/
const getMetaData = publicPath => new Promise((resolve) => {
const metaData = strategy.generateServiceProviderMetadata({}, fs.readFileSync(path.join(publicPath), 'utf8'));
resolve(metaData);
});
/**
* Construct a Single Logout Request and send it to the IdP.
* #param {Object} req - Default request object
* #param {Object} res - Default response object
*/
const logout = (req, res) => {
// Construct SLO request for IdP
strategy.logout(req, (err, url) => {
req.logOut();
// Redirect to SLO callback URL and send logout request.
return res.redirect(url);
});
};
const getStrategy = () => strategy;
module.exports = {
initPassport,
getStrategy,
getMetaData,
logout,
};
And the relevant routes and functions are as follows:
const logOutLocalSession = sid => new Promise(((resolve, reject) => {
log.info(`Received request to destroy session with sid ${sid}.`);
// Destroy local session
store.destroy(sid, (err) => {
if (err) {
log.error(`Error occurred while logging out local session with SID ${sid}: ${err}`);
reject('Onbekende fout opgetreden bij uitloggen lokaal.');
}
log.info(`Successfully logged out user locally with SID ${sid}.`);
resolve();
});
}));
const logOutAllSessions = async (req, res) => {
// Extract username to get all sessions
const { username } = req.session.passport.user;
log.info(`Received request to log user ${username} out of all sessions.`);
const sessionIdsRes = await Database.executeQuery('sessions', 'select_sids_by_user_id', [username]);
// Loop over all sessions and destroy them
const destroyPromises = [];
sessionIdsRes.forEach((sessionIdRes) => {
destroyPromises.push(logOutLocalSession(sessionIdRes.sid));
});
await Promise.all(destroyPromises);
// Remove local session from request
req.session = null;
log.info(`User ${username} logged out successfully from all known sessions.`);
};
const logOutIdp = (req, res) => {
const { username } = req.session.passport.user;
log.info(`Received request to log out user ${username} on Identity Provider.`);
const strategy = passportImpl.getStrategy();
// Create logout request for IdP
strategy.logout(req, async (err, url) => {
// Destroy local sessions
logOutAllSessions(req, res);
// Redirect to SLO callback URL and send logout request.
return res.redirect(url);
});
};
// SP initiated logout sequence
app.get('/auth/logout', (req, res) => {
const { username } = req.session.passport.user;
// If user not logged in, redirect to login
if (!req.user) {
return res.redirect('/saml/login');
}
if (username === 'Administrator' || username === 'Support user') {
logOutLocalSession(req.session.id);
} else {
logOutIdp(req, res);
}
});
// IdP initiated logout sequence or from other SP
app.post('/slo/callback', logOutAllSessions);
If there is some information missing I will be able to provide. I do hope I can get some leads on what to try next! Thanks in advance !

In terms of the ADFS configuration:
"Trusted URL" should be the ADFS logout endpoint - you can see that in the metadata - so that ADFS can clear cookies.
"Response URL" should be the endpoint in your app. that expects the SLO response so that it can clear client cookies.

Related

How to check wheter the Azure AD token send by React to Node.js is valid

Hi I have a code from https://github.com/Azure-Samples/ms-identity-javascript-react-spa
I changed it a little bit, so instead calling an Microsoft Graph API endpoint, I call mine endpoint on localhost:7000.
So it basically starts with me logging in (here i did not change enything). Then there is this function which acquires token:
const { instance, accounts } = useMsal();
const [graphData, setData] = useState(null);
function RequestProfileData() {
// Silently acquires an access token which is then attached to a request for MS Graph data
instance
.acquireTokenSilent({
...loginRequest,
account: accounts[0],
})
.then((response) => {
callMyEndpoint(response.accessToken).then((response) =>
setData(response)
);
});
}
it uses function callMyEndpoint which looks like this:
export async function callMyEndpoint(accessToken) {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append("Authorization", bearer);
const options = {
method: "POST",
headers: headers,
};
return fetch("http://localhost:7000/myendpoint", options)
.then((response) => response.json())
.catch((error) => console.log(error)) // if the user is not logged in- catch an error;
}
Now, onto my Node.js backend application where the http://localhost:7000/myendpoint is served.
app.post("/myendpoint", async (req, res) => {
console.log("TOKEN", req.headers.authorization); // it is being printed here, everything seems fine.
// here i would like to check whether the token is valid
// if req.headers.authorization == AZURE_TOKEN?
// How to do this?
});
And now the question is? How to check in backend if the token send from frontend is valid for the user, so only logged users, or users which are added in my app registration in azure can post onto this request?
You can use the libraries such as validate-azure-ad-token or you can write your own logic using jsonwebtoken
Here I have my custom logic for that first you will need client_id , tenat_id and scope name.
I am assuming you already have client and tenant id and for scope name it will be available in the Expose Api tab of your app registration.
Here I have console app which will take your token and try to validate it.
var jwt = require('jsonwebtoken');
var token = 'your Token';
var clientid = '' ;
var tenantid = "" ;
var scope = "";
// Create an audiance variable
var audiance = 'api://'+clientid;
// decoded token
var decodedToken = jwt.decode(token , {complete :true});
if((decodedToken.payload.aud==audi)&&(decodedToken.payload.scp==scope)&&(decodedToken.payload.tid==tenantid))
{
console.log("The token is valid");
}
else
{
console.log("The Token is invalid")
}
Output :

Error while trying to login with through Steam from node.js

I'm trying to login through steam from my webapp but I'm having an hard time.
This is the code in my backend (I'm using Firebase cloud functions) that let me authenticate my user.
const steam = new SteamAuth({
realm: "https://storm.co.gg", // Site name displayed to users on logon
returnUrl: "http://localhost:5001/stormtestfordota/europe-west1/api/auth/steam/authenticate", // Your return route
apiKey: apiKey // Steam API key
});
let loggedUser = "";
const redirectSteamAuth = async (req, res) => {
loggedUser = req.user.username;
const redirectUrl = await steam.getRedirectUrl();
return res.json(redirectUrl);
}
So this is the first endpoint that the user calls when trying to login to Steam. And it works, so the steamcommunity.com opens without problem.
But when I click login in steamcommunity page I'm prompted with this error
So over the name of my account you can see "ERRORE" that stands for "ERROR"
This is the endpoint that should be called after authentication:
const loginWithSteam = async (req, res) => {
try {
const user = await steam.authenticate(req);
db.collection("users").doc(loggedUser).update({
steamId: user.steamid
})
activeDota2(loggedUser, user.steamid);
return res.redirect("https://storm.co.gg/dashboard/home");
} catch (error) {
console.error(error);
return res.status(401).json(error)
}
}
These are the two endpoints:
app.post("/auth/steam", (req, res, next) => validateFirebaseIdToken(req, res, next), redirectSteamAuth);
app.get("/auth/steam/authenticate", loginWithSteam);
I solved this issue. The problem was in the urls of the steam object AND there was a problem with CORS options, I didn't add the DNS of steamcommunity in origins accepted by CORS.

How to use authenticate web sockets using node, ws and passport with JWT?

I have a web server written in node/express which uses passport to authenticate users with JSON Web Tokens (JWT).
For regular HTTP methods this is what I use:
app.get('/api/stuff', isLoggedIn(), (req, res) => {
//get stuff
res.send( whatever );
});
Where isLoggedIn is this function:
function isLoggedIn() {
return passport.authenticate('local-jwt', { session: false });
}
The authentication itself is handled by the 'local-jwt' configuration in passport using a JWTStrategy object and it works as expected.
Now I need to add web sockets to this app. I'm using the ws library. Here is what I have so far:
const wss = new WebSocket.Server({
port: 8080,
verifyClient: async (info, done) => {
// ???
done( result );
}
});
wss.on('connection', ws => {
// web socket events
});
How do I use the passport authentication to only allow clients with the correct token to connect to the web socket server?
You can sign the token with the user's id or anything you are using to differentiate users
var today = new Date();
var exp = new Date(today);
exp.setDate(today.getDate() + 60);
jwt.sign({
id: user._id,
exp: parseInt(exp.getTime() / 1000),
}, secret);
then pass the token to the frontend and use it to initialize your Websocket connection with it appended to the url then take and decode it in the server like this
const wss = new WebSocket.Server({
verifyClient: async (info, done) => {
console.log(info.req.url);
console.log('------verify client------');
const token = info.req.url.split('/')[1];
var decoded = jwt.verify(token, secret);
info.req.user = await User.findById(decoded.id).exec();
/*info.req.user is either null or the user and you can destroy the connection
if its null */
done(info.req);
},
server
});
This is one of many ways. There is a way with sessions but I see you don't want to use them. Also I don't know if you use mongo but it's the same for any database you just have to change some code.

Cloud Function to Authenticate a User

I am attempting to authenticate a user to access various scopes in the user Gsuite. I can run the code locally but I cannot seem to get it accepted as a cloud function.
I have tried deploying with firebase and with gcloud. I have checked my eslint settings.
This code is coming from https://github.com/googleapis/google-api-nodejs-client/blob/master/README.md#oauth2-client
'use strict';
const fs = require('fs');
const path = require('path');
const http = require('http');
const url = require('url');
const opn = require('open');
const destroyer = require('server-destroy');
const {google} = require('googleapis');
/**
* To use OAuth2 authentication, we need access to a a CLIENT_ID, CLIENT_SECRET, AND REDIRECT_URI. To get these credentials for your application, visit https://console.cloud.google.com/apis/credentials.
*/
const keyPath = path.join(__dirname, 'credentials.json');
let keys = {redirect_uris: ['']};
if (fs.existsSync(keyPath)) {
keys = require(keyPath).web;
}
/**
* Create a new OAuth2 client with the configured keys.
*/
const oauth2Client = new google.auth.OAuth2(
keys.client_id,
keys.client_secret,
`http://localhost:3000/oauth2callback`
);
/**
* This is one of the many ways you can configure googleapis to use authentication credentials. In this method, we're setting a global reference for all APIs. Any other API you use here, like google.drive('v3'), will now use this auth client. You can also override the auth client at the service and method call levels.
*/
google.options({auth: oauth2Client});
const scopes = ['https://www.googleapis.com/auth/documents'];
/**
* Open an http server to accept the oauth callback. In this simple example, the only request to our webserver is to /callback?code=<code>
*/
async function authenticate(){
// grab the url that will be used for authorization
const authorizeUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: scopes
});
const server = http.createServer(async (req, res) => {
try {
if (req.url.indexOf('/oauth2callback') > -1) {
const qs = new url.URL(req.url, 'http://localhost:3000').searchParams;
res.end('Authentication successful! Please return to the console.');
server.destroy();
const {tokens} = await oauth2Client.getToken(qs.get('code'));
oauth2Client.credentials = tokens; // eslint-disable-line require-atomic-updates
resolve(oauth2Client);
}
} catch (e) {
reject(e);
}
})
.listen(3000, () => {
// open the browser to the authorize url to start the workflow
opn(authorizeUrl, {wait: false}).then(cp => cp.unref())
.catch(
error => {
console.log(error);
});
});
destroyer(server)
.then(client => runSample(client)).catch(
error => {
console.log(error);
});
};
module.exports.authenticate=authenticate;
async function runSample(client) {
// retrieve user profile
console.log(client);
const docs = google.docs({
version: 'v1',
auth: client
});
const createResponse = await docs.documents.create({
requestBody: {
title: 'Your new document!',
},
});
}
I expect it to load as a cloud function to firebase or gcloud.
However:
Firebase returns "Deploy complete" but it never shows in the functions.
gcloud returns "SyntaxError: Unexpected token function" with the word function indicated in "async function authenticate(){"
I'm new to node.js and may be missing something really obvious to others.
You will never get User Credentials (Client ID/Client Secret) to work
in Cloud Functions (meaning authenticate and create credentials).
OAuth requires a web browser and a human. Neither one exists in Cloud
Functions. Use a Service Account instead. – John Hanley

In firebase, create a custom token with specific exp?

I notice that the docs specify that I can create a token to expire up to 3600 seconds later[1] But I don't see how to do that with auth().createCustomToken ... I can manually do it with jsonwektoken, but it seems like this should be addressable directly with firebase-admin library.
Another question is, what is the secret I need to verify my own token generated in this way, the uid ?
index.js
// demo server generating custom auth for firebase
import Koa from 'koa'
import Koajwt from 'koa-jwt'
import Token from './token'
const app = new Koa()
// Custom 401 handling if you don't want to expose koa-jwt errors to users
app.use(function(ctx, next){
return next().catch((err) => {
if (401 == err.status) {
ctx.status = 401
ctx.body = 'Protected resource, use Authorization header to get access\n'
} else {
throw err
}
})
})
// Unprotected middleware
app.use(function(ctx, next){
if (ctx.url.match(/^\/login/)) {
// use router , post, https to securely send an id
const conf = {
uid: 'sample-user-uid',
claims: {
// Optional custom claims to include in the Security Rules auth / request.auth variables
appid: 'sample-app-uid'
}
}
ctx.body = {
token: Token.generateJWT(conf)
}
} else {
return next();
}
});
// Middleware below this line is only reached if JWT token is valid
app.use(Koajwt({ secret: 'shared-secret' }))
// Protected middleware
app.use(function(ctx){
if (ctx.url.match(/^\/api/)) {
ctx.body = 'protected\n'
}
})
app.listen(3000);
token.js
//import jwt from 'jsonwebtoken'
import FirebaseAdmin from 'firebase-admin'
import serviceAccount from 'demo-admin-firebase-adminsdk-$$$$-$$$$$$.json'
export default {
isInitialized: false,
init() {
FirebaseAdmin.credential.cert(serviceAccount)
isInitialized = true
},
/* generateJWTprimiative (payload, signature, conf) {
// like: jwt.sign({ data: 'foobar' }, 'secret', { expiresIn: '15m' })
jwt.sign(payload, signature, conf)
} */
generateJWT (conf) {
if(! this.isInitialized)
init()
FirebaseAdmin.auth().createCustomToken(conf.uid, conf.claims)
.then(token => {
return token
})
.catch(err => {
console.log('no token generate because', err)
})
}
}
[1] https://firebase.google.com/docs/auth/admin/create-custom-tokens
You can't change the token expiration. The docs you found includes the words:
Firebase tokens comply with the OpenID Connect JWT spec, which means
the following claims are reserved and cannot be specified within the
additional claims:
... exp ...
This is further backed up by inspecting the Firebase Admin SDK source code on GitHub.
In this section:
public createCustomToken(uid: string, developerClaims?: {[key: string]: any}): Promise<string> {
// .... cut for length ....
const header: JWTHeader = {
alg: ALGORITHM_RS256,
typ: 'JWT',
};
const iat = Math.floor(Date.now() / 1000);
const body: JWTBody = {
aud: FIREBASE_AUDIENCE,
iat,
exp: iat + ONE_HOUR_IN_SECONDS,
iss: account,
sub: account,
uid,
};
if (Object.keys(claims).length > 0) {
body.claims = claims;
}
// .... cut for length ....
You can see the exp property is hard coded to be iat + ONE_HOUR_IN_SECONDS where the constant is defined elsewhere in the code as 60 * 60...
If you want to customize the expiration time, you will HAVE to create your own token via a 3rd party JWT package.
To your 2nd question, a secret is typically stored in the server environment variables, and is a pre-set string or password. Technically you could use the UID as the secret, but that would be a TERRIBLE idea security wise - please don't do this. Your secret should be like your password, keep it secure and don't upload it with your source code to GitHub. You can read more about setting and retrieving environment variables in Firebase in these docs here

Resources