'use strict'; exports = module.exports = { start, stop, revokeByUserId, getUserByAuthCode, consumeAuthCode, addClient, getClient, delClient, updateClient, listClients }; const assert = require('assert'), apps = require('./apps.js'), AuditSource = require('./auditsource.js'), BoxError = require('./boxerror.js'), blobs = require('./blobs.js'), branding = require('./branding.js'), constants = require('./constants.js'), crypto = require('crypto'), dashboard = require('./dashboard.js'), database = require('./database.js'), debug = require('debug')('box:oidc'), dns = require('./dns.js'), ejs = require('ejs'), express = require('express'), eventlog = require('./eventlog.js'), fs = require('fs'), marked = require('marked'), middleware = require('./middleware'), path = require('path'), paths = require('./paths.js'), http = require('http'), HttpError = require('connect-lastmile').HttpError, jose = require('jose'), safe = require('safetydance'), settings = require('./settings.js'), tokens = require('./tokens.js'), translations = require('./translations.js'), url = require('url'), users = require('./users.js'), groups = require('./groups.js'), util = require('util'); const OIDC_CLIENTS_TABLE_NAME = 'oidcClients'; const OIDC_CLIENTS_FIELDS = [ 'id', 'secret', 'name', 'appId', 'loginRedirectUri', 'tokenSignatureAlgorithm' ]; const ROUTE_PREFIX = '/openid'; const DEFAULT_TOKEN_SIGNATURE_ALGORITHM='RS256'; let gHttpServer = null; // ----------------------------- // Database model // ----------------------------- function postProcess(result) { assert.strictEqual(typeof result, 'object'); result.tokenSignatureAlgorithm = result.tokenSignatureAlgorithm || DEFAULT_TOKEN_SIGNATURE_ALGORITHM; return result; } async function addClient(id, data) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof data.secret, 'string'); assert.strictEqual(typeof data.loginRedirectUri, 'string'); assert.strictEqual(typeof data.name, 'string'); assert.strictEqual(typeof data.appId, 'string'); assert(data.tokenSignatureAlgorithm === 'RS256' || data.tokenSignatureAlgorithm === 'EdDSA'); const query = `INSERT INTO ${OIDC_CLIENTS_TABLE_NAME} (id, secret, name, appId, loginRedirectUri, tokenSignatureAlgorithm) VALUES (?, ?, ?, ?, ?, ?)`; const args = [ id, data.secret, data.name, data.appId, data.loginRedirectUri, data.tokenSignatureAlgorithm ]; const [error] = await safe(database.query(query, args)); if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, 'client already exists'); if (error) throw error; } async function getClient(id) { assert.strictEqual(typeof id, 'string'); if (id === tokens.ID_WEBADMIN) { const { fqdn:dashboardFqdn } = await dashboard.getLocation(); return { id: tokens.ID_WEBADMIN, secret: 'notused', application_type: 'web', response_types: ['code', 'code token'], grant_types: ['authorization_code', 'implicit'], loginRedirectUri: `https://${dashboardFqdn}/authcallback.html` }; } else if (id === tokens.ID_DEVELOPMENT) { return { id: tokens.ID_DEVELOPMENT, secret: 'notused', application_type: 'native', // have to use native here to support plaintext http, this however makes it impossible to skip consent screen response_types: ['code', 'code token'], grant_types: ['authorization_code', 'implicit'], loginRedirectUri: 'http://localhost:4000/authcallback.html' }; } const result = await database.query(`SELECT ${OIDC_CLIENTS_FIELDS} FROM ${OIDC_CLIENTS_TABLE_NAME} WHERE id = ?`, [ id ]); if (result.length === 0) return null; return postProcess(result[0]); } async function updateClient(id, data) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof data.loginRedirectUri, 'string'); assert.strictEqual(typeof data.name, 'string'); assert.strictEqual(typeof data.appId, 'string'); assert(data.tokenSignatureAlgorithm === 'RS256' || data.tokenSignatureAlgorithm === 'EdDSA'); const result = await database.query(`UPDATE ${OIDC_CLIENTS_TABLE_NAME} SET name=?, appId=?, loginRedirectUri=?, tokenSignatureAlgorithm=? WHERE id = ?`, [ data.name, data.appId, data.loginRedirectUri, data.tokenSignatureAlgorithm, id]); if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'client not found'); } async function delClient(id) { assert.strictEqual(typeof id, 'string'); const result = await database.query(`DELETE FROM ${OIDC_CLIENTS_TABLE_NAME} WHERE id = ?`, [ id ]); if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'client not found'); } async function listClients() { const results = await database.query(`SELECT * FROM ${OIDC_CLIENTS_TABLE_NAME} ORDER BY name ASC`, []); results.forEach(postProcess); 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) { 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'); function revokeObjects(modelName) { load(modelName); for (const 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'); } 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); } // ----------------------------- // Generic oidc node module data store model // ----------------------------- class CloudronAdapter { /** * * Creates an instance of MyAdapter for an oidc-provider model. * * @constructor * @param {string} name Name of the oidc-provider model. One of "Grant, "Session", "AccessToken", * "AuthorizationCode", "RefreshToken", "ClientCredentials", "Client", "InitialAccessToken", * "RegistrationAccessToken", "DeviceCode", "Interaction", "ReplayDetection", * "BackchannelAuthenticationRequest", or "PushedAuthorizationRequest" * */ constructor(name) { this.name = name; debug(`Creating OpenID storage adapter for ${name}`); if (this.name === 'Client') { return; } else { load(name); } } /** * * Update or Create an instance of an oidc-provider model. * * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when * encountered. * @param {string} id Identifier that oidc-provider will use to reference this model instance for * future operations. * @param {object} payload Object with all properties intended for storage. * @param {integer} expiresIn Number of seconds intended for this model to be stored. * */ async upsert(id, payload, expiresIn) { debug(`[${this.name}] upsert: ${id}`); if (this.name === 'Client') { debug('upsert: this should not happen as it is stored in our db'); } else if (this.name === 'AccessToken' && (payload.clientId === tokens.ID_WEBADMIN || payload.clientId === tokens.ID_DEVELOPMENT)) { const clientId = payload.clientId; const identifier = payload.accountId; const expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS; const accessToken = id; const [error] = await safe(tokens.add({ clientId, identifier, expires, accessToken })); if (error) { console.log('Error adding access token', error); throw error; } } else { DATA_STORE[this.name][id] = { id, expiresIn, payload, consumed: false }; save(this.name); } } /** * * Return previously stored instance of an oidc-provider model. * * @return {Promise} Promise fulfilled with what was previously stored for the id (when found and * not dropped yet due to expiration) or falsy value when not found anymore. Rejected with error * when encountered. * @param {string} id Identifier of oidc-provider model * */ async find(id) { debug(`[${this.name}] find: ${id}`); if (this.name === 'Client') { const [error, client] = await safe(getClient(id)); if (error) { 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 tmp.client_id = id; tmp.client_secret = client.secret; tmp.id_token_signed_response_alg = client.tokenSignatureAlgorithm || 'RS256'; if (client.response_types) tmp.response_types = client.response_types; if (client.grant_types) tmp.grant_types = client.grant_types; if (client.appId) { const [error, app] = await safe(apps.get(client.appId)); if (error || !app) { debug(`find: Unknown app for client with appId ${client.appId}`); return null; } const domains = [ app.fqdn ].concat(app.aliasDomains.map(d => d.fqdn)); // prefix login redirect uris with app.fqdn if it is just a path without a schema // native callbacks for apps have custom schema like app.immich:/ tmp.redirect_uris = []; client.loginRedirectUri.split(',').map(s => s.trim()).forEach((s) => { if (url.parse(s).protocol) tmp.redirect_uris.push(s); else tmp.redirect_uris = tmp.redirect_uris.concat(domains.map(fqdn => `https://${fqdn}${s}`)); }); } else { tmp.redirect_uris = client.loginRedirectUri.split(',').map(s => s.trim()); } return tmp; } else if (this.name === 'AccessToken') { 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; } const tmp = { accountId: result.identifier, clientId: result.clientId }; return tmp; } else { if (!DATA_STORE[this.name][id]) return null; return DATA_STORE[this.name][id].payload; } } /** * * Return previously stored instance of DeviceCode by the end-user entered user code. You only * need this method for the deviceFlow feature * * @return {Promise} Promise fulfilled with the stored device code object (when found and not * dropped yet due to expiration) or falsy value when not found anymore. Rejected with error * when encountered. * @param {string} userCode the user_code value associated with a DeviceCode instance * */ async findByUserCode(userCode) { debug(`[${this.name}] FIXME findByUserCode userCode:${userCode}`); } /** * * Return previously stored instance of Session by its uid reference property. * * @return {Promise} Promise fulfilled with the stored session object (when found and not * dropped yet due to expiration) or falsy value when not found anymore. Rejected with error * when encountered. * @param {string} uid the uid value associated with a Session instance * */ 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; } } /** * * Mark a stored oidc-provider model as consumed (not yet expired though!). Future finds for this * id should be fulfilled with an object containing additional property named "consumed" with a * truthy value (timestamp, date, boolean, etc). * * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when * encountered. * @param {string} id Identifier of oidc-provider model * */ 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); } } /** * * Destroy/Drop/Remove a stored oidc-provider model. Future finds for this id should be fulfilled * with falsy values. * * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when * encountered. * @param {string} id Identifier of oidc-provider model * */ 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); } } /** * * Destroy/Drop/Remove a stored oidc-provider model by its grantId property reference. Future * finds for all tokens having this grantId value should be fulfilled with falsy values. * * @return {Promise} Promise fulfilled when the operation succeeded. Rejected with error when * encountered. * @param {string} grantId the grantId value associated with a this model's instance * */ 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); } } } } } // ----------------------------- // Route handler // ----------------------------- function renderInteractionPage(provider) { assert.strictEqual(typeof provider, 'object'); return async function (req, res) { const translationAssets = await translations.getTranslations(); try { const { uid, prompt, params, session } = await provider.interactionDetails(req, res); const client = await getClient(params.client_id); let app = null; if (client.appId) app = await apps.get(client.appId); switch (prompt.name) { case 'login': { const options = { SUBMIT_URL: `${ROUTE_PREFIX}/interaction/${uid}/login`, ICON_URL: '/api/v1/cloudron/avatar', NAME: client?.name || await branding.getCloudronName(), FOOTER: marked.parse(await branding.renderFooter()), NOTE: (client.id === tokens.ID_WEBADMIN && constants.DEMO) ? '