oidc: refactor the StorageAdapter
This commit is contained in:
@@ -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];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user