// useraccesstokens plugin, TokenManager class // Provides token management functionality for the security enhancer import fs from 'node:fs' import fsp from 'node:fs/promises' import path from 'node:path' import crypto from 'node:crypto' import bcrypt from 'bcrypt' const SALT_ROUNDS = 12 const TOKEN_PREFIX = 'fwuat-' const TOKEN_BYTES = 32 class TokenManager { constructor(statusPath) { this.statusPath = statusPath this.tokensFile = path.join(statusPath, 'user-access-tokens.json') } async ensureTokensFile() { try { await fsp.access(this.tokensFile) } catch { await fsp.writeFile(this.tokensFile, JSON.stringify([], null, 2)) } } async loadTokens() { await this.ensureTokensFile() const data = await fsp.readFile(this.tokensFile, 'utf8') return JSON.parse(data) } async saveTokens(tokens) { await fsp.writeFile(this.tokensFile, JSON.stringify(tokens, null, 2)) } generateToken() { const tokenBytes = crypto.randomBytes(TOKEN_BYTES) const token = TOKEN_PREFIX + tokenBytes.toString('base64url') return token } async hashToken(token) { return await bcrypt.hash(token, SALT_ROUNDS) } async verifyToken(token, hash) { return await bcrypt.compare(token, hash) } getDisplayHint(token) { return token.slice(-4) } async createToken(user, name, expiresInDays = null) { const tokens = await this.loadTokens() // Check if token name already exists for this user const existingToken = tokens.find(t => JSON.stringify(t.user) === JSON.stringify(user) && t.name === name && !t.revoked) if (existingToken) { throw new Error(`Token with name "${name}" already exists`) } const token = this.generateToken() const tokenHash = await this.hashToken(token) const created = new Date().toISOString() const expires = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString() : null const tokenRecord = { name, user, tokenHash, displayHint: this.getDisplayHint(token), created, expires, lastUsed: null, revoked: false, scopes: ['site:read', 'site:write'] // Default scopes } tokens.push(tokenRecord) await this.saveTokens(tokens) return { token, record: tokenRecord } } async listTokens(user) { const tokens = await this.loadTokens() const filteredTokens = tokens.filter(t => { return JSON.stringify(t.user) === JSON.stringify(user) }) return filteredTokens.map(t => { const { tokenHash, ...safeToken } = t return safeToken }) } async revokeToken(user, name) { const tokens = await this.loadTokens() const tokenIndex = tokens.findIndex(t => JSON.stringify(t.user) === JSON.stringify(user) && t.name === name) if (tokenIndex === -1) { throw new Error(`Token "${name}" not found`) } tokens[tokenIndex].revoked = true await this.saveTokens(tokens) return tokens[tokenIndex] } async deleteToken(user, name) { const tokens = await this.loadTokens() const tokenIndex = tokens.findIndex(t => JSON.stringify(t.user) === JSON.stringify(user) && t.name === name) if (tokenIndex === -1) { throw new Error(`Token "${name}" not found`) } const deletedToken = tokens.splice(tokenIndex, 1)[0] await this.saveTokens(tokens) return deletedToken } async validateToken(token) { if (!token.startsWith(TOKEN_PREFIX)) { return null } const tokens = await this.loadTokens() const now = new Date() for (const tokenRecord of tokens) { if (tokenRecord.revoked) continue if (tokenRecord.expires && new Date(tokenRecord.expires) < now) continue const isValid = await this.verifyToken(token, tokenRecord.tokenHash) if (isValid) { // Update last used timestamp tokenRecord.lastUsed = now.toISOString() await this.saveTokens(tokens) return tokenRecord } } return null } } export { TokenManager }