2023-03-08 16:41:59 +01:00
'use strict' ;
exports = module . exports = {
2023-03-21 13:54:40 +01:00
start ,
stop ,
2025-07-01 22:07:31 +02:00
revokeByUsername ,
2024-04-15 12:35:03 +02:00
consumeAuthCode ,
2024-12-02 08:31:35 +01:00
2025-03-05 11:29:13 +01:00
cleanupExpired ,
2023-03-08 16:41:59 +01:00
} ;
2025-08-14 11:17:38 +05:30
const assert = require ( 'node:assert' ) ,
2023-04-14 21:18:44 +02:00
apps = require ( './apps.js' ) ,
2023-08-26 08:18:58 +05:30
AuditSource = require ( './auditsource.js' ) ,
2023-03-16 15:37:03 +01:00
BoxError = require ( './boxerror.js' ) ,
2023-03-23 18:02:45 +01:00
blobs = require ( './blobs.js' ) ,
2023-08-02 21:01:11 +05:30
branding = require ( './branding.js' ) ,
2023-03-21 14:46:09 +01:00
constants = require ( './constants.js' ) ,
2025-08-14 11:17:38 +05:30
crypto = require ( 'node:crypto' ) ,
2023-08-11 19:41:05 +05:30
dashboard = require ( './dashboard.js' ) ,
2025-06-11 22:00:09 +02:00
debug = require ( 'debug' ) ( 'box:oidcserver' ) ,
2023-08-14 11:08:38 +05:30
dns = require ( './dns.js' ) ,
2025-07-11 14:26:57 +02:00
ejs = require ( 'ejs' ) ,
2023-03-21 14:46:09 +01:00
express = require ( 'express' ) ,
2023-07-24 19:24:03 +02:00
eventlog = require ( './eventlog.js' ) ,
2025-08-14 11:17:38 +05:30
fs = require ( 'node:fs' ) ,
2023-08-11 13:36:46 +02:00
marked = require ( 'marked' ) ,
2023-03-14 10:47:01 +01:00
middleware = require ( './middleware' ) ,
2025-06-11 22:00:09 +02:00
oidcClients = require ( './oidcclients.js' ) ,
2025-08-14 11:17:38 +05:30
path = require ( 'node:path' ) ,
2023-03-08 16:41:59 +01:00
paths = require ( './paths.js' ) ,
2025-08-14 11:17:38 +05:30
http = require ( 'node:http' ) ,
2025-07-10 11:00:31 +02:00
HttpError = require ( '@cloudron/connect-lastmile' ) . HttpError ,
2023-03-23 18:02:45 +01:00
jose = require ( 'jose' ) ,
2023-03-13 17:01:52 +01:00
safe = require ( 'safetydance' ) ,
2023-03-21 14:46:09 +01:00
settings = require ( './settings.js' ) ,
2023-06-02 20:47:36 +02:00
tokens = require ( './tokens.js' ) ,
2025-08-14 11:17:38 +05:30
url = require ( 'node:url' ) ,
2023-03-21 14:46:09 +01:00
users = require ( './users.js' ) ,
2024-09-19 13:49:25 +04:00
groups = require ( './groups.js' ) ,
2025-08-14 11:17:38 +05:30
util = require ( 'node:util' ) ;
2023-03-08 16:41:59 +01:00
2025-07-10 13:06:13 +02:00
// 1. Index.vue starts the OIDC flow by navigating to /openid/auth. Webadmin sets callback url to authcallback.html + implicit flow
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().
// FIXME: webadmin's implicit flow (response_type=code token) results in authcallback.html being called with access_token query param. We should remove this
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 ) {
2024-11-18 17:17:22 +01:00
debug ( ` 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 ) {
2024-11-18 17:17:22 +01:00
debug ( ` [ ${ this . name } ] upsert: ${ id } ` ) ;
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
if ( this . name === 'AccessToken' && ( payload . clientId === oidcClients . ID _WEBADMIN || payload . clientId === oidcClients . ID _DEVELOPMENT ) ) {
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 ) {
2025-10-07 09:35:54 +02:00
debug ( '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 ) {
2024-11-18 17:17:22 +01:00
debug ( ` [ ${ this . name } ] find: ${ id } ` ) ;
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 ) {
2023-04-30 10:18:39 +02:00
debug ( '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 ;
2023-04-14 21:18:44 +02:00
if ( client . appId ) {
const [ error , app ] = await safe ( apps . get ( client . appId ) ) ;
if ( error || ! app ) {
2023-04-30 10:18:39 +02:00
debug ( ` 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 ) => {
if ( url . parse ( s ) . protocol ) tmp . redirect _uris . push ( s ) ;
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 ) {
debug ( ` [ ${ this . name } ] FIXME findByUserCode userCode: ${ userCode } ` ) ;
}
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 ) {
2024-11-18 17:17:22 +01:00
debug ( ` [ ${ this . name } ] findByUid: ${ uid } ` ) ;
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 ) {
2023-06-04 16:03:45 +02:00
debug ( ` [ ${ this . name } ] consume: ${ id } ` ) ;
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 ) {
2024-11-18 17:17:22 +01:00
debug ( ` [ ${ 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 ) {
2023-06-04 16:03:45 +02:00
debug ( ` [ ${ 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
}
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 ( ) {
debug ( 'cleanupExpired' ) ;
const types = [ 'AuthorizationCode' , 'AccessToken' , 'Grant' , 'Interaction' , 'RefreshToken' , 'Session' ] ;
for ( const type of types ) {
await StorageAdapter . updateData ( type , ( data ) => {
for ( const key in data ) {
if ( ! data [ key ] . expiresAt || data [ key ] . expiresAt < Date . now ( ) ) delete data [ key ] ;
}
} ) ;
2023-03-08 16:41:59 +01:00
}
}
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
} ;
debug ( '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 ` ,
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' ) {
let hasAccess = false ;
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
2025-06-11 20:40:18 +02:00
debug ( ` 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' ) ) ;
if ( 'totpToken' in req . body && typeof req . body . totpToken !== 'string' ) return next ( new HttpError ( 400 , 'totpToken must be a string' ) ) ;
const { username , password , totpToken } = req . body ;
const verifyFunc = username . indexOf ( '@' ) === - 1 ? users . verifyWithUsername : users . verifyWithEmail ;
const [ verifyError , user ] = await safe ( verifyFunc ( username , password , users . AP _WEBADMIN , { totpToken , skipTotpCheck : false } ) ) ;
if ( verifyError && verifyError . reason === BoxError . INVALID _CREDENTIALS ) return next ( new HttpError ( 401 , verifyError . message ) ) ;
if ( verifyError && verifyError . reason === BoxError . NOT _FOUND ) return next ( new HttpError ( 401 , 'Username and password does not match' ) ) ;
if ( verifyError ) return next ( new HttpError ( 500 , verifyError ) ) ;
if ( ! user ) return next ( new HttpError ( 401 , 'Username and password does not match' ) ) ;
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
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
2025-06-11 23:38:32 +02:00
debug ( ` 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 } ) ;
}
}
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-06-11 23:38:32 +02:00
await eventlog . add ( user . ghost ? eventlog . ACTION _USER _LOGIN _GHOST : eventlog . ACTION _USER _LOGIN , auditSource , { userId : user . id , user : users . removePrivateFields ( user ) , appId : params . client _id } ) ;
await users . notifyLoginLocation ( user , ip , userAgent , auditSource ) ;
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
}
2025-07-01 22:07:31 +02:00
async function getClaims ( username /*, use, scope*/ ) {
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 ,
2024-10-18 22:05:52 +02:00
picture : ` https:// ${ dashboardFqdn } /api/v1/profile/avatar/ ${ user . id } .png ` , // some apps get surprised if we respond with a svg
2024-09-19 13:49:25 +04:00
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
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 ) {
2023-03-23 18:02:45 +01:00
debug ( 'Generating new OIDC EdDSA key' ) ;
2025-06-06 16:23:11 +02:00
const { privateKey } = await jose . generateKeyPair ( 'EdDSA' , { extractable : true } ) ;
2023-04-04 11:32:32 +02:00
keyEdDsa = await jose . exportJWK ( privateKey ) ;
await blobs . setString ( blobs . OIDC _KEY _EDDSA , JSON . stringify ( keyEdDsa ) ) ;
jwksKeys . push ( keyEdDsa ) ;
} else {
debug ( 'Using existing OIDC EdDSA key' ) ;
jwksKeys . push ( JSON . parse ( keyEdDsa ) ) ;
}
let keyRs256 = await blobs . getString ( blobs . OIDC _KEY _RS256 ) ;
if ( ! keyRs256 ) {
2023-04-04 15:38:45 +02:00
debug ( 'Generating new OIDC RS256 key' ) ;
2025-06-06 16:23:11 +02:00
const { privateKey } = await jose . generateKeyPair ( 'RS256' , { extractable : true } ) ;
2023-04-04 11:32:32 +02:00
keyRs256 = await jose . exportJWK ( privateKey ) ;
await blobs . setString ( blobs . OIDC _KEY _RS256 , JSON . stringify ( keyRs256 ) ) ;
jwksKeys . push ( keyRs256 ) ;
2023-03-23 18:02:45 +01:00
} else {
2023-04-04 15:38:45 +02:00
debug ( '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 ) {
debug ( '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 ) {
2025-06-11 21:02:36 +02:00
return {
accountId : id ,
claims : async ( use , scope ) => await getClaims ( id , use , scope )
} ;
} ,
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' ] ,
groups : [ 'groups' ]
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 } ,
2023-07-20 13:26:07 +02:00
devInteractions : { enabled : false }
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 : {
2023-04-30 10:18:39 +02:00
required : function pkceRequired ( /*ctx, client*/ ) {
2023-03-08 16:41:59 +01:00
return false ;
}
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
if ( client . redirectUris . find ( ( u ) => u . indexOf ( origin ) === 0 ) ) return true ;
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
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 ) ;
debug ( ` start: create provider for ${ fqdn } at ${ ROUTE _PREFIX } ` ) ;
2025-06-11 20:40:18 +02:00
const Provider = ( await import ( 'oidc-provider' ) ) . default ;
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 ) ;
app . post ( ` ${ ROUTE _PREFIX } /interaction/:uid/login ` , setNoCache , json , interactionLogin ) ;
app . post ( ` ${ ROUTE _PREFIX } /interaction/:uid/confirm ` , setNoCache , json , interactionConfirm ) ;
app . get ( ` ${ ROUTE _PREFIX } /interaction/:uid/abort ` , setNoCache , interactionAbort ) ;
2023-03-21 13:54:40 +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
}