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' ;
2026-02-14 15:43:24 +01:00
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' ;
2026-02-14 15:43:24 +01:00
import dashboard from './dashboard.js' ;
2026-03-12 22:55:28 +05:30
import logger from './logger.js' ;
2026-02-14 15:43:24 +01:00
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' ;
2026-02-17 14:06:40 +01:00
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' ;
2026-02-14 15:43:24 +01:00
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' ;
2026-02-14 15:43:24 +01:00
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' ;
2026-02-14 15:43:24 +01:00
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' ;
2026-02-17 17:22:50 +01:00
import mailpasswords from './mailpasswords.js' ;
2023-03-08 16:41:59 +01:00
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
2023-03-08 16:41:59 +01:00
2026-03-14 22:06:17 +05:30
// 1. Index.vue starts the OIDC flow by navigating to /openid/auth. Webadmin uses authorization code flow with PKCE
2025-07-11 12:23:41 +02:00
// 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
2025-07-10 13:06:13 +02:00
// 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().
2026-03-14 22:06:17 +05:30
// 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
2023-03-21 14:39:58 +01:00
const ROUTE _PREFIX = '/openid' ;
2025-06-11 20:40:18 +02:00
let gHttpServer = null , gOidcProvider = null ;
2023-03-21 14:39:58 +01:00
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 }
2023-03-24 20:08:17 +01:00
2025-06-13 01:06:50 +02:00
static async getData ( name ) {
if ( name === 'Client' ) throw new Error ( ` ${ name } is a database model ` ) ;
2023-03-24 20:08:17 +01:00
2025-06-13 01:06:50 +02:00
if ( StorageAdapter . # database [ name ] ) return StorageAdapter . # database [ name ] ;
2023-03-24 20:08:17 +01:00
2025-06-13 01:06:50 +02:00
StorageAdapter . # database [ name ] = { } ; // init with empty table
2023-03-24 20:08:17 +01:00
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
2023-03-24 20:08:17 +01:00
2025-06-13 01:06:50 +02:00
return StorageAdapter . # database [ name ] ;
2023-03-24 20:08:17 +01:00
}
2025-06-13 01:06:50 +02:00
static async saveData ( name ) {
if ( name === 'Client' ) throw new Error ( ` ${ name } is a database model ` ) ;
2023-03-24 20:08:17 +01:00
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' ) ;
2023-03-24 20:08:17 +01:00
}
2024-04-15 12:35:03 +02:00
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 ) ;
2025-03-05 11:29:13 +01:00
}
2023-03-08 16:41:59 +01:00
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 ;
2023-03-08 16:41:59 +01:00
}
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
2025-03-05 11:29:13 +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 ;
2025-07-01 22:07:31 +02:00
// 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 ) {
2026-03-12 22:55:28 +05:30
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
}
2023-03-08 16:41:59 +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 ) {
2026-03-12 22:55:28 +05:30
log ( 'find: error getting client' , error ) ;
2023-03-21 15:23:45 +01:00
return null ;
}
2023-03-16 15:37:03 +01:00
2023-04-14 21:18:44 +02:00
const tmp = { } ;
2023-06-20 19:58:09 +02:00
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
2023-04-14 21:18:44 +02:00
tmp . client _id = id ;
tmp . client _secret = client . secret ;
tmp . id _token _signed _response _alg = client . tokenSignatureAlgorithm || 'RS256' ;
2023-03-22 11:12:50 +01:00
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 ;
2026-03-13 12:40:06 +05:30
if ( client . token _endpoint _auth _method ) tmp . token _endpoint _auth _method = client . token _endpoint _auth _method ;
2023-06-02 20:47:36 +02:00
2023-04-14 21:18:44 +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 ) {
2026-03-12 22:55:28 +05:30
log ( ` find: Unknown app for client with appId ${ client . appId } ` ) ;
2023-04-14 21:18:44 +02:00
return null ;
}
2024-04-19 19:03:21 +02:00
const domains = [ app . fqdn ] . concat ( app . aliasDomains . map ( d => d . fqdn ) ) ;
2023-07-20 13:26:07 +02:00
// prefix login redirect uris with app.fqdn if it is just a path without a schema
2023-04-14 21:18:44 +02:00
// native callbacks for apps have custom schema like app.immich:/
2024-04-19 19:03:21 +02:00
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 ) ;
2024-04-19 19:03:21 +02:00
else tmp . redirect _uris = tmp . redirect _uris . concat ( domains . map ( fqdn => ` https:// ${ fqdn } ${ s } ` ) ) ;
} ) ;
2023-04-14 21:18:44 +02:00
} else {
tmp . redirect _uris = client . loginRedirectUri . split ( ',' ) . map ( s => s . trim ( ) ) ;
}
2023-03-22 11:12:50 +01:00
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 ) {
2025-07-01 22:07:31 +02:00
// 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
}
2025-05-14 12:26:31 +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 ] ;
2025-05-14 12:26:31 +02:00
if ( ! session ) return null ;
if ( session . payload . accountId ) {
// check if the session user still exists and is active
2025-07-01 22:07:31 +02:00
const user = await users . getByUsername ( session . payload . accountId ) ;
2025-05-14 12:26:31 +02:00
if ( ! user || ! user . active ) return null ;
}
2025-05-19 10:56:00 +02:00
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 ;
2023-03-08 16:41:59 +01:00
}
async findByUserCode ( userCode ) {
2026-03-15 17:32:03 +05:30
trace ( ` [ ${ this . name } ] findByUserCode userCode: ${ userCode } ` ) ;
2026-03-13 12:40:06 +05:30
const data = await StorageAdapter . getData ( this . name ) ;
for ( const id in data ) {
if ( data [ id ] . payload . userCode === userCode ) return data [ id ] . payload ;
}
return undefined ;
2023-03-08 16:41:59 +01:00
}
2025-06-13 01:06:50 +02:00
// this is called only on Session store. there is a payload.uid
2023-03-08 16:41:59 +01:00
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 ;
2023-03-08 16:41:59 +01:00
}
async consume ( id ) {
2026-03-15 17:32:03 +05:30
trace ( ` [ ${ this . name } ] consume: ${ id } ` ) ;
2023-06-04 16:03:45 +02:00
2025-06-13 01:06:50 +02:00
await StorageAdapter . updateData ( this . name , ( data ) => data [ id ] . consumed = true ) ;
2023-03-08 16:41:59 +01:00
}
async destroy ( id ) {
2026-03-15 17:32:03 +05:30
trace ( ` [ ${ this . name } ] destroy: ${ id } ` ) ;
2023-06-04 16:03:45 +02:00
2025-06-13 01:06:50 +02:00
await StorageAdapter . updateData ( this . name , ( data ) => delete data [ id ] ) ;
2023-03-08 16:41:59 +01:00
}
async revokeByGrantId ( grantId ) {
2026-03-15 17:32:03 +05:30
trace ( ` [ ${ this . name } ] revokeByGrantId: ${ grantId } ` ) ;
2023-06-04 16:03:45 +02:00
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
}
2023-03-08 16:41:59 +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
2025-07-01 22:07:31 +02:00
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 ) {
2025-07-01 22:07:31 +02:00
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' ) ;
2025-07-09 18:06:50 +02:00
let username = null ;
2025-06-13 01:06:50 +02:00
await StorageAdapter . updateData ( 'AuthorizationCode' , ( data ) => {
const authData = data [ authCode ] ;
if ( authData ) {
2025-07-09 18:06:50 +02:00
username = authData . payload . accountId ;
2025-06-13 01:06:50 +02:00
authData . consumed = true ;
2023-03-08 16:41:59 +01:00
}
2025-06-13 01:06:50 +02:00
} ) ;
2025-07-09 18: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 ( ) {
2026-03-12 22:55:28 +05:30
log ( 'cleanupExpired' ) ;
2025-06-13 01:06:50 +02:00
2026-03-13 12:40:06 +05:30
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 ] ;
}
} ) ;
2023-03-08 16:41:59 +01:00
}
}
2026-03-13 13:21:01 +05:30
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
}
2025-10-17 23:43:17 +02:00
async function renderInteractionPage ( req , res ) {
2025-06-13 00:08:54 +02:00
const [ detailsError , details ] = await safe ( gOidcProvider . interactionDetails ( req , res ) ) ;
2025-10-17 23:43:17 +02:00
if ( detailsError ) return res . send ( await renderError ( new Error ( 'Invalid session' ) ) ) ;
2025-06-13 00:08:54 +02:00
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
} ;
2024-12-13 23:34:26 +01:00
2025-06-11 23:26:22 +02:00
if ( app ) {
2025-07-16 18:21:34 +02:00
data . name = app . label || app . subdomain || app . fqdn ;
2025-07-11 14:26:57 +02:00
data . iconUrl = app . iconUrl ;
2025-06-11 20:40:18 +02:00
}
2024-12-13 23:34:26 +01:00
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-06-11 20:40:18 +02:00
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 20:40:18 +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 ) {
2025-07-01 22:07:31 +02:00
const user = await users . getByUsername ( session . accountId ) ;
2024-12-13 23:34:26 +01:00
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 ;
2024-04-04 17:32:58 +02:00
}
2025-06-11 20:40:18 +02:00
2025-07-11 14:26:57 +02:00
data . submitUrl = ` ${ ROUTE _PREFIX } /interaction/ ${ uid } / ${ hasAccess ? 'confirm' : 'abort' } ` ;
2024-04-03 18:11:21 +02:00
2025-07-11 14:26:57 +02:00
return res . send ( ejs . render ( hasAccess ? TEMPLATE _INTERACTION _CONFIRM : TEMPLATE _INTERACTION _ABORT , data ) ) ;
2025-06-11 20:40:18 +02:00
}
}
2024-04-03 18:11:21 +02:00
2025-06-11 20:40:18 +02:00
async function interactionLogin ( req , res , next ) {
const [ detailsError , details ] = await safe ( gOidcProvider . interactionDetails ( req , res ) ) ;
2025-06-13 00:08:54 +02:00
if ( detailsError ) return next ( new HttpError ( detailsError . statusCode , detailsError . error _description ) ) ;
2024-04-04 10:29:36 +02:00
2025-06-11 20:40:18 +02:00
const ip = req . headers [ 'x-forwarded-for' ] || req . socket . remoteAddress || null ;
const clientId = details . params . client _id ;
2024-04-03 18:11:21 +02:00
2026-03-15 17:32:03 +05:30
trace ( ` interactionLogin: for OpenID client ${ clientId } from ${ ip } ` ) ;
2023-03-13 17:01:52 +01:00
2025-06-11 23:26:22 +02:00
if ( req . body . autoLoginToken ) { // auto login for first admin/owner
2025-06-11 20:40:18 +02:00
if ( typeof req . body . autoLoginToken !== 'string' ) return next ( new HttpError ( 400 , 'autoLoginToken must be string if provided' ) ) ;
2023-03-13 17:01:52 +01:00
2025-06-11 20:40:18 +02:00
const token = await tokens . getByAccessToken ( req . body . autoLoginToken ) ;
if ( ! token ) return next ( new HttpError ( 401 , 'No such token' ) ) ;
2023-03-13 17:01:52 +01:00
2025-08-07 17:09:36 +02:00
const user = await users . get ( token . identifier ) ;
2025-06-11 20:40:18 +02:00
if ( ! user ) return next ( new HttpError ( 401 , 'User not found' ) ) ;
2023-03-10 17:13:33 +01:00
2023-03-13 19:08:41 +01:00
const result = {
login : {
2025-07-01 22:07:31 +02:00
accountId : user . username ,
2023-03-13 19:08:41 +01:00
} ,
} ;
2025-06-11 20:40:18 +02: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 ) ) ;
2025-06-11 23:38:32 +02:00
await tokens . delByAccessToken ( req . body . autoLoginToken ) ; // clear token as it is one-time use
2025-06-11 20:40:18 +02:00
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' ) ) ;
2025-06-11 20:40:18 +02:00
2026-02-12 21:10:51 +01:00
const { username , password , totpToken , passkeyResponse } = req . body ;
2025-06-11 20:40:18 +02:00
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
} ) ;
}
}
2025-06-11 20:40:18 +02:00
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' ) ) ;
2025-06-11 23:38:32 +02:00
// this is saved as part of interaction.lastSubmission
2025-06-11 20:40:18 +02:00
const result = {
login : {
2025-07-01 22:07:31 +02:00
accountId : user . username ,
2025-06-11 20:40:18 +02:00
} ,
2025-06-11 23:38:32 +02:00
ghost : ! ! user . ghost
2023-03-14 14:19:29 +01:00
} ;
2025-06-11 20:40:18 +02:00
const [ interactionFinishError , redirectTo ] = await safe ( gOidcProvider . interactionResult ( req , res , result ) ) ;
if ( interactionFinishError ) return next ( new HttpError ( 500 , interactionFinishError ) ) ;
2023-03-10 17:13:33 +01:00
2025-06-11 20:40:18 +02:00
res . status ( 200 ) . send ( { redirectTo } ) ;
}
2024-06-25 12:42:46 +02:00
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 } ) ;
}
2025-06-11 20:40:18 +02:00
async function interactionConfirm ( req , res , next ) {
2025-10-17 23:43:17 +02:00
const [ detailsError , interactionDetails ] = await safe ( gOidcProvider . interactionDetails ( req , res ) ) ;
if ( detailsError ) return next ( new HttpError ( detailsError . statusCode , detailsError . error _description ) ) ;
2025-06-11 23:38:32 +02:00
const { grantId , uid , prompt : { name , details } , params , session : { accountId } , lastSubmission } = interactionDetails ;
2024-06-25 12:42:46 +02:00
2026-03-15 17:32:03 +05:30
trace ( ` route interaction confirm post uid: ${ uid } prompt.name: ${ name } accountId: ${ accountId } ` ) ;
2023-03-10 17:13:33 +01:00
2025-06-11 23:38:32 +02:00
const client = await oidcClients . get ( params . client _id ) ;
if ( ! client ) return next ( new Error ( 'Client not found' ) ) ;
2023-03-10 17:13:33 +01:00
2025-07-01 22:07:31 +02:00
const user = await users . getByUsername ( accountId ) ;
2025-06-11 23:38:32 +02:00
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)
2023-03-10 17:13:33 +01:00
2025-06-11 23:38:32 +02:00
// 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' ) ) ;
2023-03-10 17:13:33 +01:00
2025-06-11 23:38:32 +02:00
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
2025-06-11 23:38:32 +02:00
return await gOidcProvider . interactionFinished ( req , res , result , { mergeWithLastSubmission : false } ) ;
}
2026-02-17 14:06:40 +01:00
2026-02-18 15:17:08 +01:00
if ( ! app . manifest . addons ? . email && params . scope . includes ( 'mailclient' ) ) {
2026-02-17 14:06:40 +01:00
const result = {
error : 'access_denied' ,
error _description : 'App has no access to mailclient claims' ,
} ;
return await gOidcProvider . interactionFinished ( req , res , result , { mergeWithLastSubmission : false } ) ;
}
2025-06-11 23:38:32 +02:00
}
2024-06-25 12:42:46 +02:00
2025-06-11 23:38:32 +02:00
let grant ;
if ( grantId ) {
grant = await gOidcProvider . Grant . find ( grantId ) ;
} else {
grant = new gOidcProvider . Grant ( {
accountId ,
clientId : params . client _id ,
} ) ;
}
2024-06-25 12:42:46 +02:00
2025-06-11 23:38:32 +02:00
// 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
2025-06-11 23:38:32 +02:00
if ( details . missingResourceScopes ) {
for ( const [ indicator , scopes ] of Object . entries ( details . missingResourceScopes ) ) {
grant . addResourceScope ( indicator , scopes . join ( ' ' ) ) ;
2025-06-11 20:40:18 +02:00
}
2025-06-11 23:38:32 +02:00
}
2023-03-10 17:13:33 +01:00
2025-06-11 23:38:32 +02:00
const savedGrantId = await grant . save ( ) ;
2023-03-10 17:13:33 +01:00
2025-06-11 23:38:32 +02:00
const consent = { } ;
if ( ! interactionDetails . grantId ) consent . grantId = savedGrantId ;
2023-03-10 17:13:33 +01:00
2025-06-11 23:38:32 +02:00
// 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 ) ;
2023-03-10 17:13:33 +01:00
2025-11-19 23:21:09 +01:00
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 } ) ;
2026-03-12 22:55:28 +05:30
await safe ( users . notifyLoginLocation ( user , ip , userAgent , auditSource ) , { debug : log } ) ;
2024-06-25 12:42:46 +02:00
2025-06-11 23:38:32 +02:00
const result = { consent } ;
await gOidcProvider . interactionFinished ( req , res , result , { mergeWithLastSubmission : true } ) ;
2023-03-14 14:19:29 +01:00
}
2025-06-11 20:40:18 +02:00
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 ) ;
2023-03-10 17:13:33 +01:00
}
2026-02-17 19:30:40 +01:00
async function getClaims ( username , use , scope , clientId ) {
2025-07-01 22:07:31 +02:00
const [ error , user ] = await safe ( users . getByUsername ( username ) ) ;
2023-03-14 12:24:35 +01:00
if ( error ) return { error : 'user not found' } ;
2024-09-19 13:49:25 +04:00
const [ groupsError , allGroups ] = await safe ( groups . listWithMembers ( ) ) ;
2024-10-18 22:05:52 +02:00
if ( groupsError ) return { error : groupsError . message } ;
2024-09-19 13:49:25 +04:00
2023-03-14 12:24:35 +01:00
const displayName = user . displayName || user . username || '' ; // displayName can be empty and username can be null
2024-02-06 16:43:05 +01:00
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 ( ) ;
2024-02-06 16:43:05 +01:00
// 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 ,
2024-02-06 16:43:05 +01:00
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
2024-09-19 13:49:25 +04:00
preferred _username : user . username ,
2026-02-17 14:06:40 +01:00
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
2026-02-25 16:07:43 +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
}
2023-03-21 14:39:58 +01:00
async function start ( ) {
2025-06-11 20:40:18 +02:00
assert ( gHttpServer === null , 'OIDC server already started' ) ;
assert ( gOidcProvider === null , 'OIDC provider already started' ) ;
2023-10-01 13:26:43 +05:30
2023-03-21 14:39:58 +01:00
const app = express ( ) ;
gHttpServer = http . createServer ( app ) ;
2023-03-10 17:13:33 +01:00
2023-04-04 11:32:32 +02:00
const jwksKeys = [ ] ;
let keyEdDsa = await blobs . getString ( blobs . OIDC _KEY _EDDSA ) ;
if ( ! keyEdDsa ) {
2026-03-12 22:55:28 +05:30
log ( 'Generating new OIDC EdDSA key' ) ;
2025-06-06 16:23:11 +02:00
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
2023-04-04 11:32:32 +02:00
await blobs . setString ( blobs . OIDC _KEY _EDDSA , JSON . stringify ( keyEdDsa ) ) ;
jwksKeys . push ( keyEdDsa ) ;
} else {
2026-03-12 22:55:28 +05:30
log ( 'Using existing OIDC EdDSA key' ) ;
2023-04-04 11:32:32 +02:00
jwksKeys . push ( JSON . parse ( keyEdDsa ) ) ;
}
let keyRs256 = await blobs . getString ( blobs . OIDC _KEY _RS256 ) ;
if ( ! keyRs256 ) {
2026-03-12 22:55:28 +05:30
log ( 'Generating new OIDC RS256 key' ) ;
2025-06-06 16:23:11 +02:00
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
2023-04-04 11:32:32 +02:00
await blobs . setString ( blobs . OIDC _KEY _RS256 , JSON . stringify ( keyRs256 ) ) ;
jwksKeys . push ( keyRs256 ) ;
2023-03-23 18:02:45 +01:00
} else {
2026-03-12 22:55:28 +05:30
log ( 'Using existing OIDC RS256 key' ) ;
2023-04-04 11:32:32 +02:00
jwksKeys . push ( JSON . parse ( keyRs256 ) ) ;
2023-03-23 18:02:45 +01:00
}
2023-08-02 20:01:29 +05:30
let cookieSecret = await settings . get ( settings . OIDC _COOKIE _SECRET _KEY ) ;
2023-07-25 12:36:32 +02:00
if ( ! cookieSecret ) {
2026-03-12 22:55:28 +05:30
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 ) ;
2023-07-25 12:36:32 +02:00
}
2023-03-08 16:41:59 +01:00
const configuration = {
2025-06-11 23:26:22 +02:00
findAccount : async function ( ctx , id ) {
2026-02-17 19:30:40 +01:00
const clientId = ctx . oidc . client ? . clientId ;
2025-06-11 21:02:36 +02:00
return {
accountId : id ,
2026-02-17 19:30:40 +01:00
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 ) ;
} ,
2025-06-11 20:42:16 +02:00
adapter : StorageAdapter ,
2023-03-09 20:17:27 +01:00
interactions : {
2025-06-11 23:26:22 +02:00
url : async function ( ctx , interaction ) {
2023-03-21 14:39:58 +01:00
return ` ${ ROUTE _PREFIX } /interaction/ ${ interaction . uid } ` ;
2023-03-09 20:17:27 +01:00
}
2023-03-10 16:07:45 +01:00
} ,
2023-03-23 18:02:45 +01:00
jwks : {
2025-06-06 16:23:11 +02:00
keys : jwksKeys
2023-03-23 18:02:45 +01:00
} ,
2023-03-16 16:42:18 +01:00
claims : {
email : [ 'email' , 'email_verified' ] ,
2024-09-19 13:49:25 +04:00
profile : [ 'family_name' , 'given_name' , 'locale' , 'name' , 'preferred_username' , 'picture' ] ,
2026-02-17 14:06:40 +01:00
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 : {
2024-04-04 10:41:00 +02:00
rpInitiatedLogout : { enabled : false } ,
2026-02-18 14:59:59 +01:00
jwtIntrospection : { enabled : true } ,
2026-02-19 17:49:13 +01:00
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 ;
2026-02-19 19:09:02 +01:00
const internalClient = await oidcClients . get ( ctx . oidc . client . clientId ) ;
2026-02-19 17:49:13 +01:00
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 ) ;
2026-02-19 19:09:02 +01:00
return apps . canAccess ( internalApp , user ) ;
2026-02-19 17:49:13 +01:00
}
// unknown app
if ( internalClient . appId ) return false ;
return true ;
}
} ,
2026-03-13 12:40:06 +05:30
devInteractions : { enabled : false } ,
2026-03-13 13:21:01 +05:30
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
2026-03-13 13:21:01 +05:30
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
} ) ;
2026-03-13 13:21:01 +05:30
} ,
userCodeConfirmSource : async function ( ctx , form , client , deviceInfo , userCode ) {
2026-03-18 14:37:15 +01:00
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 ( ) ,
2026-03-18 14:37:15 +01:00
clientName ,
2026-03-18 11:41:05 +01:00
userCode ,
form
} ) ;
2026-03-13 13:21:01 +05:30
} ,
successSource : async function ( ctx ) {
2026-03-18 11:41:05 +01:00
ctx . body = ejs . render ( TEMPLATE _DEVICE _SUCCESS , {
name : await branding . getCloudronName ( )
} ) ;
2026-03-13 13:21:01 +05:30
} ,
} ,
2023-03-11 17:22:27 +01:00
} ,
2024-09-20 15:51:38 +04:00
clientDefaults : {
response _types : [ 'code' , 'id_token' ] ,
2024-11-18 18:04:46 +01:00
grant _types : [ 'authorization_code' , 'implicit' , 'refresh_token' ]
2024-09-20 15:51:38 +04:00
} ,
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 : {
2023-07-25 12:36:32 +02:00
keys : [ cookieSecret ]
2023-03-14 14:58:09 +01:00
} ,
2023-03-08 16:41:59 +01:00
pkce : {
2026-03-14 22:06:17 +05:30
required : function pkceRequired ( ctx , client ) {
return client . clientId === 'cid-webadmin' || client . clientId === 'cid-development' ;
2023-03-08 16:41:59 +01:00
}
2023-03-14 10:47:01 +01:00
} ,
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
2026-03-16 07:21:55 +05:30
if ( client . redirectUris . find ( ( u ) => origin === '*' || u . indexOf ( origin ) === 0 ) ) return true ;
2024-04-11 15:51:20 +02:00
return false ;
} ,
2023-06-14 16:45:51 +02:00
conformIdTokenClaims : false ,
2023-06-04 13:42:28 +02:00
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 ) ;
2023-06-04 13:42:28 +02:00
2025-07-10 13:06:13 +02:00
// 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 ;
2023-06-04 13:42:28 +02:00
} ,
2024-10-29 16:20:53 +01:00
// 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' ) ;
} ,
2023-03-14 10:47:01 +01:00
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
2024-11-18 15:29:12 +05:30
Interaction : 3600 , // 1 hour
2026-03-13 12:40:06 +05:30
DeviceCode : 600 , // 10 minutes
2024-11-18 15:29:12 +05:30
RefreshToken : 1209600 // 14 days
2023-03-08 16:41:59 +01:00
}
} ;
2023-08-14 11:08:38 +05:30
const { subdomain , domain } = await dashboard . getLocation ( ) ;
const fqdn = dns . fqdn ( subdomain , domain ) ;
2026-03-12 22:55:28 +05:30
log ( ` start: create provider for ${ fqdn } at ${ ROUTE _PREFIX } ` ) ;
2025-06-11 20:40:18 +02:00
gOidcProvider = new Provider ( ` https:// ${ fqdn } ${ ROUTE _PREFIX } ` , configuration ) ;
2023-03-21 14:39:58 +01:00
app . enable ( 'trust proxy' ) ;
2025-06-11 20:40:18 +02:00
gOidcProvider . proxy = true ;
2023-03-08 16:41:59 +01:00
2024-07-19 22:11:30 +02:00
const json = express . json ( { strict : true , limit : '2mb' } ) ;
2023-03-21 14:39:58 +01:00
function setNoCache ( req , res , next ) {
res . set ( 'cache-control' , 'no-store' ) ;
next ( ) ;
}
2025-06-11 20:40:18 +02:00
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
2025-12-09 12:14:17 +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' ) ;
2025-12-09 12:51:21 +01:00
res . send ( { keys : rsaKeys } ) ; // https://github.com/panva/jose/discussions/654
2025-12-09 12:14:17 +01:00
} ) ;
2025-06-11 20:40:18 +02:00
app . use ( ROUTE _PREFIX , gOidcProvider . callback ( ) ) ;
2023-05-12 14:31:26 +02:00
app . use ( middleware . lastMile ( ) ) ;
2023-03-21 14:39:58 +01:00
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 ( ) {
2023-03-21 14:39:58 +01:00
if ( ! gHttpServer ) return ;
await util . promisify ( gHttpServer . close . bind ( gHttpServer ) ) ( ) ;
gHttpServer = null ;
2025-06-11 20:40:18 +02:00
gOidcProvider = null ;
2023-03-21 13:54:40 +01:00
}
2026-02-14 15:43:24 +01:00
export default {
start ,
stop ,
revokeByUsername ,
consumeAuthCode ,
cleanupExpired ,
} ;