From 1678553cbf9ba9543cb217c3c7f92b9645a49841 Mon Sep 17 00:00:00 2001 From: Ayush Date: Tue, 23 Jun 2026 01:08:04 +0530 Subject: [PATCH 1/8] Modified Developer Model, Enabling PAT schema --- packages/common/src/index.js | 4 ++ packages/common/src/models/Developer.js | 16 ++++++ packages/common/src/utils/token.utils.js | 71 ++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 packages/common/src/utils/token.utils.js diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 0292773e6..136de5d42 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -127,6 +127,7 @@ const { updateDeveloperOnboarding, } = require("./utils/onboarding"); const { getProjectAccessQuery, getProjectRole } = require("./utils/projectAccess"); +const { generatePAT, hashToken, encodeBase62 } = require("./utils/token.utils"); module.exports = { connectDB, @@ -244,4 +245,7 @@ module.exports = { updateDeveloperOnboarding, getProjectAccessQuery, getProjectRole, + generatePAT, + hashToken, + encodeBase62, }; diff --git a/packages/common/src/models/Developer.js b/packages/common/src/models/Developer.js index 3578e7f45..1c98d6bdb 100644 --- a/packages/common/src/models/Developer.js +++ b/packages/common/src/models/Developer.js @@ -44,6 +44,18 @@ const onboardingSchema = new mongoose.Schema({ } }, { _id: false }); +const patSchema = new mongoose.Schema({ + tokenHash: { type: String, required: true }, + suffix: { type: String, required: true }, + label: { type: String, required: true }, + type: { type: String, enum: ['human', 'agent'], required: true }, + scopes: { type: [String], required: true }, + lastUsedAt: { type: Date, default: null }, + lastUsedIp: { type: String, default: null }, + expiresAt: { type: Date, required: true }, + createdAt: { type: Date, default: Date.now } +}); + const developerSchema = new mongoose.Schema({ email: { type: String, @@ -105,6 +117,10 @@ const developerSchema = new mongoose.Schema({ onboarding: { type: onboardingSchema, default: () => ({}) + }, + pats: { + type: [patSchema], + default: [] } }, { timestamps: true }); diff --git a/packages/common/src/utils/token.utils.js b/packages/common/src/utils/token.utils.js new file mode 100644 index 000000000..cbb644124 --- /dev/null +++ b/packages/common/src/utils/token.utils.js @@ -0,0 +1,71 @@ +const crypto = require('crypto'); + +// Standard Base62 character set +const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +/** + * For future Developer Reference + * Encodes a Buffer to a Base62 string + * @param {Buffer} buffer + * @returns {string} Base62 string + */ + +function encodeBase62(buffer) { + let value = BigInt('0x' + buffer.toString('hex')); + let result = ''; + const base = BigInt(62); + + while (value > 0n) { + result = BASE62_CHARS[Number(value % base)] + result; + value = value / base; + } + + // Handle leading zeros + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] !== 0) break; + result = BASE62_CHARS[0] + result; + } + + return result || BASE62_CHARS[0]; +} + +/** + * Generates a Personal Access Token + * @param {string} environment - 'live' or 'test' + * @returns {Object} { rawToken, tokenHash, suffix } + */ + +function generatePAT(environment = 'live') { + // Generate 32 bytes of CSPRNG entropy (256 bits) + const rawBytes = crypto.randomBytes(32); + + // Encode to base62 to avoid URL/shell character issues + const tokenPart = encodeBase62(rawBytes); + + // Prefix the token for easy environment identification and secret scanning + const rawToken = `ubpat_${environment}_${tokenPart}`; + + // SHA-256 hash for secure server-side storage + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); + + // Extract the last 4 characters for UI masking + const suffix = tokenPart.slice(-4); + + return { rawToken, tokenHash, suffix }; +} + +/** + * Hashes an existing token for verification + * @param {string} rawToken + * @returns {string} SHA-256 hash + */ + +function hashToken(rawToken) { + return crypto.createHash('sha256').update(rawToken).digest('hex'); +} + +module.exports = { + generatePAT, + hashToken, + encodeBase62 +}; From 084e3ae8dd2e32ba7d47136a274e820cbb42d071 Mon Sep 17 00:00:00 2001 From: Ayush Date: Tue, 23 Jun 2026 01:08:29 +0530 Subject: [PATCH 2/8] Added controller, middleware, routes for PAT in dashboard-api --- .../src/controllers/pat.controller.js | 104 ++++++++++++++++++ .../src/middlewares/authenticateCLI.js | 86 +++++++++++++++ apps/dashboard-api/src/routes/user.js | 5 + 3 files changed, 195 insertions(+) create mode 100644 apps/dashboard-api/src/controllers/pat.controller.js create mode 100644 apps/dashboard-api/src/middlewares/authenticateCLI.js diff --git a/apps/dashboard-api/src/controllers/pat.controller.js b/apps/dashboard-api/src/controllers/pat.controller.js new file mode 100644 index 000000000..dbc6b3fc8 --- /dev/null +++ b/apps/dashboard-api/src/controllers/pat.controller.js @@ -0,0 +1,104 @@ +const { Developer, generatePAT, redis, AppError, ApiResponse } = require('@urbackend/common'); + +exports.createPAT = async (req, res, next) => { + try { + const { label, type = 'human', scopes, ttlDays } = req.body; + + if (!label) return next(new AppError(400, "Token label is required.")); + + // Force explicit selection, no automatic global access + if (!scopes || !Array.isArray(scopes) || scopes.length === 0) { + return next(new AppError(400, "At least one scope must be explicitly selected.")); + } + + // Lifetime & rotation - Default 30 days, force bounds to prevent permanent keys + const days = Number(ttlDays) || 30; + if (days <= 0 || days > 365) return next(new AppError(400, "Token TTL must be between 1 and 365 days.")); + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + days); + + // Generate PAT with base62 encoding + const environment = process.env.NODE_ENV === 'production' ? 'live' : 'test'; + const { rawToken, tokenHash, suffix } = generatePAT(environment); + + const newPat = { + tokenHash, + suffix, + label, + type, + scopes, + expiresAt, + lastUsedAt: null, + lastUsedIp: null + }; + + const developer = await Developer.findByIdAndUpdate( + req.user.id, + { $push: { pats: newPat } }, + { new: true, runValidators: true } + ); + + if (!developer) return next(new AppError(404, "Developer not found")); + + // Return raw token exactly once + return new ApiResponse( + { rawToken, pat: { suffix, label, type, scopes, expiresAt } }, + "Token created successfully. Store this token now. You will not be able to see it again." + ).send(res, 201); + } catch (err) { + console.error(err); + return next(new AppError(500, "An error occurred while creating the token")); + } +}; + +exports.listPATs = async (req, res, next) => { + try { + const developer = await Developer.findById(req.user.id).select('pats'); + if (!developer) return next(new AppError(404, "Developer not found")); + + // only show masked suffix and metadata + const safePats = developer.pats.map(pat => ({ + id: pat._id, + suffix: pat.suffix, + label: pat.label, + type: pat.type, + scopes: pat.scopes, + createdAt: pat.createdAt, + expiresAt: pat.expiresAt, + lastUsedAt: pat.lastUsedAt, + lastUsedIp: pat.lastUsedIp + })); + + return new ApiResponse({ pats: safePats }).send(res, 200); + } catch (err) { + console.error(err); + return next(new AppError(500, "An error occurred while fetching tokens")); + } +}; + +exports.revokePAT = async (req, res, next) => { + try { + const { id } = req.params; + + const developer = await Developer.findById(req.user.id).select('pats'); + if (!developer) return next(new AppError(404, "Developer not found")); + + const patToRevoke = developer.pats.find(p => p._id.toString() === id); + if (!patToRevoke) { + return next(new AppError(404, "Token not found")); + } + + // Remove from DB + developer.pats = developer.pats.filter(p => p._id.toString() !== id); + await developer.save(); + + // forcefully clear the Redis cache so ongoing sessions are immediately killed + await redis.del(`cli:pat:cache:${patToRevoke.tokenHash}`); + + return new ApiResponse({}, "Token revoked successfully").send(res, 200); + } catch (err) { + console.error(err); + return next(new AppError(500, "An error occurred while revoking the token")); + } +}; diff --git a/apps/dashboard-api/src/middlewares/authenticateCLI.js b/apps/dashboard-api/src/middlewares/authenticateCLI.js new file mode 100644 index 000000000..c3536a62c --- /dev/null +++ b/apps/dashboard-api/src/middlewares/authenticateCLI.js @@ -0,0 +1,86 @@ +const { Developer, hashToken, redis, AppError } = require('@urbackend/common'); +const CACHE_TTL = process.env.CLI_PAT_CACHE_TTL || 300; // 5 minutes + +const authenticateCLI = async (req, res, next) => { + try { + // Accept only via Authorization Header + const authHeader = req.headers.authorization; + + if (req.query.token) { + // Aggressive rejection of query tokens to prevent accidental leak in server logs + return next(new AppError(400, 'Bad Request: Providing tokens in query parameters is strictly prohibited.')); + } + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return next(new AppError(401, 'Unauthorized: Missing or invalid Bearer token.')); + } + + const rawToken = authHeader.split(' ')[1]; + if (!rawToken || !rawToken.startsWith('ubpat_')) { + return next(new AppError(401, 'Unauthorized: Invalid token format.')); + } + + // Only checks the SHA-256 hash, raw token is never passed to DB + const tokenHash = hashToken(rawToken); + const cacheKey = `cli:pat:cache:${tokenHash}`; + + // Distributed Caching + let developerId = await redis.get(cacheKey); + let developer; + let matchedPat; + + if (developerId) { + developer = await Developer.findById(developerId); + if (!developer) { + await redis.del(cacheKey); + return next(new AppError(401, 'Unauthorized: Developer not found.')); + } + matchedPat = developer.pats.find(p => p.tokenHash === tokenHash); + } else { + developer = await Developer.findOne({ 'pats.tokenHash': tokenHash }); + if (!developer) { + return next(new AppError(401, 'Unauthorized: Invalid or revoked token.')); + } + matchedPat = developer.pats.find(p => p.tokenHash === tokenHash); + + // Cache valid token to prevent DB hammering + await redis.setex(cacheKey, CACHE_TTL, developer._id.toString()); + } + + if (!matchedPat) { + return next(new AppError(401, 'Unauthorized: Invalid token state.')); + } + + // Check expiry + if (matchedPat.expiresAt && new Date() > new Date(matchedPat.expiresAt)) { + await redis.del(cacheKey); + return next(new AppError(401, 'Unauthorized: Token has expired.')); + } + + // Fire async update (non-blocking) to log IP and Last Used time + const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress; + + Developer.updateOne( + { _id: developer._id, 'pats._id': matchedPat._id }, + { + $set: { + 'pats.$.lastUsedAt': new Date(), + 'pats.$.lastUsedIp': ip + } + } + ).catch(err => console.error("Failed to update PAT metadata:", err)); + + // Attach developer and PAT scopes for downstream controllers + req.user = { id: developer._id.toString() }; // Maintain compatibility with dashboard controllers + req.developer = developer; + req.cliScopes = matchedPat.scopes; + req.cliTokenType = matchedPat.type; + + next(); + } catch (error) { + console.error("authenticateCLI error:", error); + return next(new AppError(500, 'Internal Server Error during CLI authentication.')); + } +}; + +module.exports = authenticateCLI; diff --git a/apps/dashboard-api/src/routes/user.js b/apps/dashboard-api/src/routes/user.js index b64b697fe..640e9338f 100644 --- a/apps/dashboard-api/src/routes/user.js +++ b/apps/dashboard-api/src/routes/user.js @@ -2,8 +2,13 @@ const express = require('express'); const router = express.Router(); const authorization = require('../middlewares/authMiddleware'); const { getMe, updateOnboarding } = require('../controllers/auth.controller'); +const { createPAT, listPATs, revokePAT } = require('../controllers/pat.controller'); router.get('/me', authorization, getMe); router.patch('/onboarding', authorization, updateOnboarding); +router.post('/pats', authorization, createPAT); +router.get('/pats', authorization, listPATs); +router.delete('/pats/:id', authorization, revokePAT); + module.exports = router; From 44dfd2133b32b60e8221407532ea3134173b77d6 Mon Sep 17 00:00:00 2001 From: Ayush Date: Tue, 23 Jun 2026 01:08:47 +0530 Subject: [PATCH 3/8] Adds Test case --- apps/dashboard-api/src/__tests__/routes.user.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/dashboard-api/src/__tests__/routes.user.test.js b/apps/dashboard-api/src/__tests__/routes.user.test.js index 75f7235a5..c8ccd5d85 100644 --- a/apps/dashboard-api/src/__tests__/routes.user.test.js +++ b/apps/dashboard-api/src/__tests__/routes.user.test.js @@ -42,6 +42,12 @@ jest.mock('../controllers/auth.controller', () => ({ })), })); +jest.mock('../controllers/pat.controller', () => ({ + createPAT: jest.fn((_req, res) => res.json({ success: true })), + listPATs: jest.fn((_req, res) => res.json({ success: true })), + revokePAT: jest.fn((_req, res) => res.json({ success: true })) +})); + const express = require('express'); const request = require('supertest'); const userRouter = require('../routes/user'); From 6483ab83ab9af638809e81035ab087eeb9c12f46 Mon Sep 17 00:00:00 2001 From: Ayush Date: Tue, 23 Jun 2026 02:35:59 +0530 Subject: [PATCH 4/8] Changes accord to the CodeRabbit --- .../src/controllers/pat.controller.js | 19 ++++++---- .../src/middlewares/authenticateCLI.js | 36 +++++++++++++++---- packages/common/src/models/Developer.js | 2 ++ 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/apps/dashboard-api/src/controllers/pat.controller.js b/apps/dashboard-api/src/controllers/pat.controller.js index dbc6b3fc8..9cdb3565d 100644 --- a/apps/dashboard-api/src/controllers/pat.controller.js +++ b/apps/dashboard-api/src/controllers/pat.controller.js @@ -12,8 +12,11 @@ exports.createPAT = async (req, res, next) => { } // Lifetime & rotation - Default 30 days, force bounds to prevent permanent keys - const days = Number(ttlDays) || 30; - if (days <= 0 || days > 365) return next(new AppError(400, "Token TTL must be between 1 and 365 days.")); + let days = 30; + if (ttlDays !== undefined && ttlDays !== null && ttlDays !== '') { + days = Number(ttlDays); + } + if (isNaN(days) || days <= 0 || days > 365) return next(new AppError(400, "Token TTL must be between 1 and 365 days.")); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + days); @@ -34,7 +37,7 @@ exports.createPAT = async (req, res, next) => { }; const developer = await Developer.findByIdAndUpdate( - req.user.id, + req.user._id, { $push: { pats: newPat } }, { new: true, runValidators: true } ); @@ -54,7 +57,7 @@ exports.createPAT = async (req, res, next) => { exports.listPATs = async (req, res, next) => { try { - const developer = await Developer.findById(req.user.id).select('pats'); + const developer = await Developer.findById(req.user._id).select('pats'); if (!developer) return next(new AppError(404, "Developer not found")); // only show masked suffix and metadata @@ -81,7 +84,7 @@ exports.revokePAT = async (req, res, next) => { try { const { id } = req.params; - const developer = await Developer.findById(req.user.id).select('pats'); + const developer = await Developer.findById(req.user._id).select('pats'); if (!developer) return next(new AppError(404, "Developer not found")); const patToRevoke = developer.pats.find(p => p._id.toString() === id); @@ -94,7 +97,11 @@ exports.revokePAT = async (req, res, next) => { await developer.save(); // forcefully clear the Redis cache so ongoing sessions are immediately killed - await redis.del(`cli:pat:cache:${patToRevoke.tokenHash}`); + try { + await redis.del(`cli:pat:cache:${patToRevoke.tokenHash}`); + } catch (redisErr) { + console.error("Failed to clear PAT from Redis cache:", redisErr); + } return new ApiResponse({}, "Token revoked successfully").send(res, 200); } catch (err) { diff --git a/apps/dashboard-api/src/middlewares/authenticateCLI.js b/apps/dashboard-api/src/middlewares/authenticateCLI.js index c3536a62c..0300c8497 100644 --- a/apps/dashboard-api/src/middlewares/authenticateCLI.js +++ b/apps/dashboard-api/src/middlewares/authenticateCLI.js @@ -1,5 +1,6 @@ const { Developer, hashToken, redis, AppError } = require('@urbackend/common'); -const CACHE_TTL = process.env.CLI_PAT_CACHE_TTL || 300; // 5 minutes +const CACHE_TTL_ENV = parseInt(process.env.CLI_PAT_CACHE_TTL, 10); +const CACHE_TTL = !isNaN(CACHE_TTL_ENV) ? CACHE_TTL_ENV : 300; // 5 minutes const authenticateCLI = async (req, res, next) => { try { @@ -25,14 +26,24 @@ const authenticateCLI = async (req, res, next) => { const cacheKey = `cli:pat:cache:${tokenHash}`; // Distributed Caching - let developerId = await redis.get(cacheKey); + let developerId = null; + try { + developerId = await redis.get(cacheKey); + } catch (redisErr) { + console.warn("Redis GET failed, falling back to DB:", redisErr); + } + let developer; let matchedPat; if (developerId) { developer = await Developer.findById(developerId); if (!developer) { - await redis.del(cacheKey); + try { + await redis.del(cacheKey); + } catch (redisErr) { + console.warn("Redis DEL failed:", redisErr); + } return next(new AppError(401, 'Unauthorized: Developer not found.')); } matchedPat = developer.pats.find(p => p.tokenHash === tokenHash); @@ -44,16 +55,29 @@ const authenticateCLI = async (req, res, next) => { matchedPat = developer.pats.find(p => p.tokenHash === tokenHash); // Cache valid token to prevent DB hammering - await redis.setex(cacheKey, CACHE_TTL, developer._id.toString()); + try { + await redis.setex(cacheKey, CACHE_TTL, developer._id.toString()); + } catch (redisErr) { + console.warn("Redis SETEX failed:", redisErr); + } } if (!matchedPat) { - return next(new AppError(401, 'Unauthorized: Invalid token state.')); + try { + await redis.del(cacheKey); + } catch (redisErr) { + console.warn("Redis DEL failed:", redisErr); + } + return next(new AppError(401, 'Unauthorized: Invalid token state.')); } // Check expiry if (matchedPat.expiresAt && new Date() > new Date(matchedPat.expiresAt)) { - await redis.del(cacheKey); + try { + await redis.del(cacheKey); + } catch (redisErr) { + console.warn("Redis DEL failed:", redisErr); + } return next(new AppError(401, 'Unauthorized: Token has expired.')); } diff --git a/packages/common/src/models/Developer.js b/packages/common/src/models/Developer.js index 1c98d6bdb..0826ee8aa 100644 --- a/packages/common/src/models/Developer.js +++ b/packages/common/src/models/Developer.js @@ -124,4 +124,6 @@ const developerSchema = new mongoose.Schema({ } }, { timestamps: true }); +developerSchema.index({ 'pats.tokenHash': 1 }); + module.exports = mongoose.model('Developer', developerSchema); From 0e5ed6978f635f56fe6cb6f89eb18a736f609fd9 Mon Sep 17 00:00:00 2001 From: Ayush Date: Thu, 25 Jun 2026 01:28:21 +0530 Subject: [PATCH 5/8] refactor(auth): isolate PATs to independent collection for native TTL and performance --- .../src/controllers/pat.controller.js | 32 +++------- .../src/middlewares/authenticateCLI.js | 61 ++++++++++++------- packages/common/src/index.js | 1 + packages/common/src/models/Developer.js | 17 ------ packages/common/src/models/PAT.js | 24 ++++++++ 5 files changed, 71 insertions(+), 64 deletions(-) create mode 100644 packages/common/src/models/PAT.js diff --git a/apps/dashboard-api/src/controllers/pat.controller.js b/apps/dashboard-api/src/controllers/pat.controller.js index 9cdb3565d..60aebf8b1 100644 --- a/apps/dashboard-api/src/controllers/pat.controller.js +++ b/apps/dashboard-api/src/controllers/pat.controller.js @@ -1,4 +1,4 @@ -const { Developer, generatePAT, redis, AppError, ApiResponse } = require('@urbackend/common'); +const { Developer, PAT, generatePAT, redis, AppError, ApiResponse } = require('@urbackend/common'); exports.createPAT = async (req, res, next) => { try { @@ -25,24 +25,15 @@ exports.createPAT = async (req, res, next) => { const environment = process.env.NODE_ENV === 'production' ? 'live' : 'test'; const { rawToken, tokenHash, suffix } = generatePAT(environment); - const newPat = { + const newPat = await PAT.create({ + developer: req.user._id, tokenHash, suffix, label, type, scopes, - expiresAt, - lastUsedAt: null, - lastUsedIp: null - }; - - const developer = await Developer.findByIdAndUpdate( - req.user._id, - { $push: { pats: newPat } }, - { new: true, runValidators: true } - ); - - if (!developer) return next(new AppError(404, "Developer not found")); + expiresAt + }); // Return raw token exactly once return new ApiResponse( @@ -57,11 +48,10 @@ exports.createPAT = async (req, res, next) => { exports.listPATs = async (req, res, next) => { try { - const developer = await Developer.findById(req.user._id).select('pats'); - if (!developer) return next(new AppError(404, "Developer not found")); + const pats = await PAT.find({ developer: req.user._id }).sort({ createdAt: -1 }); // only show masked suffix and metadata - const safePats = developer.pats.map(pat => ({ + const safePats = pats.map(pat => ({ id: pat._id, suffix: pat.suffix, label: pat.label, @@ -84,18 +74,12 @@ exports.revokePAT = async (req, res, next) => { try { const { id } = req.params; - const developer = await Developer.findById(req.user._id).select('pats'); - if (!developer) return next(new AppError(404, "Developer not found")); + const patToRevoke = await PAT.findOneAndDelete({ _id: id, developer: req.user._id }); - const patToRevoke = developer.pats.find(p => p._id.toString() === id); if (!patToRevoke) { return next(new AppError(404, "Token not found")); } - // Remove from DB - developer.pats = developer.pats.filter(p => p._id.toString() !== id); - await developer.save(); - // forcefully clear the Redis cache so ongoing sessions are immediately killed try { await redis.del(`cli:pat:cache:${patToRevoke.tokenHash}`); diff --git a/apps/dashboard-api/src/middlewares/authenticateCLI.js b/apps/dashboard-api/src/middlewares/authenticateCLI.js index 0300c8497..4b7a06778 100644 --- a/apps/dashboard-api/src/middlewares/authenticateCLI.js +++ b/apps/dashboard-api/src/middlewares/authenticateCLI.js @@ -1,4 +1,4 @@ -const { Developer, hashToken, redis, AppError } = require('@urbackend/common'); +const { Developer, PAT, hashToken, redis, AppError } = require('@urbackend/common'); const CACHE_TTL_ENV = parseInt(process.env.CLI_PAT_CACHE_TTL, 10); const CACHE_TTL = !isNaN(CACHE_TTL_ENV) ? CACHE_TTL_ENV : 300; // 5 minutes @@ -26,9 +26,10 @@ const authenticateCLI = async (req, res, next) => { const cacheKey = `cli:pat:cache:${tokenHash}`; // Distributed Caching - let developerId = null; + let cachedContext = null; try { - developerId = await redis.get(cacheKey); + const rawCache = await redis.get(cacheKey); + if (rawCache) cachedContext = JSON.parse(rawCache); } catch (redisErr) { console.warn("Redis GET failed, falling back to DB:", redisErr); } @@ -36,27 +37,41 @@ const authenticateCLI = async (req, res, next) => { let developer; let matchedPat; - if (developerId) { - developer = await Developer.findById(developerId); - if (!developer) { - try { - await redis.del(cacheKey); - } catch (redisErr) { - console.warn("Redis DEL failed:", redisErr); - } - return next(new AppError(401, 'Unauthorized: Developer not found.')); - } - matchedPat = developer.pats.find(p => p.tokenHash === tokenHash); + if (cachedContext) { + // Fix 1: Cache hit still constructs the context (and expiresAt will be checked below) + developer = { _id: cachedContext.developerId }; + matchedPat = { + _id: cachedContext.patId, + scopes: cachedContext.scopes, + type: cachedContext.type, + expiresAt: cachedContext.expiresAt, + tokenHash: tokenHash + }; } else { - developer = await Developer.findOne({ 'pats.tokenHash': tokenHash }); - if (!developer) { + // Cache Miss: Query the new PAT collection + matchedPat = await PAT.findOne({ tokenHash }); + if (!matchedPat) { return next(new AppError(401, 'Unauthorized: Invalid or revoked token.')); } - matchedPat = developer.pats.find(p => p.tokenHash === tokenHash); + developer = { _id: matchedPat.developer.toString() }; - // Cache valid token to prevent DB hammering + // Cache full context to prevent DB hammering + const contextToCache = { + developerId: developer._id, + patId: matchedPat._id.toString(), + scopes: matchedPat.scopes, + type: matchedPat.type, + expiresAt: matchedPat.expiresAt + }; try { - await redis.setex(cacheKey, CACHE_TTL, developer._id.toString()); + // Fix 2: Set Redis TTL = min(CACHE_TTL, remainingPATlifetime) + const remainingMs = new Date(matchedPat.expiresAt) - Date.now(); + const remainingSec = Math.floor(remainingMs / 1000); + const redisTTL = Math.max(0, Math.min(CACHE_TTL, remainingSec)); + + if (redisTTL > 0) { + await redis.setex(cacheKey, redisTTL, JSON.stringify(contextToCache)); + } } catch (redisErr) { console.warn("Redis SETEX failed:", redisErr); } @@ -84,12 +99,12 @@ const authenticateCLI = async (req, res, next) => { // Fire async update (non-blocking) to log IP and Last Used time const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress; - Developer.updateOne( - { _id: developer._id, 'pats._id': matchedPat._id }, + PAT.updateOne( + { _id: matchedPat._id }, { $set: { - 'pats.$.lastUsedAt': new Date(), - 'pats.$.lastUsedIp': ip + lastUsedAt: new Date(), + lastUsedIp: ip } } ).catch(err => console.error("Failed to update PAT metadata:", err)); diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 136de5d42..2866d2876 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -15,6 +15,7 @@ const { // Models const Developer = require("./models/Developer"); +const PAT = require("./models/PAT"); const Project = require("./models/Project"); const MailTemplate = require("./models/MailTemplate"); const Release = require("./models/Release"); diff --git a/packages/common/src/models/Developer.js b/packages/common/src/models/Developer.js index 0826ee8aa..b77f386cc 100644 --- a/packages/common/src/models/Developer.js +++ b/packages/common/src/models/Developer.js @@ -44,17 +44,6 @@ const onboardingSchema = new mongoose.Schema({ } }, { _id: false }); -const patSchema = new mongoose.Schema({ - tokenHash: { type: String, required: true }, - suffix: { type: String, required: true }, - label: { type: String, required: true }, - type: { type: String, enum: ['human', 'agent'], required: true }, - scopes: { type: [String], required: true }, - lastUsedAt: { type: Date, default: null }, - lastUsedIp: { type: String, default: null }, - expiresAt: { type: Date, required: true }, - createdAt: { type: Date, default: Date.now } -}); const developerSchema = new mongoose.Schema({ email: { @@ -117,13 +106,7 @@ const developerSchema = new mongoose.Schema({ onboarding: { type: onboardingSchema, default: () => ({}) - }, - pats: { - type: [patSchema], - default: [] } }, { timestamps: true }); -developerSchema.index({ 'pats.tokenHash': 1 }); - module.exports = mongoose.model('Developer', developerSchema); diff --git a/packages/common/src/models/PAT.js b/packages/common/src/models/PAT.js new file mode 100644 index 000000000..7d7b2dff3 --- /dev/null +++ b/packages/common/src/models/PAT.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose'); + +const patSchema = new mongoose.Schema({ + developer: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Developer', + required: true, + index: true + }, + tokenHash: { type: String, required: true, unique: true, select: false }, + suffix: { type: String, required: true }, + label: { type: String, required: true }, + type: { type: String, enum: ['human', 'agent'], default: 'human' }, + scopes: [{ type: String }], + expiresAt: { type: Date, required: true, index: true }, // Indexed for TTL and queries + lastUsedAt: { type: Date, default: null }, + lastUsedIp: { type: String, default: null }, + createdAt: { type: Date, default: Date.now } +}); + +// Native MongoDB TTL index - auto-deletes expired PATs at exactly expiresAt time +patSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +module.exports = mongoose.model('PAT', patSchema); From a358f149cc85263efc90c206cae9ae520eb84436 Mon Sep 17 00:00:00 2001 From: Ayush Date: Thu, 25 Jun 2026 01:43:23 +0530 Subject: [PATCH 6/8] Updated the comments --- apps/dashboard-api/src/middlewares/authenticateCLI.js | 4 ++-- packages/common/src/models/PAT.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dashboard-api/src/middlewares/authenticateCLI.js b/apps/dashboard-api/src/middlewares/authenticateCLI.js index 4b7a06778..6b987aa05 100644 --- a/apps/dashboard-api/src/middlewares/authenticateCLI.js +++ b/apps/dashboard-api/src/middlewares/authenticateCLI.js @@ -38,7 +38,7 @@ const authenticateCLI = async (req, res, next) => { let matchedPat; if (cachedContext) { - // Fix 1: Cache hit still constructs the context (and expiresAt will be checked below) + // Cache hit still constructs the context developer = { _id: cachedContext.developerId }; matchedPat = { _id: cachedContext.patId, @@ -48,7 +48,7 @@ const authenticateCLI = async (req, res, next) => { tokenHash: tokenHash }; } else { - // Cache Miss: Query the new PAT collection + // query the new PAT collection matchedPat = await PAT.findOne({ tokenHash }); if (!matchedPat) { return next(new AppError(401, 'Unauthorized: Invalid or revoked token.')); diff --git a/packages/common/src/models/PAT.js b/packages/common/src/models/PAT.js index 7d7b2dff3..14c0dbe12 100644 --- a/packages/common/src/models/PAT.js +++ b/packages/common/src/models/PAT.js @@ -18,7 +18,7 @@ const patSchema = new mongoose.Schema({ createdAt: { type: Date, default: Date.now } }); -// Native MongoDB TTL index - auto-deletes expired PATs at exactly expiresAt time +// auto-deletes expired PATs at exactly expiresAt time patSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); module.exports = mongoose.model('PAT', patSchema); From 93c2916071c0234c1da098a7176b3a140fd1a51e Mon Sep 17 00:00:00 2001 From: Ayush Date: Thu, 25 Jun 2026 01:57:37 +0530 Subject: [PATCH 7/8] fix(auth): remove duplicate index from expiresAt field in PAT schema --- packages/common/src/models/PAT.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/models/PAT.js b/packages/common/src/models/PAT.js index 14c0dbe12..3921cea7e 100644 --- a/packages/common/src/models/PAT.js +++ b/packages/common/src/models/PAT.js @@ -12,7 +12,7 @@ const patSchema = new mongoose.Schema({ label: { type: String, required: true }, type: { type: String, enum: ['human', 'agent'], default: 'human' }, scopes: [{ type: String }], - expiresAt: { type: Date, required: true, index: true }, // Indexed for TTL and queries + expiresAt: { type: Date, required: true }, // TTL index handles indexing lastUsedAt: { type: Date, default: null }, lastUsedIp: { type: String, default: null }, createdAt: { type: Date, default: Date.now } From 390401f8bf88bdf3d44765aae541537b188c240d Mon Sep 17 00:00:00 2001 From: Ayush Date: Thu, 25 Jun 2026 01:58:26 +0530 Subject: [PATCH 8/8] fix(auth): explicitly export PAT model from common index --- packages/common/src/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 2866d2876..d6c7e5751 100644 --- a/packages/common/src/index.js +++ b/packages/common/src/index.js @@ -134,6 +134,7 @@ module.exports = { connectDB, redis, Developer, + PAT, Project, MailTemplate, Release,