My app is acting as an OAuth server for a 3rd party service which will be accessed through an in our app. The authentication process all happens behind the scenes without an intermediate screen for a user to click a log in button (since the process is initiated from within our app already). The challenge is that since all the OAuth requests are initiated from the 3rd party within the iframe
(and therefore we can't interact with or intercept them), the only way we can identify the user who should be authenticated is through a session cookie.
So on registration or login to our app, we set req.session.userId
equal to the id of the current logged-in user. The expectation would be that when the iframe
initiates the requests to our OAuth endpoints, beginning with GET /oauth/code
that we could read the value of req.session.userId
on the incoming request. However, this did not seem to work, no matter what set of configurations I made on the Express session. By inspecting the request in more detail, I did notice the session with the userId
value was present within req.sessionStore
, so I set up my authCode handler to loop over these sessions and find the one with userId
set.
This appeared to be working, until we noticed in a live environment that in certain specific instances, a user would log into our app and see a completely different user's account within their iframe. (One example is User A authenticates within our app and the 3rd party, then User B opens an incognito window and navigates directly to the 3rd party site without being logged in on our app). In this instance, User A's session appears to be present in the sessionStore on the request from User B. This is obviously not OK, so it appears to be back to the drawing board and trying to determine why we are not getting the userId
value back from req.session
on incoming requests.
I'll post relevant code here, but let me know if there's anything else that would help to understand the problem better.
/api/app.js
const session = require('express-session');
// Express Session Middleware
app.set('trust proxy', 1); // this is for ngrok in our local instnace
app.use(session({
secret: 'starboard penguin disco',
resave: true,
name: 'test',
proxy: true,
saveUninitialized: true,
cookie: {
httpOnly: true,
sameSite: 'none',
secure: false, // this is false now for local testing but true in production
},
}));
/api/auth.js
exports.login = async (req, res, next) => {
try {
const user = await db.User.findOne({
email: req.body.email,
});
const { id, fullName, email } = user;
const isMatch = await user.comparePassword(req.body.password, next);
if (isMatch) {
({ accessToken, refreshToken } = generateTokens(user));
// Save user id to sesson cookie
req.session.userId = id;
return res.status(201).json({
id,
email,
fullName,
jwt: accessToken,
refresh_token: refreshToken,
});
}
return next({
status: 400,
message: 'Invalid email or password',
});
} catch (err) {
return next({
status: 400,
message: 'Invalid email or password',
});
}
};
/api/handlers/oauth.js
// This handles GET calls to /oauth/code and is the first of 3 requests made by 3rd party
exports.getAuthCode = async (req, res) => {
try {
// Extract user ID from request
let userId = '';
console.log(req.session.userId) // returns undefined
const sessionStore = promisify(req.sessionStore.all.bind(req.sessionStore));
const sessions = await sessionStore();
Object.values(sessions).forEach((session) => {
if (session.userId) {
userId = session.userId;
}
});
console.log('getting user id from session');
console.log(userId);
// Generate a secure random authorization code
const codeValue = crypto.randomBytes(16).toString('hex');
// Save the code along with the client ID, redirect URI, and user ID
const authCode = await AuthorizationCode.create({
code: codeValue,
clientId: req.query.client_id, // Assuming this is passed by the client
redirectUri: req.query.redirect_uri, // Assuming this is passed by the client
expiresAt: new Date(Date.now() + 10 * 60 * 1000), // Code expires in 10 minutes
user: { id: userId },
});
await authCode.save();
return res.redirect(`${req.query.redirect_uri}?code=${authCode.code}`);
} catch (err) {
console.error(err);
return res.status(err.code || 500).json(err);
}
};
A flow diagram of your architecture would really help to understand your problem. The following is a flow diagram of what i presume your architecture is doing, but i could have off course misunderstood it. Also, since you talk about Iframes I assume that your app is running inside a browser, where cross-origin cookies behave as expected. Some info about that would also help.
With that said, are you sure the request made from inside the iframe includes the session cookie set outside of the iframe?
That would be the most obvious reason for why you cannot access req.session.userId
: You simply are not sending the cookie with the request, therefore you are not accessing any session data.
The request from inside the Iframe is a cross-site request, it will be sent only if the cookie has both sameSite = 'none' and secure=true
in your code, in /api/app.js i noticed this comment:
...
cookie: {
httpOnly: true,
sameSite: 'none',
secure: false, // this is false now for local testing but true in production
},
If you were testing it with secure: false it would never have worked, You can read more about this behaviour here
Now, about looping the sesion data: The correct way to read and write session data is by accessing the properites directly:
req.session.someData = "test" //write some session data
console.log(req.session.userId) //access some other session data
If you are not reading the data, the issue is somewhere else in the session, for example you could not be sending the cookie as I hypothesized.
No matter what the issue is, this code is doing something very dangerous, and it's absolutely not what you want:
Object.values(sessions).forEach((session) => {
if (session.userId) {
userId = session.userId;
}
});
This is basically the same thing as logging the user into a random account they don't own if they don't provide a valid session.