diff --git a/src/oidc.js b/src/oidc.js
index f43481304..db142a245 100644
--- a/src/oidc.js
+++ b/src/oidc.js
@@ -52,7 +52,7 @@ const OIDC_CLIENTS_FIELDS = [ 'id', 'secret', 'name', 'appId', 'loginRedirectUri
const ROUTE_PREFIX = '/openid';
const DEFAULT_TOKEN_SIGNATURE_ALGORITHM='RS256';
-let gHttpServer = null;
+let gHttpServer = null, gOidcProvider = null;
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
@@ -391,274 +391,250 @@ class CloudronAdapter {
}
}
-function renderInteractionPage(provider) {
- assert.strictEqual(typeof provider, 'object');
+async function renderInteractionPage(req, res) {
+ try {
+ const { uid, prompt, params, session } = await gOidcProvider.interactionDetails(req, res);
- return async function (req, res) {
- try {
- const { uid, prompt, params, session } = await provider.interactionDetails(req, res);
+ const client = await getClient(params.client_id);
- const client = await getClient(params.client_id);
+ let app = null;
+ if (client.appId) app = await apps.get(client.appId);
- 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) ? '
This is a demo. Username and password is "cloudron"
' : ''
+ };
- 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) ? 'This is a demo. Username and password is "cloudron"
' : ''
- };
-
- if (app) {
- options.NAME = app.label || app.fqdn;
- options.ICON_URL = app.iconUrl;
- }
-
- // great ejs replacement!
- let html = fs.readFileSync(__dirname + '/../dashboard/dist/login.html', 'utf-8');
- Object.keys(options).forEach(key => {
- html = html.replaceAll(`##${key}##`, options[key]);
- });
-
- return res.send(html);
+ if (app) {
+ options.NAME = app.label || app.fqdn;
+ options.ICON_URL = app.iconUrl;
}
- case 'consent': {
- let hasAccess = false;
- const data = {
- ICON_URL: '/api/v1/cloudron/avatar',
- NAME: client?.name || '',
- FOOTER: marked.parse(await branding.renderFooter())
- };
+ // great ejs replacement!
+ let html = fs.readFileSync(__dirname + '/../dashboard/dist/login.html', 'utf-8');
+ Object.keys(options).forEach(key => {
+ html = html.replaceAll(`##${key}##`, options[key]);
+ });
- // check if user has access to the app if client refers to an app
- if (app) {
- const user = await users.get(session.accountId);
-
- data.NAME = app.label || app.fqdn;
- data.ICON_URL = app.iconUrl;
- hasAccess = apps.canAccess(app, user);
- } else {
- hasAccess = true;
- }
-
- data.SUBMIT_URL = `${ROUTE_PREFIX}/interaction/${uid}/${hasAccess ? 'confirm' : 'abort'}`;
-
- let html = fs.readFileSync(path.join(__dirname, hasAccess ? '/../dashboard/dist/oidc_interaction_confirm.html' : '/../dashboard/dist/oidc_interaction_abort.html'), 'utf8');
- Object.keys(data).forEach(key => {
- html = html.replaceAll(`##${key}##`, data[key]);
- });
-
- return res.send(html);
- }
- default:
- return undefined;
- }
- } catch (error) {
- debug('route interaction get error', error);
+ return res.send(html);
+ }
+ case 'consent': {
+ let hasAccess = false;
const data = {
ICON_URL: '/api/v1/cloudron/avatar',
- NAME: 'Cloudron',
- ERROR_MESSAGE: error.error_description || 'Internal error',
+ NAME: client?.name || '',
FOOTER: marked.parse(await branding.renderFooter())
};
- let html = fs.readFileSync(path.join(__dirname, '/../dashboard/dist/oidc_error.html'), 'utf8');
+ // check if user has access to the app if client refers to an app
+ if (app) {
+ const user = await users.get(session.accountId);
+
+ data.NAME = app.label || app.fqdn;
+ data.ICON_URL = app.iconUrl;
+ hasAccess = apps.canAccess(app, user);
+ } else {
+ hasAccess = true;
+ }
+
+ data.SUBMIT_URL = `${ROUTE_PREFIX}/interaction/${uid}/${hasAccess ? 'confirm' : 'abort'}`;
+
+ let html = fs.readFileSync(path.join(__dirname, hasAccess ? '/../dashboard/dist/oidc_interaction_confirm.html' : '/../dashboard/dist/oidc_interaction_abort.html'), 'utf8');
Object.keys(data).forEach(key => {
html = html.replaceAll(`##${key}##`, data[key]);
});
- res.set('Content-Type', 'text/html');
return res.send(html);
}
- };
+ default:
+ return undefined;
+ }
+ } catch (error) {
+ debug('route interaction get error', error);
+
+ const data = {
+ ICON_URL: '/api/v1/cloudron/avatar',
+ NAME: 'Cloudron',
+ ERROR_MESSAGE: error.error_description || 'Internal error',
+ FOOTER: marked.parse(await branding.renderFooter())
+ };
+
+ let html = fs.readFileSync(path.join(__dirname, '/../dashboard/dist/oidc_error.html'), 'utf8');
+ Object.keys(data).forEach(key => {
+ html = html.replaceAll(`##${key}##`, data[key]);
+ });
+
+ res.set('Content-Type', 'text/html');
+ return res.send(html);
+ }
}
-function interactionLogin(provider) {
- assert.strictEqual(typeof provider, 'object');
+async function interactionLogin(req, res, next) {
+ const [detailsError, details] = await safe(gOidcProvider.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));
+ }
- 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;
- 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}`);
- 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'));
- // 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 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 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));
+ const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, result));
if (interactionFinishError) return next(new HttpError(500, interactionFinishError));
- res.status(200).send({ redirectTo });
+ 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(gOidcProvider.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);
- }
- }
-
+async function interactionConfirm(req, res, next) {
+ async function raiseLoginEvent(user, clientId) {
try {
- const interactionDetails = await provider.interactionDetails(req, res);
- const { grantId, uid, prompt: { name, details }, params, session: { accountId } } = interactionDetails;
+ const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
+ const userAgent = req.headers['user-agent'] || '';
+ const auditSource = AuditSource.fromOidcRequest(req);
- 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) {
- grant = await provider.Grant.find(grantId);
- } else {
- 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) consent.grantId = savedGrantId;
-
- await raiseLoginEvent(user, params.client_id);
-
- const result = { consent };
- await provider.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
- } catch (err) {
- next(err);
+ 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 gOidcProvider.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 gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
+ }
+ }
+
+ let grant;
+ if (grantId) {
+ grant = await gOidcProvider.Grant.find(grantId);
+ } else {
+ grant = new gOidcProvider.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) consent.grantId = savedGrantId;
+
+ await raiseLoginEvent(user, params.client_id);
+
+ const result = { consent };
+ await gOidcProvider.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);
- }
- };
+async function interactionAbort(req, res, next) {
+ try {
+ const result = {
+ error: 'access_denied',
+ error_description: 'End-User aborted interaction',
+ };
+ await gOidcProvider.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' };
@@ -715,15 +691,13 @@ async function renderError(ctx, out, error) {
}
async function start() {
- assert(gHttpServer === null, 'OIDC aerver already started');
+ assert(gHttpServer === null, 'OIDC server already started');
+ assert(gOidcProvider === null, 'OIDC provider 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);
@@ -846,10 +820,12 @@ async function start() {
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);
+
+ const Provider = (await import('oidc-provider')).default;
+ gOidcProvider = new Provider(`https://${fqdn}${ROUTE_PREFIX}`, configuration);
app.enable('trust proxy');
- provider.proxy = true;
+ gOidcProvider.proxy = true;
const json = express.json({ strict: true, limit: '2mb' });
function setNoCache(req, res, next) {
@@ -857,12 +833,12 @@ async function start() {
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.get (`${ROUTE_PREFIX}/interaction/:uid`, setNoCache, renderInteractionPage);
+ app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin);
+ app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm);
+ app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort);
- app.use(ROUTE_PREFIX, provider.callback());
+ app.use(ROUTE_PREFIX, gOidcProvider.callback());
app.use(middleware.lastMile());
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.OIDC_PORT, '127.0.0.1');
@@ -873,4 +849,5 @@ async function stop() {
await util.promisify(gHttpServer.close.bind(gHttpServer))();
gHttpServer = null;
+ gOidcProvider = null;
}