936 lines
36 KiB
JavaScript
936 lines
36 KiB
JavaScript
'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 = {
|
|
submitUrl: `${ROUTE_PREFIX}/interaction/${uid}/login`,
|
|
iconUrl: '/api/v1/cloudron/avatar',
|
|
name: client?.name || await branding.getCloudronName(),
|
|
footer: marked.parse(await branding.renderFooter()),
|
|
note: (client.id === tokens.ID_WEBADMIN && constants.DEMO) ? '<div style="text-align: center;">This is a demo. Username and password is "cloudron"</div>' : ''
|
|
};
|
|
|
|
if (app) {
|
|
options.name = app.label || app.fqdn;
|
|
options.iconUrl = app.iconUrl;
|
|
}
|
|
|
|
const template = fs.readFileSync(__dirname + '/oidc_templates/login.ejs', 'utf-8');
|
|
const html = ejs.render(translations.translate(template, translationAssets), options);
|
|
|
|
return res.send(html);
|
|
}
|
|
case 'consent': {
|
|
const options = {
|
|
hasAccess: false,
|
|
submitUrl: '',
|
|
iconUrl: '/api/v1/cloudron/avatar',
|
|
name: client?.name || '',
|
|
footer: marked.parse(await branding.renderFooter())
|
|
};
|
|
|
|
// check if user has access to the app if client refers to an app
|
|
if (app) {
|
|
const user = await users.get(session.accountId);
|
|
|
|
options.name = app.label || app.fqdn;
|
|
options.iconUrl = app.iconUrl;
|
|
options.hasAccess = apps.canAccess(app, user);
|
|
} else {
|
|
options.hasAccess = true;
|
|
}
|
|
|
|
options.submitUrl = `${ROUTE_PREFIX}/interaction/${uid}/${options.hasAccess ? 'confirm' : 'abort'}`;
|
|
|
|
return res.render('interaction', options);
|
|
}
|
|
default:
|
|
return undefined;
|
|
}
|
|
} catch (error) {
|
|
debug('route interaction get error', error);
|
|
return res.render('error', {
|
|
errorMessage: error.error_description || 'Internal error',
|
|
footer: marked.parse(await branding.renderFooter())
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
function interactionLogin(provider) {
|
|
assert.strictEqual(typeof provider, 'object');
|
|
|
|
return async function(req, res, next) {
|
|
const [detailsError, details] = await safe(provider.interactionDetails(req, res));
|
|
if (detailsError) {
|
|
if (detailsError.error_description === 'interaction session not found') return next(new HttpError(410, 'session timeout'));
|
|
return next(new HttpError(400, detailsError));
|
|
}
|
|
|
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
|
|
const userAgent = req.headers['user-agent'] || '';
|
|
const clientId = details.params.client_id;
|
|
|
|
debug(`interactionLogin: for OpenID client ${clientId} from ${ip}`);
|
|
|
|
// This is the auto login via token hack
|
|
if (req.body.autoLoginToken) {
|
|
if (typeof req.body.autoLoginToken !== 'string') return next(new HttpError(400, 'autoLoginToken must be string if provided'));
|
|
|
|
const token = await tokens.getByAccessToken(req.body.autoLoginToken);
|
|
if (!token) return next(new HttpError(401, 'No such token'));
|
|
|
|
const user = await users.get(token.identifier);
|
|
if (!user) return next(new HttpError(401,'User not found'));
|
|
if (!user.active) return next(new HttpError(401,'User not active'));
|
|
|
|
const result = {
|
|
login: {
|
|
accountId: user.id,
|
|
},
|
|
};
|
|
|
|
const [interactionFinishError, redirectTo] = await safe(provider.interactionResult(req, res, result));
|
|
if (interactionFinishError) return next(new HttpError(500, interactionFinishError));
|
|
|
|
const auditSource = AuditSource.fromOidcRequest(req);
|
|
await eventlog.add(user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, auditSource, { userId: user.id, user: users.removePrivateFields(user), appId: clientId });
|
|
await safe(users.notifyLoginLocation(user, ip, userAgent, auditSource), { debug });
|
|
|
|
// clear token as it is one-time use
|
|
await tokens.delByAccessToken(req.body.autoLoginToken);
|
|
|
|
return res.status(200).send({ redirectTo });
|
|
}
|
|
|
|
if (!req.body.username || typeof req.body.username !== 'string') return next(new HttpError(400, 'A username must be non-empty string'));
|
|
if (!req.body.password || typeof req.body.password !== 'string') return next(new HttpError(400, 'A password must be non-empty string'));
|
|
if ('totpToken' in req.body && typeof req.body.totpToken !== 'string') return next(new HttpError(400, 'totpToken must be a string' ));
|
|
|
|
const { username, password, totpToken } = req.body;
|
|
|
|
const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail;
|
|
|
|
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, skipTotpCheck: false }));
|
|
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message));
|
|
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match'));
|
|
if (verifyError) return next(new HttpError(500, verifyError));
|
|
if (!user) return next(new HttpError(401, 'Username and password does not match'));
|
|
|
|
// TODO we may have to check what else the Account class provides, in which case we have to map those things
|
|
const result = {
|
|
login: {
|
|
accountId: user.id,
|
|
},
|
|
};
|
|
|
|
const [interactionFinishError, redirectTo] = await safe(provider.interactionResult(req, res, result));
|
|
if (interactionFinishError) return next(new HttpError(500, interactionFinishError));
|
|
|
|
res.status(200).send({ redirectTo });
|
|
};
|
|
}
|
|
|
|
function interactionConfirm(provider) {
|
|
assert.strictEqual(typeof provider, 'object');
|
|
|
|
return async function (req, res, next) {
|
|
async function raiseLoginEvent(user, clientId) {
|
|
try {
|
|
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
|
|
const userAgent = req.headers['user-agent'] || '';
|
|
const auditSource = AuditSource.fromOidcRequest(req);
|
|
|
|
await eventlog.add(user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, auditSource, { userId: user.id, user: users.removePrivateFields(user), appId: clientId });
|
|
await users.notifyLoginLocation(user, ip, userAgent, auditSource);
|
|
} catch (e) {
|
|
console.error('oidc: Failed to raise login event.', e);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const interactionDetails = await provider.interactionDetails(req, res);
|
|
const { grantId, uid, prompt: { name, details }, params, session: { accountId } } = interactionDetails;
|
|
|
|
debug(`route interaction confirm post uid:${uid} prompt.name:${name} accountId:${accountId}`);
|
|
|
|
assert.equal(name, 'consent');
|
|
|
|
const client = await getClient(params.client_id);
|
|
const user = await users.get(accountId);
|
|
|
|
// Check if user has access to the app if client refers to an app
|
|
// In most cases the user interaction already ends in the consent screen (see above)
|
|
if (client.appId) {
|
|
const app = await apps.get(client.appId);
|
|
|
|
if (!apps.canAccess(app, user)) {
|
|
const result = {
|
|
error: 'access_denied',
|
|
error_description: 'User has no access to this app',
|
|
};
|
|
|
|
await raiseLoginEvent(user, client.appId);
|
|
|
|
return await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
|
|
}
|
|
}
|
|
|
|
let grant;
|
|
if (grantId) {
|
|
// we'll be modifying existing grant in existing session
|
|
grant = await provider.Grant.find(grantId);
|
|
} else {
|
|
// we're establishing a new grant
|
|
grant = new provider.Grant({
|
|
accountId,
|
|
clientId: params.client_id,
|
|
});
|
|
}
|
|
|
|
if (details.missingOIDCScope) {
|
|
grant.addOIDCScope(details.missingOIDCScope.join(' '));
|
|
}
|
|
if (details.missingOIDCClaims) {
|
|
grant.addOIDCClaims(details.missingOIDCClaims);
|
|
}
|
|
if (details.missingResourceScopes) {
|
|
for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
|
|
grant.addResourceScope(indicator, scopes.join(' '));
|
|
}
|
|
}
|
|
|
|
const savedGrantId = await grant.save();
|
|
|
|
const consent = {};
|
|
if (!interactionDetails.grantId) {
|
|
// we don't have to pass grantId to consent, we're just modifying existing one
|
|
consent.grantId = savedGrantId;
|
|
}
|
|
|
|
await raiseLoginEvent(user, params.client_id);
|
|
|
|
const result = { consent };
|
|
await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
};
|
|
}
|
|
|
|
function interactionAbort(provider) {
|
|
assert.strictEqual(typeof provider, 'object');
|
|
|
|
return async function (req, res, next) {
|
|
try {
|
|
const result = {
|
|
error: 'access_denied',
|
|
error_description: 'End-User aborted interaction',
|
|
};
|
|
await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param use - can either be "id_token" or "userinfo", depending on
|
|
* where the specific claims are intended to be put in.
|
|
* @param scope - the intended scope, while oidc-provider will mask
|
|
* claims depending on the scope automatically you might want to skip
|
|
* loading some claims from external resources etc. based on this detail
|
|
* or not return them in id tokens but only userinfo and so on.
|
|
*/
|
|
async function claims(userId/*, use, scope*/) {
|
|
const [error, user] = await safe(users.get(userId));
|
|
if (error) return { error: 'user not found' };
|
|
|
|
const [groupsError, allGroups] = await safe(groups.listWithMembers());
|
|
if (groupsError) return { error: groupsError.message };
|
|
|
|
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
|
|
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
|
|
|
|
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
|
|
|
|
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
|
const claims = {
|
|
sub: user.username, // it is essential to always return a sub claim
|
|
email: user.email,
|
|
email_verified: true,
|
|
family_name: lastName,
|
|
middle_name: middleName,
|
|
given_name: firstName,
|
|
locale: 'en-US',
|
|
name: user.displayName,
|
|
picture: `https://${dashboardFqdn}/api/v1/profile/avatar/${user.id}.png`, // some apps get surprised if we respond with a svg
|
|
preferred_username: user.username,
|
|
groups: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `${g.name}`; })
|
|
};
|
|
|
|
return claims;
|
|
}
|
|
|
|
async function findAccount(ctx, id) {
|
|
return {
|
|
accountId: id,
|
|
async claims(use, scope) { return await claims(id, use, scope); },
|
|
};
|
|
}
|
|
|
|
async function renderError(ctx, out, error) {
|
|
const data = {
|
|
errorMessage: error.error_description || error.error_detail || 'Unknown error',
|
|
footer: marked.parse(await branding.renderFooter())
|
|
};
|
|
|
|
debug('renderError: %o', error);
|
|
|
|
ctx.type = 'html';
|
|
ctx.body = ejs.render(fs.readFileSync(path.join(__dirname, 'oidc_templates/error.ejs'), 'utf8'), data, {});
|
|
}
|
|
|
|
async function start() {
|
|
assert(gHttpServer === null, 'OIDC aerver already started');
|
|
|
|
const app = express();
|
|
|
|
gHttpServer = http.createServer(app);
|
|
|
|
const Provider = (await import('oidc-provider')).default;
|
|
|
|
// TODO we may want to rotate those in the future
|
|
const jwksKeys = [];
|
|
|
|
let keyEdDsa = await blobs.getString(blobs.OIDC_KEY_EDDSA);
|
|
if (!keyEdDsa) {
|
|
debug('Generating new OIDC EdDSA key');
|
|
const { privateKey } = await jose.generateKeyPair('EdDSA');
|
|
keyEdDsa = await jose.exportJWK(privateKey);
|
|
await blobs.setString(blobs.OIDC_KEY_EDDSA, JSON.stringify(keyEdDsa));
|
|
jwksKeys.push(keyEdDsa);
|
|
} else {
|
|
debug('Using existing OIDC EdDSA key');
|
|
jwksKeys.push(JSON.parse(keyEdDsa));
|
|
}
|
|
|
|
let keyRs256 = await blobs.getString(blobs.OIDC_KEY_RS256);
|
|
if (!keyRs256) {
|
|
debug('Generating new OIDC RS256 key');
|
|
const { privateKey } = await jose.generateKeyPair('RS256');
|
|
keyRs256 = await jose.exportJWK(privateKey);
|
|
await blobs.setString(blobs.OIDC_KEY_RS256, JSON.stringify(keyRs256));
|
|
jwksKeys.push(keyRs256);
|
|
} else {
|
|
debug('Using existing OIDC RS256 key');
|
|
jwksKeys.push(JSON.parse(keyRs256));
|
|
}
|
|
|
|
let cookieSecret = await settings.get(settings.OIDC_COOKIE_SECRET_KEY);
|
|
if (!cookieSecret) {
|
|
debug('Generating new cookie secret');
|
|
cookieSecret = crypto.randomBytes(256).toString('base64');
|
|
await settings.set(settings.OIDC_COOKIE_SECRET_KEY, cookieSecret);
|
|
}
|
|
|
|
const configuration = {
|
|
findAccount,
|
|
renderError,
|
|
adapter: CloudronAdapter,
|
|
interactions: {
|
|
url: async function(ctx, interaction) {
|
|
return `${ROUTE_PREFIX}/interaction/${interaction.uid}`;
|
|
}
|
|
},
|
|
jwks: {
|
|
jwksKeys
|
|
},
|
|
claims: {
|
|
email: ['email', 'email_verified'],
|
|
profile: [ 'family_name', 'given_name', 'locale', 'name', 'preferred_username', 'picture' ],
|
|
groups: [ 'groups' ]
|
|
},
|
|
features: {
|
|
rpInitiatedLogout: { enabled: false },
|
|
devInteractions: { enabled: false }
|
|
},
|
|
clientDefaults: {
|
|
response_types: ['code', 'id_token'],
|
|
grant_types: ['authorization_code', 'implicit', 'refresh_token']
|
|
},
|
|
responseTypes: [
|
|
'code',
|
|
'id_token', 'id_token token',
|
|
'code id_token', 'code token', 'code id_token token',
|
|
'none',
|
|
],
|
|
// if a client only has one redirect uri specified, the client does not have to provide it in the request
|
|
allowOmittingSingleRegisteredRedirectUri: true,
|
|
clients: [],
|
|
cookies: {
|
|
keys: [ cookieSecret ]
|
|
},
|
|
pkce: {
|
|
required: function pkceRequired(/*ctx, client*/) {
|
|
return false;
|
|
}
|
|
},
|
|
clientBasedCORS(ctx, origin, client) {
|
|
// allow CORS for clients where at least the origin matches where we redirect back to
|
|
if (client.redirectUris.find((u) => u.indexOf(origin) === 0)) return true;
|
|
|
|
return false;
|
|
},
|
|
conformIdTokenClaims: false,
|
|
// https://github.com/panva/node-oidc-provider/blob/main/recipes/skip_consent.md
|
|
loadExistingGrant: async function (ctx) {
|
|
const grantId = ctx.oidc.result?.consent?.grantId
|
|
|| ctx.oidc.session.grantIdFor(ctx.oidc.client.clientId);
|
|
|
|
if (grantId) {
|
|
return await ctx.oidc.provider.Grant.find(grantId);
|
|
} else if (ctx.oidc.client.clientId === tokens.ID_WEBADMIN || ctx.oidc.client.clientId === tokens.ID_DEVELOPMENT) {
|
|
const grant = new ctx.oidc.provider.Grant({
|
|
clientId: ctx.oidc.client.clientId,
|
|
accountId: ctx.oidc.session.accountId,
|
|
});
|
|
|
|
grant.addOIDCScope('openid email profile groups');
|
|
// grant.addOIDCClaims(['first_name']);
|
|
await grant.save();
|
|
|
|
return grant;
|
|
}
|
|
},
|
|
// https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#issuerefreshtoken
|
|
async issueRefreshToken(ctx, client, code) {
|
|
if (!client.grantTypeAllowed('refresh_token') && !client.grantTypeAllowed('authorization_code')) {
|
|
return false;
|
|
}
|
|
return code.scopes.has('offline_access') || (client.applicationType === 'native' && client.clientAuthMethod === 'client_secret_basic');
|
|
},
|
|
ttl: {
|
|
// in seconds, can also be a function returning the seconds https://github.com/panva/node-oidc-provider/blob/b1c1a9318036c2d3793cc9e668f99937c5c36bc6/docs/README.md#ttl
|
|
AccessToken: 3600, // 1 hour
|
|
IdToken: 3600, // 1 hour
|
|
Grant: 1209600, // 14 days
|
|
Session: 1209600, // 14 days
|
|
Interaction: 3600, // 1 hour
|
|
RefreshToken: 1209600 // 14 days
|
|
}
|
|
};
|
|
|
|
const { subdomain, domain } = await dashboard.getLocation();
|
|
const fqdn = dns.fqdn(subdomain, domain);
|
|
debug(`start: create provider for ${fqdn} at ${ROUTE_PREFIX}`);
|
|
const provider = new Provider(`https://${fqdn}${ROUTE_PREFIX}`, configuration);
|
|
|
|
app.enable('trust proxy');
|
|
provider.proxy = true;
|
|
|
|
app.set('views', path.join(__dirname, 'oidc_templates'));
|
|
app.set('view engine', 'ejs');
|
|
|
|
const json = express.json({ strict: true, limit: '2mb' });
|
|
function setNoCache(req, res, next) {
|
|
res.set('cache-control', 'no-store');
|
|
next();
|
|
}
|
|
|
|
app.get (`${ROUTE_PREFIX}/interaction/:uid`, setNoCache, renderInteractionPage(provider));
|
|
app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin(provider));
|
|
app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm(provider));
|
|
app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort(provider));
|
|
|
|
app.use(ROUTE_PREFIX, provider.callback());
|
|
app.use(middleware.lastMile());
|
|
|
|
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.OIDC_PORT, '127.0.0.1');
|
|
}
|
|
|
|
async function stop() {
|
|
if (!gHttpServer) return;
|
|
|
|
await util.promisify(gHttpServer.close.bind(gHttpServer))();
|
|
gHttpServer = null;
|
|
}
|