Skip to content
Merged
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
6 changes: 6 additions & 0 deletions apps/dashboard-api/src/__tests__/routes.user.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
95 changes: 95 additions & 0 deletions apps/dashboard-api/src/controllers/pat.controller.js
Original file line number Diff line number Diff line change
@@ -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);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"));
}
};
125 changes: 125 additions & 0 deletions apps/dashboard-api/src/middlewares/authenticateCLI.js
Original file line number Diff line number Diff line change
@@ -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.'));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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.'));
}
Comment on lines +90 to +97

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If in model we have added the ttl then we dont have to check the expiry.


// 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;
5 changes: 5 additions & 0 deletions apps/dashboard-api/src/routes/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions packages/common/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const {

// Models
const Developer = require("./models/Developer");
const PAT = require("./models/PAT");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const Project = require("./models/Project");
const MailTemplate = require("./models/MailTemplate");
const Release = require("./models/Release");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -244,4 +247,7 @@ module.exports = {
updateDeveloperOnboarding,
getProjectAccessQuery,
getProjectRole,
generatePAT,
hashToken,
encodeBase62,
};
1 change: 1 addition & 0 deletions packages/common/src/models/Developer.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const onboardingSchema = new mongoose.Schema({
}
}, { _id: false });


const developerSchema = new mongoose.Schema({
email: {
type: String,
Expand Down
24 changes: 24 additions & 0 deletions packages/common/src/models/PAT.js
Original file line number Diff line number Diff line change
@@ -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);
71 changes: 71 additions & 0 deletions packages/common/src/utils/token.utils.js
Original file line number Diff line number Diff line change
@@ -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
};
Loading