diff --git a/dashboard/src/js/client.js b/dashboard/src/js/client.js
index 023e70d5b..940179255 100644
--- a/dashboard/src/js/client.js
+++ b/dashboard/src/js/client.js
@@ -1929,6 +1929,15 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
});
};
+ Client.prototype.destroyOidcSession = function (callback) {
+ del('/api/v1/oidc/sessions', null, function (error, data, status) {
+ if (error) return callback(error);
+ if (status !== 204) return callback(new ClientError(status, data));
+
+ callback(null);
+ });
+ };
+
Client.prototype.getOidcClients = function (callback) {
get('/api/v1/oidc/clients', null, function (error, data, status) {
if (error) return callback(error);
diff --git a/dashboard/src/views/profile.html b/dashboard/src/views/profile.html
index d7bd9289e..a112774b9 100644
--- a/dashboard/src/views/profile.html
+++ b/dashboard/src/views/profile.html
@@ -542,10 +542,9 @@
{{ 'profile.loginTokens.description' | tr:{ webadminTokenCount: tokens.webadminTokens.length, cliTokenCount: cliTokens.length } }}
-
-
+
-
+
\ No newline at end of file
diff --git a/dashboard/src/views/profile.js b/dashboard/src/views/profile.js
index fe9c7dc40..f61a98721 100644
--- a/dashboard/src/views/profile.js
+++ b/dashboard/src/views/profile.js
@@ -709,6 +709,14 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
}
};
+ $scope.logoutFromAll = function () {
+ Client.destroyOidcSession(function (error) {
+ if (error) console.error('Failed to destroy oidc session', error);
+
+ $scope.tokens.revokeAllWebAndCliTokens();
+ });
+ };
+
Client.onReady(function () {
$scope.appPassword.refresh();
$scope.tokens.refresh();
diff --git a/src/oidc.js b/src/oidc.js
index 5c749c1cf..ac00289af 100644
--- a/src/oidc.js
+++ b/src/oidc.js
@@ -3,6 +3,7 @@
exports = module.exports = {
start,
stop,
+ revokeByUserId,
clients: {
add: clientsAdd,
get: clientsGet,
@@ -97,6 +98,69 @@ async function clientsList() {
return results;
}
+// -----------------------------
+// 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) {
+ debug(`load: failed to read ${filePath}, start with new one.`, 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`);
+ debug(`save: model ${modelName} to ${filePath}.`);
+
+ try {
+ fs.writeFileSync(filePath, JSON.stringify(DATA_STORE[modelName]), 'utf8');
+ } catch (e) {
+ debug(`revokeByUserId: 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');
+
+ debug(`revokeByUserId: userId:${userId}`);
+
+ function revokeObjects(modelName) {
+ load(modelName);
+
+ for (let id in DATA_STORE[modelName]) {
+ if (DATA_STORE[modelName][id].payload?.accountId === userId) delete DATA_STORE[modelName][id];
+ }
+
+ save(modelName);
+ }
+
+ revokeObjects('Session');
+ revokeObjects('Grant');
+ revokeObjects('AuthorizationCode');
+ revokeObjects('AccessToken');
+}
+
// -----------------------------
// Generic oidc node module data store model
// -----------------------------
@@ -115,22 +179,10 @@ class CloudronAdapter {
constructor(name) {
this.name = name;
- if (this.name === 'Client') {
- this.store = null;
- this.fileStorePath = null;
- } else {
- this.fileStorePath = path.join(paths.OIDC_STORE_DIR, `${name}.json`);
+ debug(`Creating storage adapter for ${name}`);
- debug(`Creating storage adapter for ${name} backed by ${this.fileStorePath}`);
-
- let data = {};
- try {
- data = JSON.parse(fs.readFileSync(this.fileStorePath), 'utf8');
- } catch (e) {
- debug(`filestore for adapter ${name} not found, start with new one`);
- }
-
- this.store = data;
+ if (this.name !== 'Client') {
+ load(name);
}
}
@@ -152,8 +204,8 @@ class CloudronAdapter {
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
} else {
- this.store[id] = { id, expiresIn, payload, consumed: false };
- if (this.fileStorePath) fs.writeFileSync(this.fileStorePath, JSON.stringify(this.store), 'utf8');
+ DATA_STORE[this.name][id] = { id, expiresIn, payload, consumed: false };
+ save(this.name);
}
}
@@ -191,11 +243,11 @@ class CloudronAdapter {
return tmp;
} else {
- if (!this.store[id]) return null;
+ if (!DATA_STORE[this.name][id]) return null;
- debug(`[${this.name}] find id:${id}`, this.store[id]);
+ debug(`[${this.name}] find id:${id}`, DATA_STORE[this.name][id]);
- return this.store[id].payload;
+ return DATA_STORE[this.name][id].payload;
}
}
@@ -230,8 +282,8 @@ class CloudronAdapter {
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
} else {
- for (let d in this.store) {
- if (this.store[d].payload.uid === uid) return this.store[d].payload;
+ for (let d in DATA_STORE[this.name]) {
+ if (DATA_STORE[this.name][d].payload.uid === uid) return DATA_STORE[this.name][d].payload;
}
return false;
@@ -255,9 +307,8 @@ class CloudronAdapter {
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
} else {
- if (this.store[id]) this.store[id].consumed = true;
-
- if (this.fileStorePath) fs.writeFileSync(this.fileStorePath, JSON.stringify(this.store), 'utf8');
+ if (DATA_STORE[this.name][id]) DATA_STORE[this.name][id].consumed = true;
+ save(this.name);
}
}
@@ -277,9 +328,8 @@ class CloudronAdapter {
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
} else {
- delete this.store[id];
-
- if (this.fileStorePath) fs.writeFileSync(this.fileStorePath, JSON.stringify(this.store), 'utf8');
+ delete DATA_STORE[this.name][id];
+ save(this.name);
}
}
@@ -299,10 +349,10 @@ class CloudronAdapter {
if (this.name === 'Client') {
console.log('WARNING!! this should not happen as it is stored in our db');
} else {
- for (let d in this.store) {
- if (this.store[d].grantId === grantId) {
- delete this.store[d];
- return;
+ for (let d in DATA_STORE[this.name]) {
+ if (DATA_STORE[this.name][d].grantId === grantId) {
+ delete DATA_STORE[this.name][d];
+ return save(this.name);
}
}
}
diff --git a/src/routes/index.js b/src/routes/index.js
index ec6c2a413..4581883be 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -17,7 +17,7 @@ exports = module.exports = {
mailserver: require('./mailserver.js'),
network: require('./network.js'),
notifications: require('./notifications.js'),
- oidcclients: require('./oidcclients.js'),
+ oidc: require('./oidc.js'),
profile: require('./profile.js'),
provision: require('./provision.js'),
services: require('./services.js'),
diff --git a/src/routes/oidcclients.js b/src/routes/oidc.js
similarity index 89%
rename from src/routes/oidcclients.js
rename to src/routes/oidc.js
index d5ad734fc..870f4548a 100644
--- a/src/routes/oidcclients.js
+++ b/src/routes/oidc.js
@@ -1,11 +1,15 @@
'use strict';
exports = module.exports = {
- get,
- list,
- add,
- update,
- remove
+ clients: {
+ get,
+ list,
+ add,
+ update,
+ del
+ },
+
+ destroyUserSession
};
const assert = require('assert'),
@@ -78,7 +82,7 @@ async function list(req, res, next) {
next(new HttpSuccess(200, { clients: result }));
}
-async function remove(req, res, next) {
+async function del(req, res, next) {
assert.strictEqual(typeof req.params.clientId, 'string');
const [error] = await safe(oidc.clients.del(req.params.clientId));
@@ -86,3 +90,12 @@ async function remove(req, res, next) {
next(new HttpSuccess(204));
}
+
+async function destroyUserSession(req, res, next) {
+ assert.strictEqual(typeof req.user, 'object');
+
+ const [error] = await safe(oidc.revokeByUserId(req.user.id));
+ if (error) return next(BoxError.toHttpError(error));
+
+ next(new HttpSuccess(204));
+}
diff --git a/src/server.js b/src/server.js
index 5a9902561..685e2eb0d 100644
--- a/src/server.js
+++ b/src/server.js
@@ -369,11 +369,14 @@ async function initializeExpressSync() {
router.get ('/well-known-handler/*', routes.wellknown.get);
// OpenID connect clients
- router.get ('/api/v1/oidc/clients', token, authorizeAdmin, routes.oidcclients.list);
- router.post('/api/v1/oidc/clients', json, token, authorizeAdmin, routes.oidcclients.add);
- router.get ('/api/v1/oidc/clients/:clientId', token, authorizeAdmin, routes.oidcclients.get);
- router.post('/api/v1/oidc/clients/:clientId', json, token, authorizeAdmin, routes.oidcclients.update);
- router.del ('/api/v1/oidc/clients/:clientId', token, authorizeAdmin, routes.oidcclients.remove);
+ router.get ('/api/v1/oidc/clients', token, authorizeAdmin, routes.oidc.clients.list);
+ router.post('/api/v1/oidc/clients', json, token, authorizeAdmin, routes.oidc.clients.add);
+ router.get ('/api/v1/oidc/clients/:clientId', token, authorizeAdmin, routes.oidc.clients.get);
+ router.post('/api/v1/oidc/clients/:clientId', json, token, authorizeAdmin, routes.oidc.clients.update);
+ router.del ('/api/v1/oidc/clients/:clientId', token, authorizeAdmin, routes.oidc.clients.del);
+
+ // OpenID connect sessions
+ router.del ('/api/v1/oidc/sessions', token, authorizeUser, routes.oidc.destroyUserSession);
// disable server socket "idle" timeout. we use the timeout middleware to handle timeouts on a route level
// we rely on nginx for timeouts on the TCP level (see client_header_timeout)