'use strict'; exports = module.exports = { start, stop, revokeByUserId, getUserByAuthCode, consumeAuthCode, addClient, getClient, delClient, updateClient, listClients, cleanupExpired, }; 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'), 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'), 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, gOidcProvider = null; 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'); const types = [ 'Session', 'Grant', 'AuthorizationCode', 'AccessToken' ]; for (const type of types) { load(type); for (const id in DATA_STORE[type]) { if (DATA_STORE[type][id].payload?.accountId === userId) delete DATA_STORE[type][id]; } save(type); } } 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); } // This exposed to run on a cron job async function cleanupExpired() { debug('cleanupExpired'); const types = [ 'AuthorizationCode', 'AccessToken', 'Grant', 'Interaction', 'RefreshToken', 'Session' ]; for (const type of types) { load(type); for (const key in DATA_STORE[type]) { if (!DATA_STORE[type][key].expiresAt || DATA_STORE[type][key].expiresAt < Date.now()) delete DATA_STORE[type][key]; } save(type); } } // Generic oidc node module data store model . It will be instantiated many times (AccessToken, Grant, Interaction etc) class StorageAdapter { constructor(name) { this.name = name; debug(`Creating OpenID storage adapter for ${name}`); if (this.name === 'Client') { return; } else { load(name); } } async upsert(id, payload, expiresIn) { debug(`[${this.name}] upsert: ${id}`); const expiresAt = expiresIn ? new Date(Date.now() + (expiresIn * 1000)) : 0; 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 expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS; const [error] = await safe(tokens.add({ clientId: payload.clientId, identifier: payload.accountId, expires, accessToken: id, allowedIpRanges: '' })); if (error) { console.log('Error adding access token', error); throw error; } } else { DATA_STORE[this.name][id] = { id, expiresAt, payload, consumed: false }; save(this.name); } } 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 (this.name === 'Session') { const session = DATA_STORE[this.name][id]; if (!session) return null; if (session.payload.accountId) { // check if the session user still exists and is active const user = await users.get(session.payload.accountId); if (!user || !user.active) return null; } return session.payload; } else { if (!DATA_STORE[this.name][id]) return null; return DATA_STORE[this.name][id].payload; } } async findByUserCode(userCode) { debug(`[${this.name}] FIXME findByUserCode userCode:${userCode}`); } 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; } } 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); } } 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); } } 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); } } } } } async function renderInteractionPage(req, res) { try { const { uid, prompt, params, session } = await gOidcProvider.interactionDetails(req, res); const client = await getClient(params.client_id); let app = null; if (client.appId) app = await apps.get(client.appId); switch (prompt.name) { case 'login': { const options = { SUBMIT_URL: `${ROUTE_PREFIX}/interaction/${uid}/login`, ICON_URL: '/api/v1/cloudron/avatar', NAME: client?.name || await branding.getCloudronName(), FOOTER: marked.parse(await branding.renderFooter()), NOTE: (client.id === tokens.ID_WEBADMIN && constants.DEMO) ? '
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); } case 'consent': { let hasAccess = false; const data = { ICON_URL: '/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); 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); 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); } } 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)); } 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(gOidcProvider.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(gOidcProvider.interactionResult(req, res, result)); if (interactionFinishError) return next(new HttpError(500, interactionFinishError)); res.status(200).send({ redirectTo }); } async function interactionConfirm(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 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); } } 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); } } async function getClaims(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 renderError(ctx, out, error) { const data = { ICON_URL: '/api/v1/cloudron/avatar', NAME: 'Cloudron', ERROR_MESSAGE: error.error_description || error.error_detail || 'Unknown error', FOOTER: marked.parse(await branding.renderFooter()) }; debug('renderError: %o', error); let html = fs.readFileSync(path.join(__dirname, '/../dashboard/dist/oidc_error.html'), 'utf8'); Object.keys(data).forEach(key => { html = html.replaceAll(`##${key}##`, data[key]); }); ctx.type = 'html'; ctx.body = html; } async function start() { assert(gHttpServer === null, 'OIDC server already started'); assert(gOidcProvider === null, 'OIDC provider already started'); const app = express(); gHttpServer = http.createServer(app); 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', { extractable: true }); 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', { extractable: true }); 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: async function(ctx, id) { return { accountId: id, claims: async (use, scope) => await getClaims(id, use, scope) }; }, renderError, adapter: StorageAdapter, interactions: { url: async function(ctx, interaction) { return `${ROUTE_PREFIX}/interaction/${interaction.uid}`; } }, jwks: { keys: 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'); 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 = (await import('oidc-provider')).default; gOidcProvider = new Provider(`https://${fqdn}${ROUTE_PREFIX}`, configuration); app.enable('trust proxy'); gOidcProvider.proxy = true; 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); 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, gOidcProvider.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; gOidcProvider = null; }