import assert from 'node:assert'; import apps from './apps.js'; import AuditSource from './auditsource.js'; import BoxError from './boxerror.js'; import blobs from './blobs.js'; import branding from './branding.js'; import constants from './constants.js'; import crypto from 'node:crypto'; import dashboard from './dashboard.js'; import logger from './logger.js'; import dns from './dns.js'; import ejs from 'ejs'; import express from 'express'; import eventlog from './eventlog.js'; import fs from 'node:fs'; import mail from './mail.js'; import * as marked from 'marked'; import middleware from './middleware/index.js'; import oidcClients from './oidcclients.js'; import passkeys from './passkeys.js'; import path from 'node:path'; import paths from './paths.js'; import http from 'node:http'; import { HttpError } from '@cloudron/connect-lastmile'; import * as jose from 'jose'; import safe from 'safetydance'; import settings from './settings.js'; import superagent from '@cloudron/superagent'; import tokens from './tokens.js'; import users from './users.js'; import groups from './groups.js'; import util from 'node:util'; import Provider from 'oidc-provider'; import mailpasswords from './mailpasswords.js'; const { log, trace } = logger('oidcserver'); // 1. Index.vue starts the OIDC flow by navigating to /openid/auth. Webadmin uses authorization code flow with PKCE // 2. oidcserver starts an interaction and redirects to oidc_login.html // 3. oidc_login.html is rendered by renderInteractionPage() with the form submit url /interaction/:uid/login // 4. When form is submitted, it invokes interactionLogin(). This validates user creds // 5. We enter the scopes confirmation flow which is oidc_interaction_confirm.html rendered by renderInteractionPage() // 6. We have no concept of confirmation. The page auto-submits the form immediately without user interaction // 7. oidcserver calls interactionConfirm() which finishes it via interactionFinished(). // 8. authcallback.html exchanges the authorization code for an access token via POST to /openid/token with code_verifier const ROUTE_PREFIX = '/openid'; let gHttpServer = null, gOidcProvider = null; // Client data store is part of the database, so it's not saved in files // https://github.com/panva/node-oidc-provider/blob/183dc4f4b1ec1a53c5254d809091737a95c31f14/example/my_adapter.js class StorageAdapter { static #database = {}; // indexed by name. The format of entry is { id, expiresAt, payload, consumed } static async getData(name) { if (name === 'Client') throw new Error(`${name} is a database model`); if (StorageAdapter.#database[name]) return StorageAdapter.#database[name]; StorageAdapter.#database[name] = {}; // init with empty table const filePath = path.join(paths.OIDC_STORE_DIR, `${name}.json`); const [error, data] = await safe(fs.promises.readFile(filePath, 'utf8')); if (!error) StorageAdapter.#database[name] = safe.JSON.parse(data) || {}; // reset table if file corrupt return StorageAdapter.#database[name]; } static async saveData(name) { if (name === 'Client') throw new Error(`${name} is a database model`); const filePath = path.join(paths.OIDC_STORE_DIR, `${name}.json`); await fs.promises.writeFile(filePath, JSON.stringify(StorageAdapter.#database[name], null, 2), 'utf8'); } static async updateData(name, action) { const data = await StorageAdapter.getData(name); await action(data); await StorageAdapter.saveData(name); } constructor(name) { trace(`Creating OpenID storage adapter for ${name}`); this.name = name; } async upsert(id, payload, expiresIn) { trace(`[${this.name}] upsert: ${id}`); const expiresAt = expiresIn ? new Date(Date.now() + (expiresIn * 1000)) : 0; // only AccessToken of webadmin are stored in the db. Dashboard uses REST API and the token middleware looks up tokens in db if (this.name === 'AccessToken' && (payload.clientId === oidcClients.ID_WEBADMIN || payload.clientId === oidcClients.ID_DEVELOPMENT || payload.clientId === oidcClients.ID_CLI)) { const expires = Date.now() + constants.DEFAULT_TOKEN_EXPIRATION_MSECS; // oidc uses the username as accountId but accesstoken identifiers are userIds const user = await users.getByUsername(payload.accountId); if (!user) throw new Error(`user for username ${payload.accountId} not found`); const [error] = await safe(tokens.add({ clientId: payload.clientId, identifier: user.id, expires, accessToken: id, allowedIpRanges: '' })); if (error) { log('Error adding access token', error); throw error; } } else { await StorageAdapter.updateData(this.name, (data) => data[id] = { id, expiresAt, payload, consumed: false }); } } async find(id) { trace(`[${this.name}] find: ${id}`); if (this.name === 'Client') { const [error, client] = await safe(oidcClients.get(id)); if (error || !client) { log('find: error getting client', error); 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.token_endpoint_auth_method) tmp.token_endpoint_auth_method = client.token_endpoint_auth_method; if (client.appId) { const [appError, app] = await safe(apps.get(client.appId)); if (appError || !app) { log(`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.canParse(s)) 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') { // dashboard AccessToken are in the db. the app tokens are in the json files const [error, result] = await safe(tokens.getByAccessToken(id)); if (!error && result) { // translate from userId in the token to username for oidc const user = await users.get(result.identifier); if (user) { return { accountId: user.username, clientId: result.clientId }; } } } else if (this.name === 'Session') { const data = await StorageAdapter.getData(this.name); const session = data[id]; if (!session) return null; if (session.payload.accountId) { // check if the session user still exists and is active const user = await users.getByUsername(session.payload.accountId); if (!user || !user.active) return null; } return session.payload; } const data = await StorageAdapter.getData(this.name); if (!data[id]) return null; return data[id].payload; } async findByUserCode(userCode) { trace(`[${this.name}] findByUserCode userCode:${userCode}`); const data = await StorageAdapter.getData(this.name); for (const id in data) { if (data[id].payload.userCode === userCode) return data[id].payload; } return undefined; } // this is called only on Session store. there is a payload.uid async findByUid(uid) { trace(`[${this.name}] findByUid: ${uid}`); const data = await StorageAdapter.getData(this.name); for (const d in data) { if (data[d].payload.uid === uid) return data[d].payload; } return null; } async consume(id) { trace(`[${this.name}] consume: ${id}`); await StorageAdapter.updateData(this.name, (data) => data[id].consumed = true); } async destroy(id) { trace(`[${this.name}] destroy: ${id}`); await StorageAdapter.updateData(this.name, (data) => delete data[id]); } async revokeByGrantId(grantId) { trace(`[${this.name}] revokeByGrantId: ${grantId}`); await StorageAdapter.updateData(this.name, (data) => { for (const d in data) { if (data[d].grantId === grantId) { delete data[d]; return; } } }); } } // Session, Grant and Token management. This is based on the same storage as the below CloudronAdapter async function revokeByUsername(username) { assert.strictEqual(typeof username, 'string'); const types = [ 'Session', 'Grant', 'AuthorizationCode', 'AccessToken' ]; for (const type of types) { await StorageAdapter.updateData(type, (data) => { for (const id in data) { if (data[id].payload?.accountId === username) delete data[id]; } }); } } // used by proxyauth logic to authenticate using a one time code async function consumeAuthCode(authCode) { assert.strictEqual(typeof authCode, 'string'); let username = null; await StorageAdapter.updateData('AuthorizationCode', (data) => { const authData = data[authCode]; if (authData) { username = authData.payload.accountId; authData.consumed = true; } }); return username; } // This exposed to run on a cron job async function cleanupExpired() { log('cleanupExpired'); const types = [ 'AuthorizationCode', 'AccessToken', 'DeviceCode', 'Grant', 'Interaction', 'RefreshToken', 'Session' ]; for (const type of types) { await StorageAdapter.updateData(type, (data) => { for (const key in data) { if (!data[key].expiresAt || data[key].expiresAt < Date.now()) delete data[key]; } }); } } const TEMPLATE_DEVICE_INPUT = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_device_input.html'), 'utf-8'); const TEMPLATE_DEVICE_CONFIRM = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_device_confirm.html'), 'utf-8'); const TEMPLATE_DEVICE_SUCCESS = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_device_success.html'), 'utf-8'); const TEMPLATE_LOGIN = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_login.html'), 'utf-8'); const TEMPLATE_INTERACTION_CONFIRM = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_interaction_confirm.html'), 'utf8'); const TEMPLATE_INTERACTION_ABORT = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_interaction_abort.html'), 'utf8'); const TEMPLATE_ERROR = fs.readFileSync(path.join(paths.DASHBOARD_DIR, 'oidc_error.html'), 'utf8'); async function renderError(error) { const data = { iconUrl: '/api/v1/cloudron/avatar', name: 'Cloudron', errorMessage: error.error_description || error.error_detail || error.message || 'Internal error', footer: marked.parse(await branding.renderFooter()), language: await settings.get(settings.LANGUAGE_KEY), }; trace('renderError: %o', error); return ejs.render(TEMPLATE_ERROR, data); } async function renderInteractionPage(req, res) { const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res)); if (detailsError) return res.send(await renderError(new Error('Invalid session'))); const { uid, prompt, params, session } = details; const client = await oidcClients.get(params.client_id); if (!client) return res.send(await renderError(new Error('Client not found'))); const app = client.appId ? await apps.get(client.appId) : null; if (client.appId && !app) return res.send(await renderError(new Error('App not found'))); res.set('Content-Type', 'text/html'); if (prompt.name === 'login') { const data = { submitUrl: `${ROUTE_PREFIX}/interaction/${uid}/login`, iconUrl: '/api/v1/cloudron/avatar', name: client.name || await branding.getCloudronName(), footer: marked.parse(await branding.renderFooter()), note: constants.DEMO ? `This is a demo. Username and password is "${constants.DEMO_USERNAME}"` : '', language: await settings.get(settings.LANGUAGE_KEY), }; if (app) { data.name = app.label || app.subdomain || app.fqdn; data.iconUrl = app.iconUrl; } return res.send(ejs.render(TEMPLATE_LOGIN, data)); } else if (prompt.name === 'consent') { let hasAccess; const data = { iconUrl: '/api/v1/cloudron/avatar', name: client.name || '', footer: marked.parse(await branding.renderFooter()), language: await settings.get(settings.LANGUAGE_KEY), }; // check if user has access to the app if client refers to an app if (app) { const user = await users.getByUsername(session.accountId); data.name = app.label || app.fqdn; data.iconUrl = app.iconUrl; hasAccess = apps.canAccess(app, user); } else { hasAccess = true; } data.submitUrl = `${ROUTE_PREFIX}/interaction/${uid}/${hasAccess ? 'confirm' : 'abort'}`; return res.send(ejs.render(hasAccess ? TEMPLATE_INTERACTION_CONFIRM : TEMPLATE_INTERACTION_ABORT, data)); } } async function interactionLogin(req, res, next) { const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res)); if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description)); const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null; const clientId = details.params.client_id; trace(`interactionLogin: for OpenID client ${clientId} from ${ip}`); if (req.body.autoLoginToken) { // auto login for first admin/owner 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')); const result = { login: { accountId: user.username, }, }; const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, result)); if (interactionFinishError) return next(new HttpError(500, interactionFinishError)); await tokens.delByAccessToken(req.body.autoLoginToken); // clear token as it is one-time use 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')); if ('passkeyResponse' in req.body && typeof req.body.passkeyResponse !== 'object') return next(new HttpError(400, 'passkeyResponse must be an object')); const { username, password, totpToken, passkeyResponse } = req.body; const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail; // First verify password, skip 2FA check initially to determine what 2FA methods are available const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, passkeyResponse, skipTotpCheck: !totpToken && !passkeyResponse })); // Handle passkey verification if provided if (!verifyError && user && !user.ghost && passkeyResponse && !totpToken) { const userPasskeys = await passkeys.listByUserId(user.id); if (userPasskeys.length > 0) { const [passkeyError] = await safe(passkeys.verifyAuthentication(user, passkeyResponse)); if (passkeyError) { trace(`interactionLogin: passkey verification failed for ${username}: ${passkeyError.message}`); return next(new HttpError(401, 'Invalid passkey')); } trace(`interactionLogin: passkey verified for ${username}`); } } // If password verified but 2FA is required and not provided, return challenge if (!verifyError && user && !user.ghost && !totpToken && !passkeyResponse) { const userPasskeys = await passkeys.listByUserId(user.id); const has2FA = user.twoFactorAuthenticationEnabled || userPasskeys.length > 0; if (has2FA) { // Generate passkey options if user has passkeys let passkeyOptions = null; if (userPasskeys.length > 0) { const [optionsError, options] = await safe(passkeys.getAuthenticationOptions(user)); if (!optionsError) passkeyOptions = options; } return res.status(200).send({ twoFactorRequired: true, totpRequired: user.twoFactorAuthenticationEnabled, passkeyOptions }); } } 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')); // this is saved as part of interaction.lastSubmission const result = { login: { accountId: user.username, }, ghost: !!user.ghost }; 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) { const [detailsError, interactionDetails] = await safe(gOidcProvider.interactionDetails(req, res)); if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description)); const { grantId, uid, prompt: { name, details }, params, session: { accountId }, lastSubmission } = interactionDetails; trace(`route interaction confirm post uid:${uid} prompt.name:${name} accountId:${accountId}`); const client = await oidcClients.get(params.client_id); if (!client) return next(new Error('Client not found')); const user = await users.getByUsername(accountId); if (!user) return next(new Error('User not found')); user.ghost = !!lastSubmission?.ghost; // restore ghost flag. lastSubmission can be empty if login interaction was skipped (already logged in) // Check if user has access to the app if client refers to an app if (client.appId) { const app = await apps.get(client.appId); if (!app) return next(new Error('App not found')); if (!apps.canAccess(app, user)) { const result = { error: 'access_denied', error_description: 'User has no access to this app', }; return await gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: false }); } if (!app.manifest.addons?.email && params.scope.includes('mailclient')) { const result = { error: 'access_denied', error_description: 'App has no access to mailclient claims', }; 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, }); } // just confirm everything 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; // create login event 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: client.appId || null }); await safe(users.notifyLoginLocation(user, ip, userAgent, auditSource), { debug: log }); const result = { consent }; await gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: true }); } async function interactionAbort(req, res, next) { const result = { error: 'access_denied', error_description: 'End-User aborted interaction', }; const [error] = await safe(gOidcProvider.interactionFinished(req, res, result, { mergeWithLastSubmission: false })); if (error) return next(error); } async function getClaims(username, use, scope, clientId) { const [error, user] = await safe(users.getByUsername(username)); 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}`, // we always store as png preferred_username: user.username, groups: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `${g.name}`; }), }; if (clientId && scope.includes('mailclient')) { const [mailboxesError, mailboxes] = await safe(mail.listMailboxesByUserId(user.id)); if (mailboxesError) return { error: mailboxesError.message }; let mailPw = await mailpasswords.get(clientId, user.id); if (!mailPw) { const generatedPassword = crypto.randomBytes(48).toString('hex'); await mailpasswords.add(clientId, user.id, generatedPassword); mailPw = await mailpasswords.get(clientId, user.id); } if (!mailPw) return { error: 'could not generate mailclient claim' }; claims.mailclient = { accessToken: mailPw.password, mailboxes, }; } return claims; } 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) { log('Generating new OIDC EdDSA key'); const { privateKey } = await jose.generateKeyPair('EdDSA', { extractable: true }); keyEdDsa = Object.assign(await jose.exportJWK(privateKey), { alg: 'EdDSA' }); // alg is optional, but wp requires it await blobs.setString(blobs.OIDC_KEY_EDDSA, JSON.stringify(keyEdDsa)); jwksKeys.push(keyEdDsa); } else { log('Using existing OIDC EdDSA key'); jwksKeys.push(JSON.parse(keyEdDsa)); } let keyRs256 = await blobs.getString(blobs.OIDC_KEY_RS256); if (!keyRs256) { log('Generating new OIDC RS256 key'); const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true }); keyRs256 = Object.assign(await jose.exportJWK(privateKey), { alg: 'RS256' }); // alg is optional, but wp requires it await blobs.setString(blobs.OIDC_KEY_RS256, JSON.stringify(keyRs256)); jwksKeys.push(keyRs256); } else { log('Using existing OIDC RS256 key'); jwksKeys.push(JSON.parse(keyRs256)); } let cookieSecret = await settings.get(settings.OIDC_COOKIE_SECRET_KEY); if (!cookieSecret) { log('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) { const clientId = ctx.oidc.client?.clientId; return { accountId: id, claims: async (use, scope) => await getClaims(id, use, scope, clientId) }; }, renderError: async function (ctx, out, error) { ctx.type = 'html'; ctx.body = await renderError(error); }, 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' ], mailclient: [ 'mailclient' ] }, features: { rpInitiatedLogout: { enabled: false }, jwtIntrospection: { enabled: true }, introspection: { enabled: true, allowedPolicy: async function (ctx, client, token) { // first default check of the module to ensure this is a valid client with auth if (client.clientAuthMethod === 'none' && token.clientId !== ctx.oidc.client.clientId) return false; const internalClient = await oidcClients.get(ctx.oidc.client.clientId); if (!internalClient) return false; // check if we have an app, if so we have to check access const internalApp = internalClient.appId ? await apps.get(internalClient.appId) : null; if (internalApp) { const user = await users.getByUsername(token.accountId); return apps.canAccess(internalApp, user); } // unknown app if (internalClient.appId) return false; return true; } }, devInteractions: { enabled: false }, deviceFlow: { enabled: true, charset: 'base-20', mask: '****-****', userCodeInputSource: async function (ctx, form, out, err) { let message; if (err && (err.userCode || err.name === 'NoCodeError')) { message = '

