Files
cloudron-box/src/oidcserver.js

871 lines
37 KiB
JavaScript
Raw Normal View History

Migrate codebase from CommonJS to ES Modules - Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
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';
Migrate codebase from CommonJS to ES Modules - Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
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';
Migrate codebase from CommonJS to ES Modules - Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import ejs from 'ejs';
import express from 'express';
import eventlog from './eventlog.js';
import fs from 'node:fs';
import mail from './mail.js';
Migrate codebase from CommonJS to ES Modules - Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import * as marked from 'marked';
import middleware from './middleware/index.js';
Migrate codebase from CommonJS to ES Modules - Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import oidcClients from './oidcclients.js';
import passkeys from './passkeys.js';
Migrate codebase from CommonJS to ES Modules - Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
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';
2026-04-01 09:40:28 +02:00
import safe from '@cloudron/safetydance';
import settings from './settings.js';
import tokens from './tokens.js';
import users from './users.js';
import groups from './groups.js';
Migrate codebase from CommonJS to ES Modules - Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
import util from 'node:util';
2026-02-14 16:52:16 +01:00
import Provider from 'oidc-provider';
2026-03-17 17:36:37 +05:30
import oidcProviderWeakCache from 'oidc-provider/lib/helpers/weak_cache.js';
import mailpasswords from './mailpasswords.js';
2026-03-15 17:32:03 +05:30
const { log, trace } = logger('oidcserver');
Migrate codebase from CommonJS to ES Modules - Convert all require()/module.exports to import/export across 260+ files - Add "type": "module" to package.json to enable ESM by default - Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible - Convert eslint.config.js to ESM with sourceType: "module" - Replace __dirname/__filename with import.meta.dirname/import.meta.filename - Replace require.main === module with process.argv[1] === import.meta.filename - Remove 'use strict' directives (implicit in ESM) - Convert dynamic require() in switch statements to static import lookup maps (dns.js, domains.js, backupformats.js, backupsites.js, network.js) - Extract self-referencing exports.CONSTANT patterns into standalone const declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.) - Lazify SERVICES object in services.js to avoid circular dependency TDZ issues - Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests - Add _setMockApp() to ldapserver.js for ESM-safe test mocking - Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests - Convert backupupload.js to use dynamic imports so --check exits before loading the module graph (which requires BOX_ENV) - Update check-install to use ESM import for infra_version.js - Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations) - All 1315 tests passing Migration stats (AI-assisted using Cursor with Claude): - Wall clock time: ~3-4 hours - Assistant completions: ~80-100 - Estimated token usage: ~1-2M tokens Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 09:53:14 +01:00
// 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
2025-06-11 23:17:45 +02:00
const ROUTE_PREFIX = '/openid';
let gHttpServer = null, gOidcProvider = null;
2025-06-13 01:06:50 +02:00
// 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 }
2025-06-13 01:06:50 +02:00
static async getData(name) {
if (name === 'Client') throw new Error(`${name} is a database model`);
2025-06-13 01:06:50 +02:00
if (StorageAdapter.#database[name]) return StorageAdapter.#database[name];
2025-06-13 01:06:50 +02:00
StorageAdapter.#database[name] = {}; // init with empty table
2025-06-13 01:06:50 +02:00
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
2025-06-13 01:06:50 +02:00
return StorageAdapter.#database[name];
}
2025-06-13 01:06:50 +02:00
static async saveData(name) {
if (name === 'Client') throw new Error(`${name} is a database model`);
2025-06-13 01:06:50 +02:00
const filePath = path.join(paths.OIDC_STORE_DIR, `${name}.json`);
await fs.promises.writeFile(filePath, JSON.stringify(StorageAdapter.#database[name], null, 2), 'utf8');
}
2025-06-13 01:06:50 +02:00
static async updateData(name, action) {
const data = await StorageAdapter.getData(name);
await action(data);
await StorageAdapter.saveData(name);
}
constructor(name) {
2026-03-15 17:32:03 +05:30
trace(`Creating OpenID storage adapter for ${name}`);
2025-06-13 01:06:50 +02:00
this.name = name;
}
async upsert(id, payload, expiresIn) {
2026-03-15 17:32:03 +05:30
trace(`[${this.name}] upsert: ${id}`);
2024-11-18 17:17:22 +01:00
const expiresAt = expiresIn ? new Date(Date.now() + (expiresIn * 1000)) : 0;
2025-06-13 01:06:50 +02:00
// only AccessToken of webadmin are stored in the db. Dashboard uses REST API and the token middleware looks up tokens in db
2026-02-16 22:18:01 +01:00
if (this.name === 'AccessToken' && (payload.clientId === oidcClients.ID_WEBADMIN || payload.clientId === oidcClients.ID_DEVELOPMENT || payload.clientId === oidcClients.ID_CLI)) {
2023-06-02 20:47:36 +02:00
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: '' }));
2023-06-02 20:47:36 +02:00
if (error) {
log('Error adding access token', error);
2023-06-02 20:47:36 +02:00
throw error;
}
2023-03-16 15:37:03 +01:00
} else {
2025-06-13 01:06:50 +02:00
await StorageAdapter.updateData(this.name, (data) => data[id] = { id, expiresAt, payload, consumed: false });
2023-03-16 15:37:03 +01:00
}
}
async find(id) {
2026-03-15 17:32:03 +05:30
trace(`[${this.name}] find: ${id}`);
2024-11-18 17:17:22 +01:00
2023-03-16 15:37:03 +01:00
if (this.name === 'Client') {
2025-06-11 22:00:09 +02:00
const [error, client] = await safe(oidcClients.get(id));
2025-06-13 01:06:50 +02:00
if (error || !client) {
log('find: error getting client', error);
return null;
}
2023-03-16 15:37:03 +01:00
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';
2023-06-02 20:47:36 +02:00
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;
2023-06-02 20:47:36 +02:00
if (client.appId) {
2026-02-18 08:18:37 +01:00
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) => {
2026-03-10 15:15:10 +05:30
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());
}
2023-06-02 20:47:36 +02:00
return tmp;
} else if (this.name === 'AccessToken') {
2025-06-13 01:06:50 +02:00
// dashboard AccessToken are in the db. the app tokens are in the json files
2023-06-02 20:47:36 +02:00
const [error, result] = await safe(tokens.getByAccessToken(id));
2025-06-13 01:06:50 +02:00
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
};
}
2023-06-02 20:47:36 +02:00
}
} else if (this.name === 'Session') {
2025-06-13 01:06:50 +02:00
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;
2023-03-16 15:37:03 +01:00
}
2025-06-13 01:06:50 +02:00
const data = await StorageAdapter.getData(this.name);
if (!data[id]) return null;
return data[id].payload;
}
async findByUserCode(userCode) {
2026-03-15 17:32:03 +05:30
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;
}
2025-06-13 01:06:50 +02:00
// this is called only on Session store. there is a payload.uid
async findByUid(uid) {
2026-03-15 17:32:03 +05:30
trace(`[${this.name}] findByUid: ${uid}`);
2024-11-18 17:17:22 +01:00
2025-06-13 01:06:50 +02:00
const data = await StorageAdapter.getData(this.name);
for (const d in data) {
if (data[d].payload.uid === uid) return data[d].payload;
2023-03-16 15:37:03 +01:00
}
2025-06-13 01:06:50 +02:00
return null;
}
async consume(id) {
2026-03-15 17:32:03 +05:30
trace(`[${this.name}] consume: ${id}`);
2025-06-13 01:06:50 +02:00
await StorageAdapter.updateData(this.name, (data) => data[id].consumed = true);
}
async destroy(id) {
2026-03-15 17:32:03 +05:30
trace(`[${this.name}] destroy: ${id}`);
2025-06-13 01:06:50 +02:00
await StorageAdapter.updateData(this.name, (data) => delete data[id]);
}
async revokeByGrantId(grantId) {
2026-03-15 17:32:03 +05:30
trace(`[${this.name}] revokeByGrantId: ${grantId}`);
2025-06-13 01:06:50 +02:00
await StorageAdapter.updateData(this.name, (data) => {
for (const d in data) {
if (data[d].grantId === grantId) {
delete data[d];
return;
2023-03-16 15:37:03 +01:00
}
}
2025-06-13 01:06:50 +02:00
});
}
}
// 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');
2025-06-13 01:06:50 +02:00
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];
2025-06-13 01:06:50 +02:00
}
});
}
}
// used by proxyauth logic to authenticate using a one time code
async function consumeAuthCode(authCode) {
assert.strictEqual(typeof authCode, 'string');
let username = null;
2025-06-13 01:06:50 +02:00
await StorageAdapter.updateData('AuthorizationCode', (data) => {
const authData = data[authCode];
if (authData) {
username = authData.payload.accountId;
2025-06-13 01:06:50 +02:00
authData.consumed = true;
}
2025-06-13 01:06:50 +02:00
});
return username;
2025-06-13 01:06:50 +02:00
}
// This exposed to run on a cron job
async function cleanupExpired() {
log('cleanupExpired');
2025-06-13 01:06:50 +02:00
const types = [ 'AuthorizationCode', 'AccessToken', 'DeviceCode', 'Grant', 'Interaction', 'RefreshToken', 'Session' ];
2025-06-13 01:06:50 +02:00
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');
2025-07-11 14:26:57 +02:00
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');
2025-06-11 23:26:22 +02:00
async function renderError(error) {
const data = {
2025-07-11 14:26:57 +02:00
iconUrl: '/api/v1/cloudron/avatar',
name: 'Cloudron',
errorMessage: error.error_description || error.error_detail || error.message || 'Internal error',
2025-07-14 11:25:01 +02:00
footer: marked.parse(await branding.renderFooter()),
language: await settings.get(settings.LANGUAGE_KEY),
2025-06-11 23:26:22 +02:00
};
2026-03-15 17:32:03 +05:30
trace('renderError: %o', error);
2025-07-11 14:26:57 +02:00
return ejs.render(TEMPLATE_ERROR, data);
2025-06-11 23:26:22 +02:00
}
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;
2023-05-12 13:32:43 +02:00
2025-06-11 23:26:22 +02:00
const client = await oidcClients.get(params.client_id);
if (!client) return res.send(await renderError(new Error('Client not found')));
2023-05-12 13:32:43 +02:00
2025-06-11 23:26:22 +02:00
const app = client.appId ? await apps.get(client.appId) : null;
if (client.appId && !app) return res.send(await renderError(new Error('App not found')));
2023-06-19 11:50:53 +02:00
2025-06-11 23:26:22 +02:00
res.set('Content-Type', 'text/html');
2023-04-25 13:13:04 +02:00
2025-06-11 23:26:22 +02:00
if (prompt.name === 'login') {
2025-07-11 14:26:57 +02:00
const data = {
submitUrl: `${ROUTE_PREFIX}/interaction/${uid}/login`,
2026-03-16 20:04:36 +05:30
passkeyAuthOptionsUrl: `${ROUTE_PREFIX}/interaction/${uid}/passkey_auth_options`,
passkeyLoginUrl: `${ROUTE_PREFIX}/interaction/${uid}/passkey_login`,
2025-07-11 14:26:57 +02:00
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}"` : '',
2025-07-14 11:25:01 +02:00
language: await settings.get(settings.LANGUAGE_KEY),
2025-06-11 23:26:22 +02:00
};
2025-06-11 23:26:22 +02:00
if (app) {
data.name = app.label || app.subdomain || app.fqdn;
2025-07-11 14:26:57 +02:00
data.iconUrl = app.iconUrl;
}
2025-07-11 14:26:57 +02:00
return res.send(ejs.render(TEMPLATE_LOGIN, data));
2025-06-11 23:26:22 +02:00
} else if (prompt.name === 'consent') {
2026-02-15 19:37:30 +01:00
let hasAccess;
2025-07-11 14:26:57 +02:00
const data = {
iconUrl: '/api/v1/cloudron/avatar',
name: client.name || '',
2025-07-14 11:25:01 +02:00
footer: marked.parse(await branding.renderFooter()),
language: await settings.get(settings.LANGUAGE_KEY),
2025-06-11 23:26:22 +02:00
};
2025-06-11 23:26:22 +02:00
// check if user has access to the app if client refers to an app
if (app) {
const user = await users.getByUsername(session.accountId);
2025-07-11 14:26:57 +02:00
data.name = app.label || app.fqdn;
data.iconUrl = app.iconUrl;
2025-06-11 23:26:22 +02:00
hasAccess = apps.canAccess(app, user);
} else {
hasAccess = true;
}
2025-07-11 14:26:57 +02:00
data.submitUrl = `${ROUTE_PREFIX}/interaction/${uid}/${hasAccess ? 'confirm' : 'abort'}`;
2025-07-11 14:26:57 +02:00
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));
2024-04-04 10:29:36 +02:00
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null;
const clientId = details.params.client_id;
2026-03-15 17:32:03 +05:30
trace(`interactionLogin: for OpenID client ${clientId} from ${ip}`);
2025-06-11 23:26:22 +02:00
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'));
2025-08-07 17:09:36 +02:00
const user = await users.get(token.identifier);
if (!user) return next(new HttpError(401,'User not found'));
2023-03-13 19:08:41 +01:00
const result = {
login: {
accountId: user.username,
2023-03-13 19:08:41 +01:00
},
};
const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, result));
2023-03-13 19:08:41 +01:00
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'));
2026-02-12 21:10:51 +01:00
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'));
2026-02-12 21:10:51 +01:00
const { username, password, totpToken, passkeyResponse } = req.body;
const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail;
2026-02-12 21:10:51 +01:00
// First verify password, skip 2FA check initially to determine what 2FA methods are available
2026-03-16 17:30:35 +05:30
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, passkeyResponse, skipTotpCheck: !totpToken }));
2026-02-12 21:10:51 +01:00
// Handle passkey verification if provided
if (!verifyError && user && !user.ghost && passkeyResponse && !totpToken) {
2026-02-17 19:30:33 +01:00
const userPasskeys = await passkeys.listByUserId(user.id);
2026-02-12 21:10:51 +01:00
if (userPasskeys.length > 0) {
const [passkeyError] = await safe(passkeys.verifyAuthentication(user, passkeyResponse));
if (passkeyError) {
2026-03-15 17:32:03 +05:30
trace(`interactionLogin: passkey verification failed for ${username}: ${passkeyError.message}`);
2026-02-12 21:10:51 +01:00
return next(new HttpError(401, 'Invalid passkey'));
}
2026-03-15 17:32:03 +05:30
trace(`interactionLogin: passkey verified for ${username}`);
2026-02-12 21:10:51 +01:00
}
}
// If password verified but 2FA is required and not provided, return challenge
if (!verifyError && user && !user.ghost && !totpToken && !passkeyResponse) {
2026-02-17 19:30:33 +01:00
const userPasskeys = await passkeys.listByUserId(user.id);
2026-03-16 16:27:00 +05:30
const has2FA = user.totpEnabled || userPasskeys.length > 0;
2026-02-12 21:10:51 +01:00
if (has2FA) {
// Generate passkey options if user has passkeys
let passkeyOptions = null;
if (userPasskeys.length > 0) {
2026-02-17 19:30:33 +01:00
const [optionsError, options] = await safe(passkeys.getAuthenticationOptions(user));
2026-02-12 21:10:51 +01:00
if (!optionsError) passkeyOptions = options;
}
return res.status(200).send({
twoFactorRequired: true,
2026-03-16 16:27:00 +05:30
totpRequired: user.totpEnabled,
2026-02-12 21:10:51 +01:00
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 });
}
2026-03-16 20:04:36 +05:30
async function interactionPasskeyAuthOptions(req, res, next) {
const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res));
if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description));
const { uid } = details;
const [error, options] = await safe(passkeys.getDiscoverableAuthOptions(uid));
if (error) return next(new HttpError(500, error));
res.status(200).send(options);
}
async function interactionPasskeyLogin(req, res, next) {
const [detailsError, details] = await safe(gOidcProvider.interactionDetails(req, res));
if (detailsError) return next(new HttpError(detailsError.statusCode, detailsError.error_description));
if (!req.body.passkeyResponse || typeof req.body.passkeyResponse !== 'object') return next(new HttpError(400, 'passkeyResponse must be an object'));
const { uid } = details;
const [verifyError, result] = await safe(passkeys.verifyDiscoverableAuth(uid, req.body.passkeyResponse));
if (verifyError) {
trace(`interactionPasskeyLogin: passkey verification failed: ${verifyError.message}`);
return next(new HttpError(401, 'Passkey verification failed'));
}
const user = await users.get(result.userId);
if (!user) return next(new HttpError(401, 'User not found'));
const interactionResult = {
login: {
accountId: user.username,
},
};
const [interactionFinishError, redirectTo] = await safe(gOidcProvider.interactionResult(req, res, interactionResult));
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;
2026-03-15 17:32:03 +05:30
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'));
2025-06-12 22:58:29 +02:00
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',
};
2023-04-25 13:13:04 +02:00
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);
2023-04-25 13:13:04 +02:00
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) {
2025-06-11 23:18:48 +02:00
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));
2023-03-14 12:24:35 +01:00
if (error) return { error: 'user not found' };
const [groupsError, allGroups] = await safe(groups.listWithMembers());
if (groupsError) return { error: groupsError.message };
2023-03-14 12:24:35 +01:00
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
2023-03-14 12:24:35 +01:00
2024-01-29 13:55:31 +01:00
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
2023-03-14 12:52:37 +01:00
const claims = {
2023-03-17 14:20:21 +01:00
sub: user.username, // it is essential to always return a sub claim
2023-03-14 12:24:35 +01:00
email: user.email,
email_verified: true,
family_name: lastName,
middle_name: middleName,
2023-03-14 12:24:35 +01:00
given_name: firstName,
locale: 'en-US',
name: user.displayName,
2025-12-24 10:51:38 +01:00
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}`; }),
2023-03-14 12:24:35 +01:00
};
2023-03-14 12:52:37 +01:00
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,
};
}
2023-03-14 12:52:37 +01:00
return claims;
2023-03-14 12:24:35 +01:00
}
async function start() {
assert(gHttpServer === null, 'OIDC server already started');
assert(gOidcProvider === null, 'OIDC provider already started');
2023-10-01 13:26:43 +05:30
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 });
2026-02-12 19:39:18 +01:00
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 });
2026-02-12 19:39:18 +01:00
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));
}
2023-08-02 20:01:29 +05:30
let cookieSecret = await settings.get(settings.OIDC_COOKIE_SECRET_KEY);
if (!cookieSecret) {
log('Generating new cookie secret');
2024-01-23 12:44:23 +01:00
cookieSecret = crypto.randomBytes(256).toString('base64');
2023-08-02 20:01:29 +05:30
await settings.set(settings.OIDC_COOKIE_SECRET_KEY, cookieSecret);
}
const configuration = {
2025-06-11 23:26:22 +02:00
findAccount: async function (ctx, id) {
const clientId = ctx.oidc.client?.clientId;
2025-06-11 21:02:36 +02:00
return {
accountId: id,
claims: async (use, scope) => await getClaims(id, use, scope, clientId)
2025-06-11 21:02:36 +02:00
};
},
2025-06-11 23:26:22 +02:00
renderError: async function (ctx, out, error) {
ctx.type = 'html';
ctx.body = await renderError(error);
},
adapter: StorageAdapter,
interactions: {
2025-06-11 23:26:22 +02:00
url: async function (ctx, interaction) {
return `${ROUTE_PREFIX}/interaction/${interaction.uid}`;
}
2023-03-10 16:07:45 +01:00
},
jwks: {
keys: jwksKeys
},
2023-03-16 16:42:18 +01:00
claims: {
email: ['email', 'email_verified'],
profile: [ 'family_name', 'given_name', 'locale', 'name', 'preferred_username', 'picture' ],
groups: [ 'groups' ],
2026-02-17 17:34:05 +01:00
mailclient: [ 'mailclient' ]
2023-03-16 16:42:18 +01:00
},
2023-03-11 17:22:27 +01:00
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) {
2026-03-18 11:41:05 +01:00
// below errors and message is currently unused
let message;
if (err && (err.userCode || err.name === 'NoCodeError')) {
message = '<p class="red">The code you entered is incorrect. Try again</p>';
} else if (err && err.name === 'AbortedError') {
message = '<p class="red">The sign-in request was interrupted</p>';
} else if (err) {
message = '<p class="red">There was an error processing your request</p>';
} else {
message = '<p>Enter the code displayed on your device</p>';
}
2026-03-18 11:41:05 +01:00
ctx.body = ejs.render(TEMPLATE_DEVICE_INPUT, {
name: await branding.getCloudronName(),
message,
form
});
},
userCodeConfirmSource: async function (ctx, form, client, deviceInfo, userCode) {
let clientName = ctx.oidc.client.clientName || ctx.oidc.client.clientId;
// only special case the cli to avoid dumping the internal cid
if (clientName === oidcClients.ID_CLI) clientName = 'CLI';
2026-03-18 11:41:05 +01:00
ctx.body = ejs.render(TEMPLATE_DEVICE_CONFIRM, {
name: await branding.getCloudronName(),
clientName,
2026-03-18 11:41:05 +01:00
userCode,
form
});
},
successSource: async function (ctx) {
2026-03-18 11:41:05 +01:00
ctx.body = ejs.render(TEMPLATE_DEVICE_SUCCESS, {
name: await branding.getCloudronName()
});
},
},
2023-03-11 17:22:27 +01:00
},
clientDefaults: {
response_types: ['code', 'id_token'],
2024-11-18 18:04:46 +01:00
grant_types: ['authorization_code', 'implicit', 'refresh_token']
},
2023-06-02 20:47:36 +02:00
responseTypes: [
'code',
'id_token', 'id_token token',
'code id_token', 'code token', 'code id_token token',
'none',
],
2023-03-15 13:37:51 +01:00
// if a client only has one redirect uri specified, the client does not have to provide it in the request
allowOmittingSingleRegisteredRedirectUri: true,
2023-03-14 14:58:09 +01:00
clients: [],
cookies: {
keys: [ cookieSecret ]
2023-03-14 14:58:09 +01:00
},
pkce: {
required: function pkceRequired(ctx, client) {
return client.clientId === 'cid-webadmin' || client.clientId === 'cid-development';
}
},
2024-04-11 15:51:20 +02:00
clientBasedCORS(ctx, origin, client) {
// allow CORS for clients where at least the origin matches where we redirect back to
if (client.redirectUris.find((u) => origin === '*' || u.indexOf(origin) === 0)) return true;
2024-04-11 15:51:20 +02:00
return false;
},
conformIdTokenClaims: false,
loadExistingGrant: async function (ctx) {
2025-06-11 23:17:45 +02:00
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
}
};
2023-08-14 11:08:38 +05:30
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);
2026-03-16 20:04:36 +05:30
app.post(`${ROUTE_PREFIX}/interaction/:uid/login`, setNoCache, json, interactionLogin);
app.post(`${ROUTE_PREFIX}/interaction/:uid/passkey_auth_options`, setNoCache, json, interactionPasskeyAuthOptions);
app.post(`${ROUTE_PREFIX}/interaction/:uid/passkey_login`, setNoCache, json, interactionPasskeyLogin);
app.post(`${ROUTE_PREFIX}/interaction/:uid/confirm`, setNoCache, json, interactionConfirm);
app.get (`${ROUTE_PREFIX}/interaction/:uid/abort`, setNoCache, interactionAbort);
2023-03-21 13:54:40 +01:00
// cloudflare access has a bug that it cannot handle OKP key type. https://github.com/sebadob/rauthy/issues/1229#issuecomment-3610993452
2026-03-17 17:36:37 +05:30
app.get (`${ROUTE_PREFIX}/jwks_rsaonly`, setNoCache, function (req, res) {
const { keys } = oidcProviderWeakCache(gOidcProvider).jwks;
const rsaKeys = keys.filter(k => k.kty === 'RSA');
res.set('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());
2023-05-12 14:31:26 +02:00
app.use(middleware.lastMile());
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.OIDC_PORT, '127.0.0.1');
2023-03-21 13:54:40 +01:00
}
async function stop() {
if (!gHttpServer) return;
await util.promisify(gHttpServer.close.bind(gHttpServer))();
gHttpServer = null;
gOidcProvider = null;
2023-03-21 13:54:40 +01:00
}
export default {
start,
stop,
revokeByUsername,
consumeAuthCode,
cleanupExpired,
};