diff --git a/api/models/Session.js b/api/models/Session.js index 697fa6634..d93ac526c 100644 --- a/api/models/Session.js +++ b/api/models/Session.js @@ -1,6 +1,6 @@ const mongoose = require('mongoose'); const crypto = require('crypto'); -const jwt = require('jsonwebtoken'); +const signPayload = require('../server/services/signPayload'); const { REFRESH_TOKEN_EXPIRY } = process.env ?? {}; const expires = eval(REFRESH_TOKEN_EXPIRY) ?? 1000 * 60 * 60 * 24 * 7; @@ -31,13 +31,11 @@ sessionSchema.methods.generateRefreshToken = async function () { this.expiration = new Date(expiresIn); } - const refreshToken = jwt.sign( - { - id: this.user, - }, - process.env.JWT_REFRESH_SECRET, - { expiresIn: Math.floor((expiresIn - Date.now()) / 1000) }, - ); + const refreshToken = await signPayload({ + payload: { id: this.user }, + secret: process.env.JWT_REFRESH_SECRET, + expirationTime: Math.floor((expiresIn - Date.now()) / 1000), + }); const hash = crypto.createHash('sha256'); this.refreshTokenHash = hash.update(refreshToken).digest('hex'); diff --git a/api/models/User.js b/api/models/User.js index b3d2fecde..5e18fbae0 100644 --- a/api/models/User.js +++ b/api/models/User.js @@ -1,6 +1,6 @@ const mongoose = require('mongoose'); const bcrypt = require('bcryptjs'); -const jwt = require('jsonwebtoken'); +const signPayload = require('../server/services/signPayload'); const userSchema = require('./schema/userSchema.js'); const { SESSION_EXPIRY } = process.env ?? {}; const expires = eval(SESSION_EXPIRY) ?? 1000 * 60 * 15; @@ -21,18 +21,17 @@ userSchema.methods.toJSON = function () { }; }; -userSchema.methods.generateToken = function () { - const token = jwt.sign( - { +userSchema.methods.generateToken = async function () { + return await signPayload({ + payload: { id: this._id, username: this.username, provider: this.provider, email: this.email, }, - process.env.JWT_SECRET, - { expiresIn: expires / 1000 }, - ); - return token; + secret: process.env.JWT_SECRET, + expirationTime: expires / 1000, + }); }; userSchema.methods.comparePassword = function (candidatePassword, callback) { diff --git a/api/package.json b/api/package.json index 99793e13c..f01b8c55c 100644 --- a/api/package.json +++ b/api/package.json @@ -39,6 +39,7 @@ "googleapis": "^118.0.0", "handlebars": "^4.7.7", "html": "^1.0.0", + "jose": "^4.15.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "keyv": "^4.5.3", @@ -52,6 +53,7 @@ "openai-chat-tokens": "^0.2.8", "openid-client": "^5.4.2", "passport": "^0.6.0", + "passport-custom": "^1.1.1", "passport-discord": "^0.1.4", "passport-facebook": "^3.0.0", "passport-github2": "^0.1.12", @@ -59,7 +61,7 @@ "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pino": "^8.12.1", - "sharp": "^0.32.5", + "sharp": "^0.32.6", "tiktoken": "^1.0.10", "ua-parser-js": "^1.0.36", "winston": "^3.10.0", diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 361c3464b..240fff465 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -4,6 +4,7 @@ const { resetPassword, setAuthTokens, } = require('../services/AuthService'); +const jose = require('jose'); const jwt = require('jsonwebtoken'); const Session = require('../../models/Session'); const User = require('../../models/User'); @@ -76,7 +77,13 @@ const refreshController = async (req, res) => { } try { - const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); + let payload; + if (typeof Bun !== 'undefined') { + const secret = new TextEncoder().encode(process.env.JWT_REFRESH_SECRET); + ({ payload } = await jose.jwtVerify(refreshToken, secret)); + } else { + payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET); + } const userId = payload.id; const user = await User.findOne({ _id: userId }); if (!user) { @@ -99,7 +106,7 @@ const refreshController = async (req, res) => { const token = await setAuthTokens(userId, res, session._id); const userObj = user.toJSON(); res.status(200).send({ token, user: userObj }); - } else if (payload.exp > Date.now() / 1000) { + } 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'); diff --git a/api/server/index.js b/api/server/index.js index 7975f406b..4a0ed9f6e 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -12,7 +12,7 @@ const { PORT, HOST, ALLOW_SOCIAL_LOGIN } = process.env ?? {}; const port = Number(PORT) || 3080; const host = HOST || 'localhost'; const projectPath = path.join(__dirname, '..', '..', 'client'); -const { jwtLogin, passportLogin } = require('../strategies'); +const { jwtLogin, joseLogin, passportLogin } = require('../strategies'); const startServer = async () => { await connectDb(); @@ -39,7 +39,11 @@ const startServer = async () => { // OAUTH app.use(passport.initialize()); - passport.use(await jwtLogin()); + if (typeof Bun !== 'undefined') { + passport.use('jwt', await joseLogin()); + } else { + passport.use(await jwtLogin()); + } passport.use(passportLogin()); if (ALLOW_SOCIAL_LOGIN?.toLowerCase() === 'true') { diff --git a/api/server/services/signPayload.js b/api/server/services/signPayload.js new file mode 100644 index 000000000..4bd680a53 --- /dev/null +++ b/api/server/services/signPayload.js @@ -0,0 +1,36 @@ +const jose = require('jose'); +const jwt = require('jsonwebtoken'); + +/** + * Signs a given payload using either the `jose` library (for Bun runtime) or `jsonwebtoken`. + * + * @async + * @function + * @param {Object} options - The options for signing the payload. + * @param {Object} options.payload - The payload to be signed. + * @param {string} options.secret - The secret key used for signing. + * @param {number} options.expirationTime - The expiration time in seconds. + * @returns {Promise} Returns a promise that resolves to the signed JWT. + * @throws {Error} Throws an error if there's an issue during signing. + * + * @example + * const signedPayload = await signPayload({ + * payload: { userId: 123 }, + * secret: 'my-secret-key', + * expirationTime: 3600 + * }); + */ +async function signPayload({ payload, secret, expirationTime }) { + if (typeof Bun !== 'undefined') { + // this code will only run when the file is run with Bun + const encodedSecret = new TextEncoder().encode(secret); + return await new jose.SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime(expirationTime + 's') + .sign(encodedSecret); + } + + return jwt.sign(payload, secret, { expiresIn: expirationTime }); +} + +module.exports = signPayload; diff --git a/api/server/utils/removePorts.js b/api/server/utils/removePorts.js index db3e5e1db..375ff1cc7 100644 --- a/api/server/utils/removePorts.js +++ b/api/server/utils/removePorts.js @@ -1 +1 @@ -module.exports = (req) => req.ip.replace(/:\d+[^:]*$/, ''); +module.exports = (req) => req?.ip?.replace(/:\d+[^:]*$/, ''); diff --git a/api/strategies/index.js b/api/strategies/index.js index 1c49c2b1c..1b1f8192a 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -2,6 +2,7 @@ const passportLogin = require('./localStrategy'); const googleLogin = require('./googleStrategy'); const githubLogin = require('./githubStrategy'); const discordLogin = require('./discordStrategy'); +const joseLogin = require('./joseStrategy'); const jwtLogin = require('./jwtStrategy'); const facebookLogin = require('./facebookStrategy'); const setupOpenId = require('./openidStrategy'); @@ -11,6 +12,7 @@ module.exports = { googleLogin, githubLogin, discordLogin, + joseLogin, jwtLogin, facebookLogin, setupOpenId, diff --git a/api/strategies/joseStrategy.js b/api/strategies/joseStrategy.js new file mode 100644 index 000000000..6e1f79796 --- /dev/null +++ b/api/strategies/joseStrategy.js @@ -0,0 +1,38 @@ +const jose = require('jose'); +const passportCustom = require('passport-custom'); +const CustomStrategy = passportCustom.Strategy; +const User = require('../models/User'); + +const joseLogin = async () => + new CustomStrategy(async (req, done) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return done(null, false, { message: 'No auth token' }); + } + + const token = authHeader.split(' ')[1]; + + try { + const secret = new TextEncoder().encode(process.env.JWT_SECRET); + const { payload } = await jose.jwtVerify(token, secret); + + const user = await User.findById(payload.id); + if (user) { + done(null, user); + } else { + console.log('JoseJwtStrategy => no user found'); + done(null, false, { message: 'No user found' }); + } + } catch (err) { + if (err?.code === 'ERR_JWT_EXPIRED') { + console.error('JoseJwtStrategy => token expired'); + } else { + console.error('JoseJwtStrategy => error'); + console.error(err); + } + done(null, false, { message: 'Invalid token' }); + } + }); + +module.exports = joseLogin; diff --git a/bun.lockb b/bun.lockb index 66e3a2606..25e95fde8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package-lock.json b/package-lock.json index c5e3abfa4..c4ca442c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "googleapis": "^118.0.0", "handlebars": "^4.7.7", "html": "^1.0.0", + "jose": "^4.15.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "keyv": "^4.5.3", @@ -73,6 +74,7 @@ "openai-chat-tokens": "^0.2.8", "openid-client": "^5.4.2", "passport": "^0.6.0", + "passport-custom": "^1.1.1", "passport-discord": "^0.1.4", "passport-facebook": "^3.0.0", "passport-github2": "^0.1.12", @@ -80,7 +82,7 @@ "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", "pino": "^8.12.1", - "sharp": "^0.32.5", + "sharp": "^0.32.6", "tiktoken": "^1.0.10", "ua-parser-js": "^1.0.36", "winston": "^3.10.0", @@ -14963,9 +14965,9 @@ } }, "node_modules/jose": { - "version": "4.14.6", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.6.tgz", - "integrity": "sha512-EqJPEUlZD0/CSUMubKtMaYUOtWe91tZXTWMJZoKSbLk+KtdhNdcvppH8lA9XwVu2V4Ailvsj0GBZJ2ZwDjfesQ==", + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.2.tgz", + "integrity": "sha512-IY73F228OXRl9ar3jJagh7Vnuhj/GzBunPiZP13K0lOl7Am9SoWW3kEzq3MCllJMTtZqHTiDXQvoRd4U95aU6A==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -18071,6 +18073,17 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-custom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", + "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/passport-discord": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/passport-discord/-/passport-discord-0.1.4.tgz", @@ -21093,9 +21106,9 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/sharp": { - "version": "0.32.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.5.tgz", - "integrity": "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ==", + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", "hasInstallScript": true, "dependencies": { "color": "^4.2.3",