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; }