diff --git a/src/oidc.js b/src/oidc.js index 8fe8d1484..cedbcf68d 100644 --- a/src/oidc.js +++ b/src/oidc.js @@ -2,6 +2,8 @@ exports = module.exports = { getProvider, + start, + stop, clients: { add: clientsAdd, get: clientsGet, @@ -78,7 +80,7 @@ async function clientsDel(id) { } async function clientsList() { - const results = await database.query(`SELECT * FROM ${OIDC_CLIENTS_TABLE_NAME}`, []); + const results = await database.query(`SELECT * FROM ${OIDC_CLIENTS_TABLE_NAME} SORT BY id ASC`, []); return results; } @@ -572,3 +574,11 @@ async function getProvider(routePrefix) { return provider; } + +async function start() { + +} + +async function stop() { + +} diff --git a/src/routes/index.js b/src/routes/index.js index 8629b8cde..ec6c2a413 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -17,6 +17,7 @@ exports = module.exports = { mailserver: require('./mailserver.js'), network: require('./network.js'), notifications: require('./notifications.js'), + oidcclients: require('./oidcclients.js'), profile: require('./profile.js'), provision: require('./provision.js'), services: require('./services.js'), diff --git a/src/routes/oidcclients.js b/src/routes/oidcclients.js new file mode 100644 index 000000000..c04c358f2 --- /dev/null +++ b/src/routes/oidcclients.js @@ -0,0 +1,70 @@ +'use strict'; + +exports = module.exports = { + get, + list, + add, + update, + remove +}; + +const assert = require('assert'), + BoxError = require('../boxerror.js'), + oidc = require('../oidc.js'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + safe = require('safetydance'); + +async function add(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.id !== 'string' || !req.body.id) return next(new HttpError(400, 'id must be non-empty string')); + if (typeof req.body.secret !== 'string' || !req.body.secret) return next(new HttpError(400, 'secret must be non-empty string')); + if (typeof req.body.loginRedirectUri !== 'string' || !req.body.loginRedirectUri) return next(new HttpError(400, 'loginRedirectUri must be non-empty string')); + if ('logoutRedirectUri' in req.body && (typeof req.body.logoutRedirectUri !== 'string' || !req.body.logoutRedirectUri)) return next(new HttpError(400, 'logoutRedirectUri must be non-empty string if provided')); + + const [error] = await safe(oidc.clients.add(req.body.id, req.body.secret, req.body.loginRedirectUri, req.body.logoutRedirectUri || '')); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(201, {})); +} + +async function get(req, res, next) { + assert.strictEqual(typeof req.params.clientId, 'string'); + + const [error, client] = await safe(oidc.clients.get(req.params.clientId)); + if (error) return next(BoxError.toHttpError(error)); + if (!result) return next(new HttpError(404, 'OpenID connect client not found')); + + next(new HttpSuccess(200, client)); +} + +async function update(req, res, next) { + assert.strictEqual(typeof req.params.clientId, 'string'); + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.secret !== 'string' || !req.body.secret) return next(new HttpError(400, 'secret must be non-empty string')); + if (typeof req.body.loginRedirectUri !== 'string' || !req.body.loginRedirectUri) return next(new HttpError(400, 'loginRedirectUri must be non-empty string')); + if ('logoutRedirectUri' in req.body && (typeof req.body.logoutRedirectUri !== 'string' || !req.body.logoutRedirectUri)) return next(new HttpError(400, 'logoutRedirectUri must be non-empty string if provided')); + + const [error] = await safe(oidc.clients.update(req.params.clientId, { secret: req.body.secret, loginRedirectUri: req.body.loginRedirectUri, logoutRedirectUri: req.body.logoutRedirectUri || ''})); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, {})); +} + +async function list(req, res, next) { + const [error, result] = await safe(oidc.clients.list()); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, { clients: result })); +} + +async function remove(req, res, next) { + assert.strictEqual(typeof req.params.clientId, 'string'); + + const [error] = await safe(oidc.clients.del(req.params.clientId)); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(204)); +} diff --git a/src/routes/test/common.js b/src/routes/test/common.js index 296bf7abb..cd436ed2d 100644 --- a/src/routes/test/common.js +++ b/src/routes/test/common.js @@ -56,9 +56,10 @@ exports = module.exports = { }; async function setupServer() { - await server.start(); await database._clear(); + await database.initialize(); await settings._setApiServerOrigin(exports.mockApiServerOrigin); + await server.start(); } async function setup() { diff --git a/src/routes/test/oidcclients-test.js b/src/routes/test/oidcclients-test.js new file mode 100644 index 000000000..708aaf11a --- /dev/null +++ b/src/routes/test/oidcclients-test.js @@ -0,0 +1,191 @@ +/* jslint node:true */ +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +'use strict'; + +const common = require('./common.js'), + expect = require('expect.js'), + superagent = require('superagent'); + +const CLIENT_0 = { + id: 'client0', + secret: 'secret0', + loginRedirectUri: 'http://foo.bar', + logoutRedirectUri: '' +}; + +const CLIENT_1 = { + id: 'client1', + secret: 'secret1', + loginRedirectUri: 'https://cloudron.io/login', + logoutRedirectUri: 'https://cloudron.io/logout' +}; + +describe('OpenID connect clients API', function () { + const { setup, cleanup, serverUrl, owner, user } = common; + + before(setup); + after(cleanup); + + it('create fails due to missing token', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients`) + .send(CLIENT_0) + .ok(() => true); + + expect(response.statusCode).to.equal(401); + }); + + it('create succeeds without logoutRedirectUri', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients`) + .query({ access_token: owner.token }) + .send(CLIENT_0); + + expect(response.statusCode).to.equal(201); + }); + + it('create fails for already exists', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients`) + .query({ access_token: owner.token }) + .send(CLIENT_0) + .ok(() => true); + + expect(response.statusCode).to.equal(409); + }); + + it('can create another client with logoutRedirectUri', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/groups`) + .query({ access_token: owner.token }) + .send({ name: 'group1'}); + + expect(response.statusCode).to.equal(201); + }); + + it('cannot get non-existing client', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/oidc/clients/nope`) + .query({ access_token: owner.token }) + .ok(() => true); + + expect(response.statusCode).to.equal(404); + }); + + it('cannot get existing client with normal user', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .query({ access_token: user.token }) + .ok(() => true); + + expect(response.statusCode).to.equal(403); + }); + + it('can get existing client', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/oidc/clients/${CLIENT_1.id}`) + .query({ access_token: owner.token }); + + expect(response.statusCode).to.equal(200); + expect(response.body.id).to.equal(CLIENT_1.id); + expect(response.body.secret).to.equal(CLIENT_1.secret); + expect(response.body.loginRedirectUri).to.equal(CLIENT_1.loginRedirectUri); + expect(response.body.logoutRedirectUri).to.equal(CLIENT_1.logoutRedirectUri); + }); + + it('cannot update non-existent client', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients/nope`) + .query({ access_token: owner.token }) + .ok(() => true); + + expect(response.statusCode).to.equal(404); + }); + + it('cannot list clients without token', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/oidc/clients`) + .ok(() => true); + + expect(response.statusCode).to.equal(401); + }); + + it('cannot list clients as normal user', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/oidc/clients`) + .query({ access_token: user.token }) + .ok(() => true); + + expect(response.statusCode).to.equal(403); + }); + + it('can list clients', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/oidc/clients`) + .query({ access_token: owner.token }); + + expect(response.statusCode).to.equal(200); + console.log(response.body) + expect(response.body.groups).to.be.an(Array); + expect(response.body.groups.length).to.be(2); + expect(response.body.groups[0].name).to.eql(group0Object.name); + expect(response.body.groups[1].name).to.eql(group1Object.name); + }); + + it('cannot update client without secret', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .query({ access_token: owner.token }) + .body({ loginRedirectUri: CLIENT_0.loginRedirectUrl }) + .ok(() => true); + + expect(response.statusCode).to.equal(400); + }); + + it('cannot update client without loginRedirectUrl', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .query({ access_token: owner.token }) + .body({ secret: CLIENT_0.secret }) + .ok(() => true); + + expect(response.statusCode).to.equal(400); + }); + + it('can update client without logoutRedirectUri', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .query({ access_token: owner.token }) + .body({ secret: 'newsecret', loginRedirectUri: CLIENT_0.loginRedirectUrl }) + .ok(() => true); + + expect(response.statusCode).to.equal(200); + + const response2 = await superagent.get(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .query({ access_token: owner.token }); + + expect(response2.statusCode).to.equal(200); + expect(response2.body.secret).to.equal('newsecret'); + }); + + it('can update client with logoutRedirectUri', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .query({ access_token: owner.token }) + .body({ secret: 'newsecret', loginRedirectUri: CLIENT_0.loginRedirectUrl, logoutRedirectUri: CLIENT_1.logoutRedirectUri }) + .ok(() => true); + + expect(response.statusCode).to.equal(200); + + const response2 = await superagent.get(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .query({ access_token: owner.token }); + + expect(response2.statusCode).to.equal(200); + expect(response2.body.secret).to.equal('newsecret'); + expect(response2.body.loginRedirectUrl).to.equal(CLIENT_0.loginRedirectUrl); + expect(response2.body.logoutRedirectUri).to.equal(CLIENT_1.logoutRedirectUri); + }); + + it('cannot remove without token', async function () { + const response = await superagent.del(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .ok(() => true); + + expect(response.statusCode).to.equal(401); + }); + + it('can remove empty group', async function () { + const response = await superagent.del(`${serverUrl}/api/v1/oidc/clients/${CLIENT_0.id}`) + .query({ access_token: owner.token }); + + expect(response.statusCode).to.equal(204); + }); +}); diff --git a/src/server.js b/src/server.js index fd6abe240..d7f4edddf 100644 --- a/src/server.js +++ b/src/server.js @@ -388,6 +388,12 @@ async function initializeExpressSync() { app.use(oidcPrefix, oidcProvider.callback()); + 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); + // 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) httpServer.setTimeout(0);