Currently, we only have one field for the name. The first part is first name. The rest is last name. Obviously, this won't work in all cases but is the best we can do for the moment.
836 lines
31 KiB
JavaScript
836 lines
31 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
start,
|
|
stop,
|
|
revokeByUserId,
|
|
clients: {
|
|
add: clientsAdd,
|
|
get: clientsGet,
|
|
del: clientsDel,
|
|
update: clientsUpdate,
|
|
list: clientsList
|
|
}
|
|
};
|
|
|
|
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'),
|
|
translation = require('./translation.js'),
|
|
url = require('url'),
|
|
users = require('./users.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 clientsAdd(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 clientsGet(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 clientsUpdate(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 clientsDel(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 clientsList() {
|
|
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}, start with new one.`);
|
|
else 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`);
|
|
// debug(`save: model ${modelName} to ${filePath}.`);
|
|
|
|
try {
|
|
fs.writeFileSync(filePath, JSON.stringify(DATA_STORE[modelName], null, 2), 'utf8');
|
|
} catch (e) {
|
|
debug(`save: 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 (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
|
|
// -----------------------------
|
|
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) {
|
|
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) {
|
|
if (this.name === 'Client') {
|
|
const [error, client] = await safe(clientsGet(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;
|
|
}
|
|
|
|
// 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()).map(s => url.parse(s).protocol ? s : `https://${app.fqdn}${s}`);
|
|
} else {
|
|
tmp.redirect_uris = client.loginRedirectUri.split(',').map(s => s.trim());
|
|
}
|
|
|
|
return tmp;
|
|
} else if (this.name === 'AccessToken') {
|
|
debug('find: we dont support finding AccessTokens', id);
|
|
const [error, result] = await safe(tokens.getByAccessToken(id));
|
|
if (error || !result) {
|
|
debug(`find: Unknown accessToken for id ${id} 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) {
|
|
if (this.name === 'Client' || this.name === 'AccessToken') {
|
|
debug('findByUid: this should not happen as it is stored in our db');
|
|
} else {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* 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 (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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------
|
|
// Route handler
|
|
// -----------------------------
|
|
function renderInteractionPage(provider) {
|
|
assert.strictEqual(typeof provider, 'object');
|
|
|
|
return async function (req, res) {
|
|
const translationAssets = await translation.getTranslations();
|
|
|
|
try {
|
|
const { uid, prompt, params, session } = await provider.interactionDetails(req, res);
|
|
|
|
const client = await clientsGet(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(translation.translate(template, translationAssets.translations || {}, translationAssets.fallback || {}), 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) return next(new HttpError(500, detailsError));
|
|
|
|
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress || null;
|
|
const userAgent = req.headers['user-agent'] || '';
|
|
const clientId = details.params.client_id;
|
|
|
|
debug(`interactionLogin: for OpenID client ${clientId} from ${ip}`);
|
|
|
|
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));
|
|
|
|
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 });
|
|
if (!user.ghost) safe(users.notifyLoginLocation(user, ip, userAgent, auditSource), { debug });
|
|
|
|
// debug(`route interaction login post result redirectTo:${redirectTo}`);
|
|
|
|
res.status(200).send({ redirectTo });
|
|
};
|
|
}
|
|
|
|
function interactionConfirm(provider) {
|
|
assert.strictEqual(typeof provider, 'object');
|
|
|
|
return async function (req, res, next) {
|
|
try {
|
|
const interactionDetails = await provider.interactionDetails(req, res);
|
|
let { 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 clientsGet(params.client_id);
|
|
|
|
// 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);
|
|
const user = await users.get(accountId);
|
|
|
|
if (!apps.canAccess(app, user)) {
|
|
const result = {
|
|
error: 'access_denied',
|
|
error_description: 'User has no access to this app',
|
|
};
|
|
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) {
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
|
|
grant.addResourceScope(indicator, scopes.join(' '));
|
|
}
|
|
}
|
|
|
|
grantId = 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 = grantId;
|
|
}
|
|
|
|
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 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}`,
|
|
preferred_username: user.username
|
|
};
|
|
|
|
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, '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' ]
|
|
},
|
|
features: {
|
|
rpInitiatedLogout: { enabled: false },
|
|
devInteractions: { enabled: false }
|
|
},
|
|
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;
|
|
}
|
|
},
|
|
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');
|
|
// grant.addOIDCClaims(['first_name']);
|
|
await grant.save();
|
|
|
|
return grant;
|
|
}
|
|
},
|
|
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
|
|
}
|
|
};
|
|
|
|
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 = middleware.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;
|
|
}
|