Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): add support for external JWT management via identity providers, fallback to self-signed JWTs #4398

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@ OPENID_ISSUER=
OPENID_SESSION_SECRET=
OPENID_SCOPE="openid profile email"
OPENID_CALLBACK_URL=/oauth/openid/callback
OPENID_JWKS_URI=
OPENID_TOKEN_ENDPOINT_URI=
OPENID_REVOKATION_ENDPOINT_URI=
OPENID_REQUIRED_ROLE=
OPENID_REQUIRED_ROLE_TOKEN_KIND=
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
Expand Down
38 changes: 38 additions & 0 deletions api/models/Session.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,44 @@ sessionSchema.methods.generateRefreshToken = async function () {
throw error;
}
};
sessionSchema.methods.storeRefreshToken = async function (refreshToken, expiresIn, userId) {
try {
if (!userId) {
throw new Error('User ID is required to update refresh token');
}
if (!refreshToken) {
throw new Error('Refresh token is required to update refresh token');
}
if (typeof expiresIn === 'undefined' || expiresIn === null || isNaN(expiresIn)) {
throw new Error('Valid expiration time is required to update refresh token');
}

const expirationDate = new Date(expiresIn);
if (isNaN(expirationDate.getTime())) {
throw new Error('Invalid expiration date calculated from expiresIn');
}

const refreshTokenHash = await hashToken(refreshToken);

let session = await mongoose.model('Session').findOne({ user: userId });
if (!session) {
session = new mongoose.model('Session')({
user: userId,
refreshTokenHash,
expiration: expirationDate,
});
} else {
session.refreshTokenHash = refreshTokenHash;
session.expiration = expirationDate;
}

await session.save();
return session;
} catch (error) {
logger.error('[storeRefreshToken] Error storing refresh token:', error);
throw error;
}
};

