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'); 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..60aebf8b1 --- /dev/null +++ b/apps/dashboard-api/src/controllers/pat.controller.js @@ -0,0 +1,95 @@ +const { Developer, PAT, 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 + 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); + + // Generate PAT with base62 encoding + const environment = process.env.NODE_ENV === 'production' ? 'live' : 'test'; + const { rawToken, tokenHash, suffix } = generatePAT(environment); + + const newPat = await PAT.create({ + developer: req.user._id, + tokenHash, + suffix, + label, + type, + scopes, + expiresAt + }); + + // 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 pats = await PAT.find({ developer: req.user._id }).sort({ createdAt: -1 }); + + // only show masked suffix and metadata + const safePats = 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 patToRevoke = await PAT.findOneAndDelete({ _id: id, developer: req.user._id }); + + if (!patToRevoke) { + return next(new AppError(404, "Token not found")); + } + + // forcefully clear the Redis cache so ongoing sessions are immediately killed + 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) { + 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..6b987aa05 --- /dev/null +++ b/apps/dashboard-api/src/middlewares/authenticateCLI.js @@ -0,0 +1,125 @@ +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 + +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 cachedContext = null; + try { + const rawCache = await redis.get(cacheKey); + if (rawCache) cachedContext = JSON.parse(rawCache); + } catch (redisErr) { + console.warn("Redis GET failed, falling back to DB:", redisErr); + } + + let developer; + let matchedPat; + + if (cachedContext) { + // Cache hit still constructs the context + developer = { _id: cachedContext.developerId }; + matchedPat = { + _id: cachedContext.patId, + scopes: cachedContext.scopes, + type: cachedContext.type, + expiresAt: cachedContext.expiresAt, + tokenHash: tokenHash + }; + } else { + // query the new PAT collection + matchedPat = await PAT.findOne({ tokenHash }); + if (!matchedPat) { + return next(new AppError(401, 'Unauthorized: Invalid or revoked token.')); + } + developer = { _id: matchedPat.developer.toString() }; + + // 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 { + // 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); + } + } + + if (!matchedPat) { + 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)) { + try { + await redis.del(cacheKey); + } catch (redisErr) { + console.warn("Redis DEL failed:", redisErr); + } + 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; + + PAT.updateOne( + { _id: matchedPat._id }, + { + $set: { + lastUsedAt: new Date(), + 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; diff --git a/packages/common/src/index.js b/packages/common/src/index.js index 0292773e6..d6c7e5751 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"); @@ -127,11 +128,13 @@ const { updateDeveloperOnboarding, } = require("./utils/onboarding"); const { getProjectAccessQuery, getProjectRole } = require("./utils/projectAccess"); +const { generatePAT, hashToken, encodeBase62 } = require("./utils/token.utils"); module.exports = { connectDB, redis, Developer, + PAT, Project, MailTemplate, Release, @@ -244,4 +247,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..b77f386cc 100644 --- a/packages/common/src/models/Developer.js +++ b/packages/common/src/models/Developer.js @@ -44,6 +44,7 @@ const onboardingSchema = new mongoose.Schema({ } }, { _id: false }); + const developerSchema = new mongoose.Schema({ email: { type: String, diff --git a/packages/common/src/models/PAT.js b/packages/common/src/models/PAT.js new file mode 100644 index 000000000..3921cea7e --- /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 }, // TTL index handles indexing + lastUsedAt: { type: Date, default: null }, + lastUsedIp: { type: String, default: null }, + createdAt: { type: Date, default: Date.now } +}); + +// auto-deletes expired PATs at exactly expiresAt time +patSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +module.exports = mongoose.model('PAT', patSchema); 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 +};