'use strict'; exports = module.exports = { add, get, update, del, delByAccessToken, delExpired, delByUserIdAndType, listByUserId, getByAccessToken, hasScope, isIpAllowedSync, SCOPES: ['*']//, 'apps', 'domains'], }; const TOKENS_FIELDS = [ 'id', 'accessToken', 'identifier', 'clientId', 'scopeJson', 'expires', 'name', 'lastUsedTime', 'allowedIpRanges' ].join(','); const assert = require('node:assert'), BoxError = require('./boxerror.js'), crypto = require('node:crypto'), database = require('./database.js'), hat = require('./hat.js'), ipaddr = require('./ipaddr.js'), safe = require('safetydance'); const gParsedRangesCache = new Map(); // indexed by token.id function postProcess(result) { assert.strictEqual(typeof result, 'object'); result.scope = safe.JSON.parse(result.scopeJson) || {}; delete result.scopeJson; return result; } function validateTokenName(name) { assert.strictEqual(typeof name, 'string'); if (name.length > 64) return new BoxError(BoxError.BAD_FIELD, 'name too long'); return null; } function validateScope(scope) { assert.strictEqual(typeof scope, 'object'); for (const key in scope) { if (exports.SCOPES.indexOf(key) === -1) return new BoxError(BoxError.BAD_FIELD, `Unkown token scope ${key}. Valid scopes are ${exports.SCOPES.join(',')}`); if (scope[key] !== 'rw' && scope[key] !== 'r') return new BoxError(BoxError.BAD_FIELD, `Unkown token scope value ${scope[key]} for ${key}. Valid values are 'rw' or 'r'`); } return null; } function hasScope(token, method, path) { assert.strictEqual(typeof token, 'object'); assert.strictEqual(typeof method, 'string'); assert.strictEqual(typeof path, 'string'); // TODO path checking in the future const matchAll = token.scope['*']; if (matchAll === 'rw') { return true; } else if (matchAll === 'r' && (method === 'GET' || method === 'HEAD' || method === 'OPTIONS')) { return true; } else { return false; } } function parseIpRanges(ipRanges) { assert.strictEqual(typeof ipRanges, 'string'); if (ipRanges.length === 0) return ['0.0.0.0/0', '::/0']; const result = []; for (const line of ipRanges.split('\n')) { if (!line || line.startsWith('#')) continue; // each line can have comma separated list. this complexity is because we changed the UI to take a line input instead of textarea for (const entry of line.split(',')) { const rangeOrIP = entry.trim(); if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `'${rangeOrIP}' is not a valid IP or range`); result.push(rangeOrIP); } } return result; } async function add(token) { assert.strictEqual(typeof token, 'object'); const { clientId, identifier, expires, allowedIpRanges } = token; const name = token.name || ''; const scope = token.scope || { '*': 'rw' }; let error = validateTokenName(name); if (error) throw error; error = validateScope(scope); if (error) throw error; parseIpRanges(allowedIpRanges); // validate const id = 'tid-' + crypto.randomUUID(); const accessToken = token.accessToken || hat(8 * 32); await database.query('INSERT INTO tokens (id, accessToken, identifier, clientId, expires, scopeJson, name, allowedIpRanges) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ id, accessToken, identifier, clientId, expires, JSON.stringify(scope), name, allowedIpRanges ]); return { id, accessToken, scope, clientId, identifier, expires, name, allowedIpRanges }; } async function get(id) { assert.strictEqual(typeof id, 'string'); const result = await database.query(`SELECT ${TOKENS_FIELDS} FROM tokens WHERE id = ?`, [ id ]); if (result.length === 0) return null; return postProcess(result[0]); } async function del(id) { assert.strictEqual(typeof id, 'string'); const result = await database.query('DELETE FROM tokens WHERE id = ?', [ id ]); if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Token not found'); } async function listByUserId(userId) { assert.strictEqual(typeof userId, 'string'); const results = await database.query(`SELECT ${TOKENS_FIELDS} FROM tokens WHERE identifier = ?`, [ userId ]); results.forEach(postProcess); return results; } async function getByAccessToken(accessToken) { assert.strictEqual(typeof accessToken, 'string'); const result = await database.query(`SELECT ${TOKENS_FIELDS} FROM tokens WHERE accessToken = ? AND expires > ?`, [ accessToken, Date.now() ]); if (result.length === 0) return null; return postProcess(result[0]); } async function delByAccessToken(accessToken) { assert.strictEqual(typeof accessToken, 'string'); const result = await database.query('DELETE FROM tokens WHERE accessToken = ?', [ accessToken ]); if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Token not found'); } async function delExpired() { const result = await database.query('DELETE FROM tokens WHERE expires <= ?', [ Date.now() ]); return result.affectedRows; } async function delByUserIdAndType(userId, type) { assert.strictEqual(typeof userId, 'string'); assert.strictEqual(typeof type, 'string'); const result = await database.query('DELETE FROM tokens WHERE identifier=? AND clientId=?', [ userId, type ]); return result.affectedRows; } async function update(id, values) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof values, 'object'); const args = [ ]; const fields = [ ]; for (const k in values) { fields.push(k + ' = ?'); args.push(values[k]); } args.push(id); const result = await database.query(`UPDATE tokens SET ${fields.join(', ')} WHERE id = ?`, args); if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Token not found'); } function isIpAllowedSync(token, ip) { assert.strictEqual(typeof token, 'object'); assert.strictEqual(typeof ip, 'string'); let allowedIpRanges = gParsedRangesCache.get(token.id); // returns undefined if not found if (!allowedIpRanges) { allowedIpRanges = parseIpRanges(token.allowedIpRanges || ''); gParsedRangesCache.set(token.id, allowedIpRanges); } for (const ipOrRange of allowedIpRanges) { if (!ipOrRange.includes('/')) { if (ipaddr.isEqual(ipOrRange, ip)) return true; } else { if (ipaddr.includes(ipOrRange, ip)) return true; } } return false; }