diff --git a/src/oidcserver.js b/src/oidcserver.js index 36f845b65..07302a074 100644 --- a/src/oidcserver.js +++ b/src/oidcserver.js @@ -4,7 +4,6 @@ exports = module.exports = { start, stop, revokeByUserId, - getUserByAuthCode, consumeAuthCode, cleanupExpired, @@ -51,107 +50,41 @@ const ROUTE_PREFIX = '/openid'; let gHttpServer = null, gOidcProvider = null; -// Basic in-memory json file backed based data store -const DATA_STORE = {}; - -function load(modelName) { - assert.strictEqual(typeof modelName, 'string'); - - if (DATA_STORE[modelName]) return; - - const filePath = path.join(paths.OIDC_STORE_DIR, `${modelName}.json`); - debug(`load: model ${modelName} based on ${filePath}.`); - - let data = {}; - try { - data = JSON.parse(fs.readFileSync(filePath), 'utf8'); - } catch (e) { - if (e.code !== 'ENOENT') debug(`load: failed to read ${filePath}, use in-memory. %o`, e); - } - - DATA_STORE[modelName] = data; -} - -function save(modelName) { - assert.strictEqual(typeof modelName, 'string'); - - if (!DATA_STORE[modelName]) return; - - const filePath = path.join(paths.OIDC_STORE_DIR, `${modelName}.json`); - - try { - fs.writeFileSync(filePath, JSON.stringify(DATA_STORE[modelName], null, 2), 'utf8'); - } catch (e) { - debug(`save: model ${modelName} failed to write ${filePath}`, e); - } -} - -// Session, Grant and Token management. This is based on the same storage as the below CloudronAdapter -async function revokeByUserId(userId) { - assert.strictEqual(typeof userId, 'string'); - - const types = [ 'Session', 'Grant', 'AuthorizationCode', 'AccessToken' ]; - for (const type of types) { - load(type); - - for (const id in DATA_STORE[type]) { - if (DATA_STORE[type][id].payload?.accountId === userId) delete DATA_STORE[type][id]; - } - - save(type); - } -} - -async function consumeAuthCode(authCode) { - assert.strictEqual(typeof authCode, 'string'); - - const authData = DATA_STORE['AuthorizationCode'][authCode]; - if (!authData || !authData.payload) return; - - DATA_STORE['AuthorizationCode'][authCode].consumed = true; - - save('AuthorizationCode'); -} - -async function getUserByAuthCode(authCode) { - assert.strictEqual(typeof authCode, 'string'); - - load('AuthorizationCode'); - const authData = DATA_STORE['AuthorizationCode'][authCode]; - - if (!authData || !authData.payload || !authData.payload.accountId) return null; - - return await users.get(authData.payload.accountId); -} - -// This exposed to run on a cron job -async function cleanupExpired() { - debug('cleanupExpired'); - - const types = [ 'AuthorizationCode', 'AccessToken', 'Grant', 'Interaction', 'RefreshToken', 'Session' ]; - for (const type of types) { - load(type); - - for (const key in DATA_STORE[type]) { - if (!DATA_STORE[type][key].expiresAt || DATA_STORE[type][key].expiresAt < Date.now()) delete DATA_STORE[type][key]; - } - - save(type); - } -} - -// Generic oidc node module data store model . It will be instantiated many times (AccessToken, Grant, Interaction etc) +// Client data store is part of the database, so it's not saved in files +// https://github.com/panva/node-oidc-provider/blob/183dc4f4b1ec1a53c5254d809091737a95c31f14/example/my_adapter.js class StorageAdapter { + static #database = {}; // indexed by name. The format of entry is { id, expiresAt, payload, consumed } + + static async getData(name) { + if (name === 'Client') throw new Error(`${name} is a database model`); + + if (StorageAdapter.#database[name]) return StorageAdapter.#database[name]; + + StorageAdapter.#database[name] = {}; // init with empty table + + const filePath = path.join(paths.OIDC_STORE_DIR, `${name}.json`); + const [error, data] = await safe(fs.promises.readFile(filePath, 'utf8')); + if (!error) StorageAdapter.#database[name] = safe.JSON.parse(data) || {}; // reset table if file corrupt + + return StorageAdapter.#database[name]; + } + + static async saveData(name) { + if (name === 'Client') throw new Error(`${name} is a database model`); + + const filePath = path.join(paths.OIDC_STORE_DIR, `${name}.json`); + await fs.promises.writeFile(filePath, JSON.stringify(StorageAdapter.#database[name], null, 2), 'utf8'); + } + + static async updateData(name, action) { + const data = await StorageAdapter.getData(name); + await action(data); + await StorageAdapter.saveData(name); + } + constructor(name) { - this.name = name; - debug(`Creating OpenID storage adapter for ${name}`); - - if (this.name === 'Client') { - return; - } else { - load(name); - } + this.name = name; } async upsert(id, payload, expiresIn) { @@ -159,9 +92,8 @@ class StorageAdapter { const expiresAt = expiresIn ? new Date(Date.now() + (expiresIn * 1000)) : 0; - if (this.name === 'Client') { - debug('upsert: this should not happen as it is stored in our db'); - } else if (this.name === 'AccessToken' && (payload.clientId === oidcClients.ID_WEBADMIN || payload.clientId === oidcClients.ID_DEVELOPMENT)) { + // only AccessToken of webadmin are stored in the db. Dashboard uses REST API and the token middleware looks up tokens in db + if (this.name === 'AccessToken' && (payload.clientId === oidcClients.ID_WEBADMIN || payload.clientId === oidcClients.ID_DEVELOPMENT)) { const expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS; const [error] = await safe(tokens.add({ clientId: payload.clientId, identifier: payload.accountId, expires, accessToken: id, allowedIpRanges: '' })); @@ -170,8 +102,7 @@ class StorageAdapter { throw error; } } else { - DATA_STORE[this.name][id] = { id, expiresAt, payload, consumed: false }; - save(this.name); + await StorageAdapter.updateData(this.name, (data) => data[id] = { id, expiresAt, payload, consumed: false }); } } @@ -180,11 +111,10 @@ class StorageAdapter { if (this.name === 'Client') { const [error, client] = await safe(oidcClients.get(id)); - if (error) { + if (error || !client) { debug('find: error getting client', error); return null; } - if (!client) return null; const tmp = {}; tmp.application_type = client.application_type || 'native'; // default is web but we want more flexible redirectUris and this is only used in https://github.com/panva/node-oidc-provider/blob/03c9bc513860e68ee7be84f99bfc9dc930b224e8/lib/helpers/client_schema.js#L536 @@ -217,22 +147,17 @@ class StorageAdapter { return tmp; } else if (this.name === 'AccessToken') { + // dashboard AccessToken are in the db. the app tokens are in the json files const [error, result] = await safe(tokens.getByAccessToken(id)); - if (error || !result) { - debug(`find: ${id} is not an API accessToken maybe oidc internal`); - - if (!DATA_STORE[this.name][id]) return null; - return DATA_STORE[this.name][id].payload; + if (!error && result) { + return { + accountId: result.identifier, + clientId: result.clientId + }; } - - const tmp = { - accountId: result.identifier, - clientId: result.clientId - }; - - return tmp; } else if (this.name === 'Session') { - const session = DATA_STORE[this.name][id]; + const data = await StorageAdapter.getData(this.name); + const session = data[id]; if (!session) return null; if (session.payload.accountId) { @@ -242,65 +167,96 @@ class StorageAdapter { } return session.payload; - } else { - if (!DATA_STORE[this.name][id]) return null; - return DATA_STORE[this.name][id].payload; } + + const data = await StorageAdapter.getData(this.name); + if (!data[id]) return null; + return data[id].payload; } async findByUserCode(userCode) { debug(`[${this.name}] FIXME findByUserCode userCode:${userCode}`); } + // this is called only on Session store. there is a payload.uid async findByUid(uid) { debug(`[${this.name}] findByUid: ${uid}`); - if (this.name === 'Client' || this.name === 'AccessToken') { - debug('findByUid: this should not happen as it is stored in our db'); - } else { - for (const d in DATA_STORE[this.name]) { - if (DATA_STORE[this.name][d].payload.uid === uid) return DATA_STORE[this.name][d].payload; - } - - return false; + const data = await StorageAdapter.getData(this.name); + for (const d in data) { + if (data[d].payload.uid === uid) return data[d].payload; } + + return null; } async consume(id) { debug(`[${this.name}] consume: ${id}`); - if (this.name === 'Client') { - debug('consume: this should not happen as it is stored in our db'); - } else { - if (DATA_STORE[this.name][id]) DATA_STORE[this.name][id].consumed = true; - save(this.name); - } + await StorageAdapter.updateData(this.name, (data) => data[id].consumed = true); } async destroy(id) { debug(`[${this.name}] destroy: ${id}`); - if (this.name === 'Client') { - debug('destroy: this should not happen as it is stored in our db'); - } else { - delete DATA_STORE[this.name][id]; - save(this.name); - } + await StorageAdapter.updateData(this.name, (data) => delete data[id]); } async revokeByGrantId(grantId) { debug(`[${this.name}] revokeByGrantId: ${grantId}`); - if (this.name === 'Client') { - debug('revokeByGrantId: this should not happen as it is stored in our db'); - } else { - for (const d in DATA_STORE[this.name]) { - if (DATA_STORE[this.name][d].grantId === grantId) { - delete DATA_STORE[this.name][d]; - return save(this.name); + await StorageAdapter.updateData(this.name, (data) => { + for (const d in data) { + if (data[d].grantId === grantId) { + delete data[d]; + return; } } + }); + } +} + +// Session, Grant and Token management. This is based on the same storage as the below CloudronAdapter +async function revokeByUserId(userId) { + assert.strictEqual(typeof userId, 'string'); + + const types = [ 'Session', 'Grant', 'AuthorizationCode', 'AccessToken' ]; + for (const type of types) { + await StorageAdapter.updateData(type, (data) => { + for (const id in data) { + if (data[id].payload?.accountId === userId) delete data[id]; + } + }); + } +} + +// used by proxyauth logic to authenticate using a one time code +async function consumeAuthCode(authCode) { + assert.strictEqual(typeof authCode, 'string'); + + let userId = null; + await StorageAdapter.updateData('AuthorizationCode', (data) => { + const authData = data[authCode]; + if (authData) { + userId = authData.payload.accountId; + authData.consumed = true; } + }); + + return userId; +} + +// This exposed to run on a cron job +async function cleanupExpired() { + debug('cleanupExpired'); + + const types = [ 'AuthorizationCode', 'AccessToken', 'Grant', 'Interaction', 'RefreshToken', 'Session' ]; + for (const type of types) { + await StorageAdapter.updateData(type, (data) => { + for (const key in data) { + if (!data[key].expiresAt || data[key].expiresAt < Date.now()) delete data[key]; + } + }); } } diff --git a/src/proxyauth.js b/src/proxyauth.js index b16736bd6..2e0e379fd 100644 --- a/src/proxyauth.js +++ b/src/proxyauth.js @@ -150,10 +150,8 @@ async function callback(req, res, next) { debug(`callback: with code ${req.query.code}`); - req.user = await oidcServer.getUserByAuthCode(req.query.code); - - // this is one-time use - await oidcServer.consumeAuthCode(req.query.code); + const userId = await oidcServer.consumeAuthCode(req.query.code); + if (userId) req.user = await users.get(userId); next(); }