// useraccesstokens plugin, server-side component // These handlers are launched with the wiki server. 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 => t.user === 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() return tokens .filter(t => t.user === user) .map(t => { const { tokenHash, ...safeToken } = t return safeToken }) } async revokeToken(user, name) { const tokens = await this.loadTokens() const tokenIndex = tokens.findIndex(t => t.user === 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 => t.user === 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 } } const startServer = async function (params) { const { app, argv } = params // Initialize token manager const tokenManager = new TokenManager(argv.status) // Middleware to check if user is authenticated const authenticated = function (req, res, next) { if (!app.securityhandler.isAuthorized(req)) { return res.status(401).json({ error: 'Must be authenticated' }) } return next() } // Middleware to get current user const getCurrentUser = function (req) { return app.securityhandler.getUser(req) } // API Routes // POST /plugin/useraccesstokens/create - Create a new token app.post('/plugin/useraccesstokens/create', authenticated, async (req, res) => { try { const user = getCurrentUser(req) const { name, expiresInDays } = req.body if (!name || typeof name !== 'string' || name.trim() === '') { return res.status(400).json({ error: 'Token name is required' }) } const result = await tokenManager.createToken(user, name.trim(), expiresInDays) res.json({ token: result.token, name: result.record.name, displayHint: result.record.displayHint, created: result.record.created, expires: result.record.expires, scopes: result.record.scopes }) } catch (error) { res.status(400).json({ error: error.message }) } }) // GET /plugin/useraccesstokens/list - List user's tokens app.get('/plugin/useraccesstokens/list', authenticated, async (req, res) => { try { const user = getCurrentUser(req) const tokens = await tokenManager.listTokens(user) res.json(tokens) } catch (error) { res.status(500).json({ error: error.message }) } }) // POST /plugin/useraccesstokens/revoke - Revoke a token app.post('/plugin/useraccesstokens/revoke', authenticated, async (req, res) => { try { const user = getCurrentUser(req) const { name } = req.body if (!name) { return res.status(400).json({ error: 'Token name is required' }) } await tokenManager.revokeToken(user, name) res.json({ message: `Token "${name}" revoked successfully` }) } catch (error) { res.status(400).json({ error: error.message }) } }) // DELETE /plugin/useraccesstokens/delete - Delete a token app.delete('/plugin/useraccesstokens/delete', authenticated, async (req, res) => { try { const user = getCurrentUser(req) const { name } = req.body if (!name) { return res.status(400).json({ error: 'Token name is required' }) } await tokenManager.deleteToken(user, name) res.json({ message: `Token "${name}" deleted successfully` }) } catch (error) { res.status(400).json({ error: error.message }) } }) // Middleware for token-based authentication app.use('/api', async (req, res, next) => { const authHeader = req.headers.authorization if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7) try { const tokenRecord = await tokenManager.validateToken(token) if (tokenRecord) { // Set user context for token-based requests req.tokenAuth = { user: tokenRecord.user, scopes: tokenRecord.scopes, tokenName: tokenRecord.name } return next() } } catch (error) { console.error('Token validation error:', error) } } next() }) console.log('UserAccessTokens plugin server started') } export { startServer, TokenManager }