oidc: refactor the StorageAdapter

This commit is contained in:
Girish Ramakrishnan
2025-06-13 01:06:50 +02:00
parent 25684bf4f6
commit b60681e9bd
2 changed files with 107 additions and 153 deletions

View File

@@ -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];
}
});
}
}

View File

@@ -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();
}