-
Notifications
You must be signed in to change notification settings - Fork 68
Enabling PAT backend #336
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
Merged
yash-pouranik
merged 8 commits into
geturbackend:main
from
Ayush4958:enabling-PAT-backend-clean
Jun 25, 2026
Merged
Enabling PAT backend #336
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1678553
Modified Developer Model, Enabling PAT schema
Ayush4958 084e3ae
Added controller, middleware, routes for PAT in dashboard-api
Ayush4958 44dfd21
Adds Test case
Ayush4958 6483ab8
Changes accord to the CodeRabbit
Ayush4958 0e5ed69
refactor(auth): isolate PATs to independent collection for native TTLβ¦
Ayush4958 a358f14
Updated the comments
Ayush4958 93c2916
fix(auth): remove duplicate index from expiresAt field in PAT schema
Ayush4958 390401f
fix(auth): explicitly export PAT model from common index
Ayush4958 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
|
|
||
| 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")); | ||
| } | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.')); | ||
| } | ||
|
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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| }; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.