diff --git a/src/utils/revocation-list/check-claims.js b/src/utils/revocation-list/check-claims.js new file mode 100644 index 000000000..0e281d642 --- /dev/null +++ b/src/utils/revocation-list/check-claims.js @@ -0,0 +1,50 @@ +const queryOperators = { + eq: (a, b) => a === b, + ne: (a, b) => a !== b, + gt: (a, b) => a > b, + gte: (a, b) => a >= b, + lt: (a, b) => a < b, + lte: (a, b) => a <= b, + regex: (a, b) => { + if (typeof b !== 'string' && !(b instanceof RegExp)) { + return true; + } + const valueAsRegEx = new RegExp(b); + + return valueAsRegEx.test(String(a)); + }, + sw: (a, b) => { + if (typeof b !== 'string') { + return true; + } + return String(a).startsWith(b); + }, +}; + +const queryOperatorsKeys = Object.keys(queryOperators); + +/** + * Check rule claims with decoded token claims by comparison query operators + * @param {Object} ruleClaim + * @param {string} ruleClaim.query + * @param {string | number | RegExp} ruleClaim.value + * @param {*} decodedClaimValue + * @returns {boolean} + */ +const checkClaimValueByRule = (ruleClaim, decodedClaimValue) => { + if (ruleClaim === undefined) { + return true; + } + const { query, value } = ruleClaim; + const comparisonFunc = queryOperators[query]; + + if (comparisonFunc === undefined) { + return true; + } + return comparisonFunc(decodedClaimValue, value); +}; + +module.exports = { + queryOperatorsKeys, + checkClaimValueByRule, +}; diff --git a/src/utils/revocation-list/revoke-token-api.js b/src/utils/revocation-list/revoke-token-api.js new file mode 100644 index 000000000..6a4baad10 --- /dev/null +++ b/src/utils/revocation-list/revoke-token-api.js @@ -0,0 +1,171 @@ +const uuidv4 = require('uuid').v4; +const sortStoreList = require('./sort-list'); +const { queryOperatorsKeys, checkClaimValueByRule } = require('./check-claims'); + +/* +store +{ + "c68892eb-2fdf-45e5-9d0d-6e4592640eca": { + "expAt": 1578009600000, + "type": "all", + "claims": { + "userId": { + "query": "eq", + "value": "sometestuserid", + }, + } + }, + "b637ce58-8d55-44f4-a07d-9d77ad2043f8": { + "expAt": 1578009600000, + "type": "refresh", + "claims": { + "userId": { + "query": "eq", + "value": "someuserid", + }, + "iat": { + "query": "gte", + "value": 1578009600000, // '03-01-2020' + }, + } + }, + "30106816-99a4-499a-bced-c1b91717efa9": { + "expAt": 1578009600000, + "type": "access", + "claims": { + "iat": { + "query": "gte", + "value": 1669939200000, // '2022-12-02' + } + }, + }, +} +*/ +/* +rule 1: +{ + "type": "refresh", + "claims": { + "userId": { + "eq": "someuserid", + }, + "iat": { + "gte": 1578009600000, + }, + } +} +rule 2: +{ + "type": "access", + "claims": { + "iat": { + "gte": 1669939200000, // '2022-12-02' + } + } +} +rule 3: +{ + "type": "all", + "claims": { + "userId": { + "eq": "sometestuserid", + }, + } +} +*/ + +// move to global config +const tokenexpAt = { + refresh: 365 * 24 * 60 * 60 * 1000, // 365 days in ms + access: 1 * 60 * 60 * 1000, // 1 hour in ms +}; + +// const TOKEN_TYPES = ['refresh', 'access']; + +class RevokeTokenApi { + constructor() { + this.store = {}; + } + + addRule(rule) { + const { type, claims: addingClaims } = rule; + + const id = uuidv4(); + // rule expiration time + const expAt = Date.now() + (tokenexpAt[type] || tokenexpAt.refresh); + + const claims = {}; + Object.entries(addingClaims).forEach(([tokenClaim, queryValue]) => { + const claimQueryOptions = Object.entries(queryValue); + + const [query, value] = claimQueryOptions + .find(([queryKey]) => queryOperatorsKeys.includes(queryKey)); + + claims[tokenClaim] = { + query, + value, + }; + }); + + const newStoreRule = { + [id]: { + id, + type, + expAt, + claims, + }, + }; + + this.store = sortStoreList({ ...this.store, ...newStoreRule }); + + // sync rules with other nodes + + return id; + } + + /** + * Remove rule from store by rule id + * @param {string} id Rule ID + */ + removeRule(id) { + const { [id]: ruleByID, ...restRules } = this.store; + + this.store = restRules; + + // or + // delete this.store[id]; + + // sync rules with other nodes + } + + isRevoked(decodedData) { + const ruleList = Object.entries(this.store); + const decodedDataKeys = Object.keys(decodedData); + + const isRevokedByRule = ruleList.some(([ruleID, rule]) => { + const { type, expAt, claims } = rule; + + // remove rule if rule expired + if (Date.now() > expAt) { + this.removeRule(ruleID); + return false; + } + + if (type !== decodedData.type && type !== 'all') { + return false; + } + return decodedDataKeys.every((dataClaim) => { + const ruleClaimValue = claims[dataClaim]; + const claimValue = decodedData[dataClaim]; + + return checkClaimValueByRule(ruleClaimValue, claimValue); + }); + }); + + return isRevokedByRule; + } +} + +module.exports = { + RevokeTokenApi, +}; diff --git a/src/utils/revocation-list/sort-list.js b/src/utils/revocation-list/sort-list.js new file mode 100644 index 000000000..94c243e3e --- /dev/null +++ b/src/utils/revocation-list/sort-list.js @@ -0,0 +1,21 @@ +/** + * sorting rules for fast find revoked token in rule list + * @param {Object} store + */ +const sortStoreList = (store) => { + // more logic for sorting? + const sortedList = Object.entries(store) + .sort((ruleA, ruleB) => { + const [, rulePropsA] = ruleA; + const [, rulePropsB] = ruleB; + + const ruleClaimsSizeA = Object.keys(rulePropsA.claims).length; + const ruleClaimsSizeB = Object.keys(rulePropsB.claims).length; + + return ruleClaimsSizeA - ruleClaimsSizeB; + }); + + return Object.fromEntries(sortedList); +}; + +module.exports = sortStoreList; diff --git a/test/suites/utils/revocation-list/revoke-token-api.js b/test/suites/utils/revocation-list/revoke-token-api.js new file mode 100644 index 000000000..3797ee881 --- /dev/null +++ b/test/suites/utils/revocation-list/revoke-token-api.js @@ -0,0 +1,144 @@ +const assert = require('assert'); + +const { RevokeTokenApi } = require('../../../../src/utils/revocation-list/revoke-token-api'); + +describe('#revoke-token-api', function suite() { + it('add rule', () => { + const revokeApi = new RevokeTokenApi(); + + const rule = { + type: 'refresh', + claims: { + userId: { + eq: 'testuserid', + }, + exp: { + lte: 1578009600000, // '03-01-2020' + }, + }, + }; + + const id = revokeApi.addRule(rule); + + assert.ok(typeof id === 'string'); + assert.ok(revokeApi.store[id]); + assert.deepStrictEqual(revokeApi.store[id], { + id, + type: 'refresh', + expAt: revokeApi.store[id].expAt, + claims: { + userId: { + query: 'eq', + value: 'testuserid', + }, + exp: { + query: 'lte', + value: 1578009600000, // '03-01-2020' + }, + }, + }); + }); + + it('remove rule', () => { + const revokeApi = new RevokeTokenApi(); + + const rule = { + type: 'all', + claims: { + userId: { + regex: /.*/, + }, + }, + }; + + const id = revokeApi.addRule(rule); + assert.ok(revokeApi.store[id]); + + revokeApi.removeRule(id); + assert.ok(revokeApi.store[id] === undefined); + assert.deepStrictEqual(revokeApi.store, {}); + }); + + describe('check token by rules', () => { + it('should token revoked', () => { + const revokeApi = new RevokeTokenApi(); + + const rule = { + type: 'access', + claims: { + userId: { + eq: 'testuserid', + }, + iat: { + gte: 1578009600000, // 03.01.2020 + }, + }, + }; + const rule2 = { + type: 'refresh', + claims: { + exp: { + lte: 1669939200000, // 02.12.2022 + }, + }, + }; + const decodedData = { + userId: 'someuserid', + type: 'refresh', + iat: 1576926000000, // 21.12.2019 + exp: 1618939200000, // 20.04.2021 + }; + + revokeApi.addRule(rule); + revokeApi.addRule(rule2); + + const isTokenRevoked = revokeApi.isRevoked(decodedData); + assert.ok(isTokenRevoked); + }); + + it('should token not revoked', () => { + const revokeApi = new RevokeTokenApi(); + + const rule = { + type: 'access', + claims: { + userId: { + eq: 'testuserid', + }, + iat: { + gte: 1578009600000, // 03.01.2020 + }, + }, + }; + const rule2 = { + type: 'refresh', + claims: { + exp: { + lte: 1669939200000, // 02.12.2022 + }, + }, + }; + const rule3 = { + type: 'all', + claims: { + exp: { + lte: 1558939200000, // 27.05.2019 + }, + }, + }; + const decodedTokenData = { + userId: 'someuserid', + type: 'access', + iat: 1618790400000, // 19.04.2021 + exp: 1618939200000, // 20.04.2021 + }; + + revokeApi.addRule(rule); + revokeApi.addRule(rule2); + revokeApi.addRule(rule3); + + const isTokenRevoked = revokeApi.isRevoked(decodedTokenData); + assert.strictEqual(isTokenRevoked, false); + }); + }); +});