sessionSchema.statics.deleteAllUserSessions = async function (userId) {
try {
Expand Down
2 changes: 2 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"dedent": "^1.5.3",
"dotenv": "^16.0.3",
"express": "^4.21.0",
"express-jwt": "^8.4.1",
"express-mongo-sanitize": "^2.2.0",
"express-rate-limit": "^6.9.0",
"express-session": "^1.17.3",
Expand All @@ -67,6 +68,7 @@
"ioredis": "^5.3.2",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.1.0",
"keyv": "^4.5.4",
"keyv-file": "^0.2.0",
"klona": "^2.0.6",
Expand Down
115 changes: 90 additions & 25 deletions api/server/controllers/AuthController.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ const {
requestPasswordReset,
} = require('~/server/services/AuthService');
const { hashToken } = require('~/server/utils/crypto');
const { Session, getUserById } = require('~/models');
const { Session, getUserById, findUser } = require('~/models');
const { logger } = require('~/config');
const { default: axios } = require('axios');
const { isOpenIDConfigured } = require('~/strategies/validators');

const registrationController = async (req, res) => {
try {
Expand Down Expand Up @@ -60,34 +62,97 @@ const refreshController = async (req, res) => {
}

try {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await getUserById(payload.id, '-password -__v');
if (!user) {
return res.status(401).redirect('/login');
}
if (isOpenIDConfigured()) {
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: process.env.OPENID_CLIENT_ID || '',
client_secret: process.env.OPENID_CLIENT_SECRET || '',
scope: process.env.OPENID_SCOPE || 'openid profile email',
});

const response = await axios.post(
process.env.OPENID_TOKEN_ENDPOINT_URI,
params.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);

const decodedToken = jwt.decode(response.data.id_token);
const openidId = decodedToken?.sub;

if (!openidId) {
return res.status(401).send('Unable to retrieve user subject from token');
}

const user = await findUser({ openidId }, '-password -__v');

if (!user) {
return res.status(401).redirect('/login');
}

const userId = user._id;

const userId = payload.id;
if (process.env.NODE_ENV === 'CI') {
const token = await setAuthTokens(userId, res, response.data);
return res.status(200).send({ token, user });
}

if (process.env.NODE_ENV === 'CI') {
const token = await setAuthTokens(userId, res);
return res.status(200).send({ token, user });
// Hash the refresh token
const hashedToken = await hashToken(refreshToken);

// Find the session with the hashed refresh token
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });

const isTokenExpired = (tokenExpiry) => {
return tokenExpiry < Math.floor(Date.now() / 1000);
};

if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, response.data, session._id);
res.status(200).send({ token, user });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)
res.status(403).send('No session found');
} else if (isTokenExpired(response.data.expires_in)) {
res.status(403).redirect('/login');
} else {
res.status(401).send('Refresh token expired or not found for this user');
}
}
else {
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await getUserById(payload.id, '-password -__v');
if (!user) {
return res.status(401).redirect('/login');
}

// Hash the refresh token
const hashedToken = await hashToken(refreshToken);

// Find the session with the hashed refresh token
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session._id);
res.status(200).send({ token, user });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)
res.status(403).send('No session found');
} else if (payload.exp < Date.now() / 1000) {
res.status(403).redirect('/login');
} else {
res.status(401).send('Refresh token expired or not found for this user');
const userId = payload.id;

if (process.env.NODE_ENV === 'CI') {
const token = await setAuthTokens(userId, res);
return res.status(200).send({ token, user });
}

// Hash the refresh token
const hashedToken = await hashToken(refreshToken);

// Find the session with the hashed refresh token
const session = await Session.findOne({ user: userId, refreshTokenHash: hashedToken });
if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session._id);
res.status(200).send({ token, user });
} else if (req?.query?.retry) {
// Retrying from a refresh token request that failed (401)
res.status(403).send('No session found');
} else if (payload.exp < Date.now() / 1000) {
res.status(403).redirect('/login');
} else {
res.status(401).send('Refresh token expired or not found for this user');
}
}
} catch (err) {
logger.error(`[refreshController] Refresh token: ${refreshToken}`, err);
Expand Down
20 changes: 20 additions & 0 deletions api/server/controllers/auth/LogoutController.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
const axios = require('axios');
const cookies = require('cookie');
const { logoutUser } = require('~/server/services/AuthService');
const { logger } = require('~/config');
const { isOpenIDConfigured } = require('~/strategies/validators');

const logoutController = async (req, res) => {
const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null;
try {
if (isOpenIDConfigured()) {
const params = new URLSearchParams({
token: refreshToken,
token_type_hint: 'refresh_token',
client_id: process.env.OPENID_CLIENT_ID || '',
client_secret: process.env.OPENID_CLIENT_SECRET || '',
});

await axios.post(
process.env.OPENID_REVOKATION_ENDPOINT_URI,
params.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
}
const logout = await logoutUser(req.user._id, refreshToken);
const { status, message } = logout;
res.clearCookie('refreshToken');
Expand Down
69 changes: 68 additions & 1 deletion api/server/middleware/requireJwtAuth.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,72 @@
const passport = require('passport');
const jwksRsa = require('jwks-rsa');
const { expressjwt: jwt } = require('express-jwt');
const { logger } = require('~/config');
const cookie = require('cookie');
const { findUser } = require('~/models/userMethods');
const { isOpenIDConfigured } = require('~/strategies/validators');

const requireJwtAuth = passport.authenticate('jwt', { session: false });
const setUser = async function (req, openidId = null, email = null) {
if (!openidId && !email) {
logger.error('[setUser] Either openidId or email should be supplied!');
return;
}

const query = openidId ? { openidId } : { email };
req.user = await findUser(query);

if (req.user) {
req.user.id ??= req.user._id.toString();
logger.info(
`[setUser] user found with ${openidId ? 'openidId' : 'email'}: ${openidId || email}`,
);
} else {
logger.info(
`[setUser] user not found with ${openidId ? 'openidId' : 'email'}: ${openidId || email}`,
);
}
};

const requireJwtAuth = isOpenIDConfigured() ? [
jwt({
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: process.env.OPENID_JWKS_URI,
}),
algorithms: ['RS256'],
issuer: process.env.OPENID_ISSUER,
audience: [process.env.OPENID_CLIENT_ID, 'account'],
getToken: (req) => {
if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
return req.headers.authorization.split(' ')[1];
}
if (req.headers.cookie) {
const cookies = cookie.parse(req.headers.cookie);
return cookies.accessToken;
}
return null;
},
}),

async (req, res, next) => {
try {
if (!req.auth) {
logger.warn('[requireJwtAuth] No decoded JWT found in request');
return res.status(401).send('Unauthorized');
}

const { sub, email } = req.auth;

await setUser(req, sub, email);

next();
} catch (error) {
logger.error('[requireJwtAuth] Error setting user:', error);
return res.status(500).send('Internal Server Error');
}
},
] : passport.authenticate('jwt', { session: false });

module.exports = requireJwtAuth;
Loading
Loading