The code you entered is incorrect. Try again

'; } else if (err && err.name === 'AbortedError') { message = '

The sign-in request was interrupted

'; } else if (err) { message = '

There was an error processing your request

'; } else { message = '

Enter the code displayed on your device

'; } ctx.body = ejs.render(TEMPLATE_DEVICE_INPUT, { message, form }); }, userCodeConfirmSource: async function (ctx, form, client, deviceInfo, userCode) { ctx.body = ejs.render(TEMPLATE_DEVICE_CONFIRM, { clientName: ctx.oidc.client.clientName || ctx.oidc.client.clientId, userCode, form }); }, successSource: async function (ctx) { ctx.body = ejs.render(TEMPLATE_DEVICE_SUCCESS, {}); }, }, }, 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 client.clientId === 'cid-webadmin' || client.clientId === 'cid-development'; } }, 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, 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); // if required, we can skip the consent screen altogether. See https://github.com/panva/node-oidc-provider/discussions/1307 . but then we have to raise login events here return null; }, // 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 DeviceCode: 600, // 10 minutes RefreshToken: 1209600 // 14 days } }; const { subdomain, domain } = await dashboard.getLocation(); const fqdn = dns.fqdn(subdomain, domain); log(`start: create provider for ${fqdn} at ${ROUTE_PREFIX}`); 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); // cloudflare access has a bug that it cannot handle OKP key type. https://github.com/sebadob/rauthy/issues/1229#issuecomment-3610993452 app.get (`${ROUTE_PREFIX}/jwks_rsaonly`, setNoCache, async function (req, res) { // previously (aff5e8f44d0c), we used to send response directly. but this was intricately linked to oidc-provider logic because of key.kid calculation const [error, response] = await safe(superagent.get(`http://127.0.0.1:${constants.OIDC_PORT}${ROUTE_PREFIX}/jwks`)); if (error) return res.send(`Internal error: ${error?.message}`); if (response.status !== 200) return res.send(`Internal error, unexpected status: ${response.status}`); const jwksResponse = safe.JSON.parse(response.body.toString('utf8')); const rsaKeys = jwksResponse?.keys?.filter(k => k.kty === 'RSA') || []; res.set('content-type', req.get('content-type')); // application/jwk-set+json; charset=utf-8 res.send({ keys: rsaKeys }); // https://github.com/panva/jose/discussions/654 }); 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; } export default { start, stop, revokeByUsername, consumeAuthCode, cleanupExpired, };