2023-03-08 16:41:59 +01:00
'use strict' ;
exports = module . exports = {
2023-03-21 13:54:40 +01:00
start ,
stop ,
2023-03-24 20:08:17 +01:00
revokeByUserId ,
2023-03-16 15:37:03 +01:00
clients : {
add : clientsAdd ,
get : clientsGet ,
del : clientsDel ,
update : clientsUpdate ,
list : clientsList
2023-03-14 14:19:29 +01:00
}
2023-03-08 16:41:59 +01:00
} ;
2023-03-10 17:13:33 +01:00
const assert = require ( 'assert' ) ,
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-03-21 14:46:09 +01:00
constants = require ( './constants.js' ) ,
2023-03-16 15:37:03 +01:00
database = require ( './database.js' ) ,
2023-03-10 17:13:33 +01:00
debug = require ( 'debug' ) ( 'box:oidc' ) ,
2023-03-17 14:45:45 +01:00
ejs = require ( 'ejs' ) ,
2023-03-21 14:46:09 +01:00
express = require ( 'express' ) ,
2023-03-08 16:41:59 +01:00
fs = require ( 'fs' ) ,
2023-03-14 10:47:01 +01:00
middleware = require ( './middleware' ) ,
2023-03-08 16:41:59 +01:00
path = require ( 'path' ) ,
paths = require ( './paths.js' ) ,
2023-03-21 14:46:09 +01:00
http = require ( 'http' ) ,
2023-03-13 17:01:52 +01:00
HttpError = require ( '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' ) ,
users = require ( './users.js' ) ,
util = require ( 'util' ) ;
2023-03-08 16:41:59 +01:00
2023-03-16 15:37:03 +01:00
const OIDC _CLIENTS _TABLE _NAME = 'oidcClients' ;
2023-04-04 15:38:45 +02:00
const OIDC _CLIENTS _FIELDS = [ 'id' , 'secret' , 'name' , 'appId' , 'loginRedirectUri' , 'logoutRedirectUri' , 'tokenSignatureAlgorithm' ] ;
2023-03-16 15:37:03 +01:00
2023-03-21 14:39:58 +01:00
const ROUTE _PREFIX = '/openid' ;
2023-04-04 15:38:45 +02:00
const DEFAULT _TOKEN _SIGNATURE _ALGORITHM = 'RS256' ;
2023-03-21 14:39:58 +01:00
let gHttpServer = null ;
2023-03-23 09:27:40 +01:00
// -----------------------------
// Database model
// -----------------------------
2023-04-04 15:38:45 +02:00
function postProcess ( result ) {
assert . strictEqual ( typeof result , 'object' ) ;
result . tokenSignatureAlgorithm = result . tokenSignatureAlgorithm || DEFAULT _TOKEN _SIGNATURE _ALGORITHM ;
return result ;
}
2023-03-23 09:27:40 +01:00
async function clientsAdd ( id , data ) {
2023-03-16 15:37:03 +01:00
assert . strictEqual ( typeof id , 'string' ) ;
2023-03-23 09:27:40 +01:00
assert . strictEqual ( typeof data . secret , 'string' ) ;
assert . strictEqual ( typeof data . loginRedirectUri , 'string' ) ;
assert . strictEqual ( typeof data . logoutRedirectUri , 'string' ) ;
assert . strictEqual ( typeof data . name , 'string' ) ;
assert . strictEqual ( typeof data . appId , 'string' ) ;
2023-04-04 15:38:45 +02:00
assert ( data . tokenSignatureAlgorithm === 'RS256' || data . tokenSignatureAlgorithm === 'EdDSA' ) ;
2023-03-16 15:37:03 +01:00
2023-04-04 15:38:45 +02:00
debug ( ` clientsAdd: id: ${ id } secret: ${ data . secret } name: ${ data . name } appId: ${ data . appId } loginRedirectUri: ${ data . loginRedirectUri } logoutRedirectUri: ${ data . logoutRedirectUri } tokenSignatureAlgorithm: ${ data . tokenSignatureAlgorithm } ` ) ;
2023-03-21 19:39:13 +01:00
2023-04-04 15:38:45 +02:00
const query = ` INSERT INTO ${ OIDC _CLIENTS _TABLE _NAME } (id, secret, name, appId, loginRedirectUri, logoutRedirectUri, tokenSignatureAlgorithm) VALUES (?, ?, ?, ?, ?, ?, ?) ` ;
const args = [ id , data . secret , data . name , data . appId , data . loginRedirectUri , data . logoutRedirectUri , data . tokenSignatureAlgorithm ] ;
2023-03-16 15:37:03 +01:00
const [ error ] = await safe ( database . query ( query , args ) ) ;
if ( error && error . code === 'ER_DUP_ENTRY' ) throw new BoxError ( BoxError . ALREADY _EXISTS , 'client already exists' ) ;
if ( error ) throw error ;
}
async function clientsGet ( id ) {
assert . strictEqual ( typeof id , 'string' ) ;
debug ( ` clientsGet: id: ${ id } ` ) ;
const result = await database . query ( ` SELECT ${ OIDC _CLIENTS _FIELDS } FROM ${ OIDC _CLIENTS _TABLE _NAME } WHERE id = ? ` , [ id ] ) ;
if ( result . length === 0 ) return null ;
2023-04-04 15:38:45 +02:00
return postProcess ( result [ 0 ] ) ;
2023-03-16 15:37:03 +01:00
}
2023-03-23 09:27:40 +01:00
async function clientsUpdate ( id , data ) {
2023-03-16 15:37:03 +01:00
assert . strictEqual ( typeof id , 'string' ) ;
2023-03-23 09:27:40 +01:00
assert . strictEqual ( typeof data . secret , 'string' ) ;
assert . strictEqual ( typeof data . loginRedirectUri , 'string' ) ;
assert . strictEqual ( typeof data . logoutRedirectUri , 'string' ) ;
assert . strictEqual ( typeof data . name , 'string' ) ;
assert . strictEqual ( typeof data . appId , 'string' ) ;
2023-04-04 15:38:45 +02:00
assert ( data . tokenSignatureAlgorithm === 'RS256' || data . tokenSignatureAlgorithm === 'EdDSA' ) ;
2023-03-16 15:37:03 +01:00
2023-04-04 15:38:45 +02:00
debug ( ` clientsUpdate: id: ${ id } secret: ${ data . secret } name: ${ data . name } appId: ${ data . appId } loginRedirectUri: ${ data . loginRedirectUri } logoutRedirectUri: ${ data . logoutRedirectUri } tokenSignatureAlgorithm: ${ data . tokenSignatureAlgorithm } ` ) ;
2023-03-23 09:27:40 +01:00
2023-04-04 15:38:45 +02:00
const result = await database . query ( ` UPDATE ${ OIDC _CLIENTS _TABLE _NAME } SET secret=?, name=?, appId=?, loginRedirectUri=?, logoutRedirectUri=?, tokenSignatureAlgorithm=? WHERE id = ? ` , [ data . secret , data . name , data . appId , data . loginRedirectUri , data . logoutRedirectUri , data . tokenSignatureAlgorithm , id ] ) ;
2023-03-16 15:37:03 +01:00
if ( result . affectedRows !== 1 ) throw new BoxError ( BoxError . NOT _FOUND , 'client not found' ) ;
}
async function clientsDel ( id ) {
assert . strictEqual ( typeof id , 'string' ) ;
const result = await database . query ( ` DELETE FROM ${ OIDC _CLIENTS _TABLE _NAME } WHERE id = ? ` , [ id ] ) ;
if ( result . affectedRows !== 1 ) throw new BoxError ( BoxError . NOT _FOUND , 'client not found' ) ;
}
async function clientsList ( ) {
2023-03-21 17:40:06 +01:00
const results = await database . query ( ` SELECT * FROM ${ OIDC _CLIENTS _TABLE _NAME } ORDER BY id ASC ` , [ ] ) ;
2023-04-04 15:38:45 +02:00
results . forEach ( postProcess ) ;
2023-03-16 15:37:03 +01:00
return results ;
}
2023-03-14 14:58:09 +01:00
2023-03-24 20:08:17 +01:00
// -----------------------------
// Basic in-memory json file backed based data store
// -----------------------------
const DATA _STORE = { } ;
function load ( modelName ) {
assert . strictEqual ( typeof modelName , 'string' ) ;
if ( DATA _STORE [ modelName ] ) return ;
const filePath = path . join ( paths . OIDC _STORE _DIR , ` ${ modelName } .json ` ) ;
debug ( ` load: model ${ modelName } based on ${ filePath } . ` ) ;
let data = { } ;
try {
data = JSON . parse ( fs . readFileSync ( filePath ) , 'utf8' ) ;
} catch ( e ) {
debug ( ` load: failed to read ${ filePath } , start with new one. ` , e ) ;
}
DATA _STORE [ modelName ] = data ;
}
function save ( modelName ) {
assert . strictEqual ( typeof modelName , 'string' ) ;
if ( ! DATA _STORE [ modelName ] ) return ;
const filePath = path . join ( paths . OIDC _STORE _DIR , ` ${ modelName } .json ` ) ;
debug ( ` save: model ${ modelName } to ${ filePath } . ` ) ;
try {
fs . writeFileSync ( filePath , JSON . stringify ( DATA _STORE [ modelName ] ) , 'utf8' ) ;
} catch ( e ) {
debug ( ` revokeByUserId: failed to write ${ filePath } ` , e ) ;
}
}
// -----------------------------
// Session, Grant and Token management
// -----------------------------
// This is based on the same storage as the below CloudronAdapter
async function revokeByUserId ( userId ) {
assert . strictEqual ( typeof userId , 'string' ) ;
debug ( ` revokeByUserId: userId: ${ userId } ` ) ;
function revokeObjects ( modelName ) {
load ( modelName ) ;
for ( let id in DATA _STORE [ modelName ] ) {
if ( DATA _STORE [ modelName ] [ id ] . payload ? . accountId === userId ) delete DATA _STORE [ modelName ] [ id ] ;
}
save ( modelName ) ;
}
revokeObjects ( 'Session' ) ;
revokeObjects ( 'Grant' ) ;
revokeObjects ( 'AuthorizationCode' ) ;
revokeObjects ( 'AccessToken' ) ;
}
2023-03-23 09:27:40 +01:00
// -----------------------------
// Generic oidc node module data store model
// -----------------------------
2023-03-08 16:41:59 +01:00
class CloudronAdapter {
2023-03-21 14:46:09 +01:00
/ * *
*
* Creates an instance of MyAdapter for an oidc - provider model .
*
* @ constructor
* @ param { string } name Name of the oidc - provider model . One of "Grant, " Session ", " AccessToken " ,
* "AuthorizationCode" , "RefreshToken" , "ClientCredentials" , "Client" , "InitialAccessToken" ,
* "RegistrationAccessToken" , "DeviceCode" , "Interaction" , "ReplayDetection" ,
* "BackchannelAuthenticationRequest" , or "PushedAuthorizationRequest"
*
* /
2023-03-08 16:41:59 +01:00
constructor ( name ) {
this . name = name ;
2023-03-24 20:08:17 +01:00
debug ( ` Creating storage adapter for ${ name } ` ) ;
2023-03-14 14:58:09 +01:00
2023-03-24 20:08:17 +01:00
if ( this . name !== 'Client' ) {
load ( name ) ;
2023-03-14 14:58:09 +01:00
}
2023-03-08 16:41:59 +01:00
}
2023-03-21 14:46:09 +01:00
/ * *
*
* Update or Create an instance of an oidc - provider model .
*
* @ return { Promise } Promise fulfilled when the operation succeeded . Rejected with error when
* encountered .
* @ param { string } id Identifier that oidc - provider will use to reference this model instance for
* future operations .
* @ param { object } payload Object with all properties intended for storage .
* @ param { integer } expiresIn Number of seconds intended for this model to be stored .
*
* /
2023-03-08 16:41:59 +01:00
async upsert ( id , payload , expiresIn ) {
debug ( ` [ ${ this . name } ] upsert id: ${ id } expiresIn: ${ expiresIn } ` , payload ) ;
2023-03-16 15:37:03 +01:00
if ( this . name === 'Client' ) {
console . log ( 'WARNING!! this should not happen as it is stored in our db' ) ;
} else {
2023-03-24 20:08:17 +01:00
DATA _STORE [ this . name ] [ id ] = { id , expiresIn , payload , consumed : false } ;
save ( this . name ) ;
2023-03-16 15:37:03 +01:00
}
2023-03-08 16:41:59 +01:00
}
2023-03-21 14:46:09 +01:00
/ * *
*
* Return previously stored instance of an oidc - provider model .
*
* @ return { Promise } Promise fulfilled with what was previously stored for the id ( when found and
* not dropped yet due to expiration ) or falsy value when not found anymore . Rejected with error
* when encountered .
* @ param { string } id Identifier of oidc - provider model
*
* /
2023-03-08 16:41:59 +01:00
async find ( id ) {
debug ( ` [ ${ this . name } ] find id: ${ id } ` ) ;
2023-03-16 15:37:03 +01:00
if ( this . name === 'Client' ) {
const [ error , client ] = await safe ( clientsGet ( id ) ) ;
2023-03-21 15:23:45 +01:00
if ( error ) {
console . log ( 'Error getting client' , error ) ;
return null ;
}
if ( ! client ) return null ;
2023-03-16 15:37:03 +01:00
debug ( ` [ ${ this . name } ] find id: ${ id } ` , client ) ;
2023-03-22 11:12:50 +01:00
const tmp = {
2023-03-16 15:37:03 +01:00
client _id : id ,
client _secret : client . secret ,
2023-04-06 13:28:24 +02:00
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-06 12:42:51 +02:00
redirect _uris : client . loginRedirectUri . split ( ',' ) . map ( s => s . trim ( ) ) ,
2023-04-04 15:38:45 +02:00
id _token _signed _response _alg : client . tokenSignatureAlgorithm || 'RS256'
2023-03-16 15:37:03 +01:00
} ;
2023-03-22 11:12:50 +01:00
if ( client . logoutRedirectUri ) tmp . post _logout _redirect _uris = [ client . logoutRedirectUri ] ;
return tmp ;
2023-03-16 15:37:03 +01:00
} else {
2023-03-24 20:08:17 +01:00
if ( ! DATA _STORE [ this . name ] [ id ] ) return null ;
2023-03-08 16:41:59 +01:00
2023-03-24 20:08:17 +01:00
debug ( ` [ ${ this . name } ] find id: ${ id } ` , DATA _STORE [ this . name ] [ id ] ) ;
2023-03-14 14:58:09 +01:00
2023-03-24 20:08:17 +01:00
return DATA _STORE [ this . name ] [ id ] . payload ;
2023-03-16 15:37:03 +01:00
}
2023-03-08 16:41:59 +01:00
}
2023-03-21 14:46:09 +01:00
/ * *
*
* Return previously stored instance of DeviceCode by the end - user entered user code . You only
* need this method for the deviceFlow feature
*
* @ return { Promise } Promise fulfilled with the stored device code object ( when found and not
* dropped yet due to expiration ) or falsy value when not found anymore . Rejected with error
* when encountered .
* @ param { string } userCode the user _code value associated with a DeviceCode instance
*
* /
2023-03-08 16:41:59 +01:00
async findByUserCode ( userCode ) {
debug ( ` [ ${ this . name } ] FIXME findByUserCode userCode: ${ userCode } ` ) ;
}
2023-03-21 14:46:09 +01:00
/ * *
*
* Return previously stored instance of Session by its uid reference property .
*
* @ return { Promise } Promise fulfilled with the stored session object ( when found and not
* dropped yet due to expiration ) or falsy value when not found anymore . Rejected with error
* when encountered .
* @ param { string } uid the uid value associated with a Session instance
*
* /
2023-03-08 16:41:59 +01:00
async findByUid ( uid ) {
debug ( ` [ ${ this . name } ] findByUid uid: ${ uid } ` ) ;
2023-03-16 15:37:03 +01:00
if ( this . name === 'Client' ) {
console . log ( 'WARNING!! this should not happen as it is stored in our db' ) ;
} else {
2023-03-24 20:08:17 +01:00
for ( let d in DATA _STORE [ this . name ] ) {
if ( DATA _STORE [ this . name ] [ d ] . payload . uid === uid ) return DATA _STORE [ this . name ] [ d ] . payload ;
2023-03-16 15:37:03 +01:00
}
2023-03-08 16:41:59 +01:00
2023-03-16 15:37:03 +01:00
return false ;
}
2023-03-08 16:41:59 +01:00
}
2023-03-21 14:46:09 +01:00
/ * *
*
* Mark a stored oidc - provider model as consumed ( not yet expired though ! ) . Future finds for this
* id should be fulfilled with an object containing additional property named "consumed" with a
* truthy value ( timestamp , date , boolean , etc ) .
*
* @ return { Promise } Promise fulfilled when the operation succeeded . Rejected with error when
* encountered .
* @ param { string } id Identifier of oidc - provider model
*
* /
2023-03-08 16:41:59 +01:00
async consume ( id ) {
2023-03-09 18:59:04 +01:00
debug ( ` [ ${ this . name } ] consume id: ${ id } ` ) ;
2023-03-16 15:37:03 +01:00
if ( this . name === 'Client' ) {
console . log ( 'WARNING!! this should not happen as it is stored in our db' ) ;
} else {
2023-03-24 20:08:17 +01:00
if ( DATA _STORE [ this . name ] [ id ] ) DATA _STORE [ this . name ] [ id ] . consumed = true ;
save ( this . name ) ;
2023-03-16 15:37:03 +01:00
}
2023-03-08 16:41:59 +01:00
}
2023-03-21 14:46:09 +01:00
/ * *
*
* Destroy / Drop / Remove a stored oidc - provider model . Future finds for this id should be fulfilled
* with falsy values .
*
* @ return { Promise } Promise fulfilled when the operation succeeded . Rejected with error when
* encountered .
* @ param { string } id Identifier of oidc - provider model
*
* /
2023-03-08 16:41:59 +01:00
async destroy ( id ) {
debug ( ` [ ${ this . name } ] destroy id: ${ id } ` ) ;
2023-03-16 15:37:03 +01:00
if ( this . name === 'Client' ) {
console . log ( 'WARNING!! this should not happen as it is stored in our db' ) ;
} else {
2023-03-24 20:08:17 +01:00
delete DATA _STORE [ this . name ] [ id ] ;
save ( this . name ) ;
2023-03-16 15:37:03 +01:00
}
2023-03-08 16:41:59 +01:00
}
2023-03-21 14:46:09 +01:00
/ * *
*
* Destroy / Drop / Remove a stored oidc - provider model by its grantId property reference . Future
* finds for all tokens having this grantId value should be fulfilled with falsy values .
*
* @ return { Promise } Promise fulfilled when the operation succeeded . Rejected with error when
* encountered .
* @ param { string } grantId the grantId value associated with a this model ' s instance
*
* /
2023-03-08 16:41:59 +01:00
async revokeByGrantId ( grantId ) {
debug ( ` [ ${ this . name } ] revokeByGrantId grantId: ${ grantId } ` ) ;
2023-03-16 15:37:03 +01:00
if ( this . name === 'Client' ) {
console . log ( 'WARNING!! this should not happen as it is stored in our db' ) ;
} else {
2023-03-24 20:08:17 +01:00
for ( let d in DATA _STORE [ this . name ] ) {
if ( DATA _STORE [ this . name ] [ d ] . grantId === grantId ) {
delete DATA _STORE [ this . name ] [ d ] ;
return save ( this . name ) ;
2023-03-16 15:37:03 +01:00
}
2023-03-08 16:41:59 +01:00
}
}
}
}
2023-03-23 09:27:40 +01:00
// -----------------------------
// Route handler
// -----------------------------
2023-03-21 14:39:58 +01:00
function renderInteractionPage ( provider ) {
2023-03-10 17:13:33 +01:00
assert . strictEqual ( typeof provider , 'object' ) ;
2023-03-14 14:19:29 +01:00
return async function ( req , res , next ) {
2023-03-10 17:13:33 +01:00
try {
const { uid , prompt , params , session } = await provider . interactionDetails ( req , res ) ;
2023-03-13 19:08:41 +01:00
console . log ( 'details' , await provider . interactionDetails ( req , res ) ) ;
2023-03-10 17:13:33 +01:00
2023-03-11 17:22:27 +01:00
debug ( ` route interaction get uid: ${ uid } prompt.name: ${ prompt . name } client_id: ${ params . client _id } session: ${ session } ` ) ;
2023-03-10 17:13:33 +01:00
2023-03-23 10:00:17 +01:00
const [ error , client ] = await safe ( clientsGet ( params . client _id ) ) ;
if ( error ) return next ( error ) ;
2023-03-10 17:13:33 +01:00
switch ( prompt . name ) {
2023-03-21 14:46:09 +01:00
case 'login' : {
return res . render ( 'login' , {
submitUrl : ` ${ ROUTE _PREFIX } /interaction/ ${ uid } /login ` ,
2023-03-23 10:00:17 +01:00
name : client ? . name || 'Cloudron'
2023-03-21 14:46:09 +01:00
} ) ;
}
case 'consent' : {
return res . render ( 'interaction' , {
submitUrl : ` ${ ROUTE _PREFIX } /interaction/ ${ uid } /confirm ` ,
2023-03-23 10:00:17 +01:00
name : client ? . name || 'Cloudron'
2023-03-21 14:46:09 +01:00
} ) ;
}
default :
return undefined ;
2023-03-10 17:13:33 +01:00
}
2023-03-14 10:47:01 +01:00
} catch ( error ) {
2023-03-21 14:46:09 +01:00
debug ( 'route interaction get error' ) ;
2023-03-14 10:47:01 +01:00
console . log ( error ) ;
return next ( error ) ;
2023-03-10 17:13:33 +01:00
}
2023-03-14 14:19:29 +01:00
} ;
}
function interactionLogin ( provider ) {
assert . strictEqual ( typeof provider , 'object' ) ;
2023-03-10 17:13:33 +01:00
2023-03-14 14:19:29 +01:00
return async function ( req , res , next ) {
2023-03-13 19:08:41 +01:00
const [ detailsError , details ] = await safe ( provider . interactionDetails ( req , res ) ) ;
if ( detailsError ) return next ( new HttpError ( 500 , detailsError ) ) ;
2023-03-10 17:13:33 +01:00
2023-03-13 19:08:41 +01:00
const uid = details . uid ;
const prompt = details . prompt ;
const name = prompt . name ;
2023-03-10 17:13:33 +01:00
2023-03-13 19:08:41 +01:00
debug ( ` route interaction login post uid: ${ uid } prompt.name: ${ name } ` , req . body ) ;
2023-03-10 17:13:33 +01:00
2023-03-13 19:08:41 +01:00
assert . equal ( name , 'login' ) ;
2023-03-13 17:01:52 +01:00
2023-03-13 19:08:41 +01:00
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' ) ) ;
2023-03-13 17:01:52 +01:00
2023-03-13 19:08:41 +01:00
const { username , password , totpToken } = req . body ;
2023-03-13 17:01:52 +01:00
2023-03-13 19:08:41 +01:00
const verifyFunc = username . indexOf ( '@' ) === - 1 ? users . verifyWithUsername : users . verifyWithEmail ;
2023-03-13 17:01:52 +01:00
2023-03-13 19:08:41 +01:00
const [ verifyError , user ] = await safe ( verifyFunc ( username , password , users . AP _WEBADMIN , { totpToken } ) ) ;
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 , 'Unauthorized' ) ) ;
if ( verifyError ) return next ( new HttpError ( 500 , verifyError ) ) ;
if ( ! user ) return next ( new HttpError ( 401 , 'Unauthorized' ) ) ;
2023-03-10 17:13:33 +01:00
2023-03-13 19:08:41 +01:00
// TODO we may have to check what else the Account class provides, in which case we have to map those things
const result = {
login : {
accountId : user . id ,
} ,
} ;
2023-03-14 10:47:01 +01:00
const [ interactionFinishError , redirectTo ] = await safe ( provider . interactionResult ( req , res , result ) ) ;
2023-03-13 19:08:41 +01:00
if ( interactionFinishError ) return next ( new HttpError ( 500 , interactionFinishError ) ) ;
2023-03-14 10:47:01 +01:00
debug ( ` route interaction login post result redirectTo: ${ redirectTo } ` ) ;
res . status ( 200 ) . send ( { redirectTo } ) ;
2023-03-14 14:19:29 +01:00
} ;
}
function interactionConfirm ( provider ) {
assert . strictEqual ( typeof provider , 'object' ) ;
2023-03-10 17:13:33 +01:00
2023-03-14 14:19:29 +01:00
return async function ( req , res , next ) {
2023-03-10 17:13:33 +01:00
try {
const interactionDetails = await provider . interactionDetails ( req , res ) ;
const { uid , prompt : { name , details } , params , session : { accountId } } = interactionDetails ;
2023-03-11 17:22:27 +01:00
debug ( ` route interaction confirm post uid: ${ uid } prompt.name: ${ name } accountId: ${ accountId } ` ) ;
2023-03-10 17:13:33 +01:00
assert . equal ( name , 'consent' ) ;
let { grantId } = interactionDetails ;
let grant ;
if ( grantId ) {
// we'll be modifying existing grant in existing session
grant = await provider . Grant . find ( grantId ) ;
} else {
// we're establishing a new grant
grant = new provider . Grant ( {
accountId ,
clientId : params . client _id ,
} ) ;
}
if ( details . missingOIDCScope ) {
grant . addOIDCScope ( details . missingOIDCScope . join ( ' ' ) ) ;
}
if ( details . missingOIDCClaims ) {
grant . addOIDCClaims ( details . missingOIDCClaims ) ;
}
if ( details . missingResourceScopes ) {
// eslint-disable-next-line no-restricted-syntax
for ( const [ indicator , scopes ] of Object . entries ( details . missingResourceScopes ) ) {
grant . addResourceScope ( indicator , scopes . join ( ' ' ) ) ;
}
}
grantId = await grant . save ( ) ;
const consent = { } ;
if ( ! interactionDetails . grantId ) {
// we don't have to pass grantId to consent, we're just modifying existing one
consent . grantId = grantId ;
}
const result = { consent } ;
await provider . interactionFinished ( req , res , result , { mergeWithLastSubmission : true } ) ;
} catch ( err ) {
next ( err ) ;
}
2023-03-14 14:19:29 +01:00
} ;
}
function interactionAbort ( provider ) {
assert . strictEqual ( typeof provider , 'object' ) ;
2023-03-10 17:13:33 +01:00
2023-03-14 14:19:29 +01:00
return async function ( req , res , next ) {
2023-03-21 14:46:09 +01:00
debug ( 'route interaction abort' ) ;
2023-03-10 17:13:33 +01:00
try {
const result = {
error : 'access_denied' ,
error _description : 'End-User aborted interaction' ,
} ;
await provider . interactionFinished ( req , res , result , { mergeWithLastSubmission : false } ) ;
} catch ( err ) {
next ( err ) ;
}
2023-03-14 14:19:29 +01:00
} ;
2023-03-10 17:13:33 +01:00
}
2023-03-14 12:24:35 +01:00
/ * *
* @ param use - can either be "id_token" or "userinfo" , depending on
* where the specific claims are intended to be put in .
* @ param scope - the intended scope , while oidc - provider will mask
* claims depending on the scope automatically you might want to skip
* loading some claims from external resources etc . based on this detail
* or not return them in id tokens but only userinfo and so on .
* /
async function claims ( userId , use , scope ) {
debug ( ` claims: userId: ${ userId } use: ${ use } scope: ${ scope } ` ) ;
const [ error , user ] = await safe ( users . get ( userId ) ) ;
if ( error ) return { error : 'user not found' } ;
const displayName = user . displayName || user . username || '' ; // displayName can be empty and username can be null
const nameParts = displayName . split ( ' ' ) ;
const firstName = nameParts [ 0 ] ;
const lastName = nameParts . length > 1 ? nameParts [ nameParts . length - 1 ] : '' ; // choose last part, if it exists
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 ,
given _name : firstName ,
locale : 'en-US' ,
name : user . displayName ,
2023-03-14 12:52:37 +01:00
preferred _username : user . username
2023-03-14 12:24:35 +01:00
} ;
2023-03-14 12:52:37 +01:00
debug ( ` claims: userId: ${ userId } result ` , claims ) ;
return claims ;
2023-03-14 12:24:35 +01:00
}
2023-03-17 14:45:45 +01:00
// @param form - form source (id="op.logoutForm") to be embedded in the page and submitted by the End-User
2023-03-17 12:34:54 +01:00
async function logoutSource ( ctx , form ) {
2023-03-17 14:45:45 +01:00
const data = {
host : settings . dashboardFqdn ( ) ,
form
} ;
ctx . body = ejs . render ( fs . readFileSync ( path . join ( _ _dirname , 'oidc_templates/logout.ejs' ) , 'utf8' ) , data , { } ) ;
2023-03-17 12:34:54 +01:00
}
async function postLogoutSuccessSource ( ctx ) {
2023-03-21 14:46:09 +01:00
// const client = ctx.oidc.client || {}; // client is defined if the user chose to stay logged in with the OP
2023-03-17 14:45:45 +01:00
const data = {
dashboardOrigin : settings . dashboardOrigin ( )
} ;
ctx . body = ejs . render ( fs . readFileSync ( path . join ( _ _dirname , 'oidc_templates/post_logout.ejs' ) , 'utf8' ) , data , { } ) ;
2023-03-17 12:34:54 +01:00
}
2023-03-21 15:12:55 +01:00
async function findAccount ( ctx , id ) {
debug ( ` findAccount id: ${ id } ` ) ;
return {
accountId : id ,
async claims ( use , scope ) { return await claims ( id , use , scope ) ; } ,
} ;
}
async function renderError ( ctx , out , error ) {
const data = {
dashboardOrigin : settings . dashboardOrigin ( ) ,
2023-03-22 12:27:16 +01:00
errorMessage : error . error _description || error . error _detail || 'Unknown error'
2023-03-21 15:12:55 +01:00
} ;
debug ( 'renderError:' , error ) ;
ctx . type = 'html' ;
ctx . body = ejs . render ( fs . readFileSync ( path . join ( _ _dirname , 'oidc_templates/error.ejs' ) , 'utf8' ) , data , { } ) ;
}
2023-03-21 14:39:58 +01:00
async function start ( ) {
const app = express ( ) ;
gHttpServer = http . createServer ( app ) ;
2023-03-10 17:13:33 +01:00
2023-03-08 16:41:59 +01:00
const { Provider } = await import ( 'oidc-provider' ) ;
2023-03-23 18:02:45 +01:00
// TODO we may want to rotate those in the future
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' ) ;
const { privateKey } = await jose . generateKeyPair ( 'EdDSA' ) ;
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' ) ;
2023-04-04 11:32:32 +02:00
const { privateKey } = await jose . generateKeyPair ( 'RS256' ) ;
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-03-08 16:41:59 +01:00
const configuration = {
2023-03-21 15:12:55 +01:00
findAccount ,
renderError ,
2023-03-08 16:41:59 +01:00
adapter : CloudronAdapter ,
2023-03-09 20:17:27 +01:00
interactions : {
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 : {
2023-04-04 11:32:32 +02:00
jwksKeys
2023-03-23 18:02:45 +01:00
} ,
2023-03-16 16:42:18 +01:00
claims : {
email : [ 'email' , 'email_verified' ] ,
profile : [ 'family_name' , 'given_name' , 'locale' , 'name' , 'preferred_username' ]
} ,
2023-03-11 17:22:27 +01:00
features : {
2023-03-17 12:34:54 +01:00
devInteractions : { enabled : false } ,
rpInitiatedLogout : {
enabled : true ,
logoutSource ,
postLogoutSuccessSource
} ,
2023-03-11 17:22:27 +01:00
} ,
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 : {
// FIXME https://github.com/panva/node-oidc-provider/blob/b1c1a9318036c2d3793cc9e668f99937c5c36bc6/lib/helpers/defaults.js#L770
keys : [ 'cookiesecret1' , 'cookiesecret2' ]
} ,
2023-03-08 16:41:59 +01:00
pkce : {
required : function pkceRequired ( ctx , client ) {
return false ;
}
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
Interaction : 3600 // 1 hour
2023-03-08 16:41:59 +01:00
}
} ;
2023-03-21 14:39:58 +01:00
debug ( ` start: create provider for ${ settings . dashboardFqdn ( ) } at ${ ROUTE _PREFIX } ` ) ;
const provider = new Provider ( ` https:// ${ settings . dashboardFqdn ( ) } ${ ROUTE _PREFIX } ` , configuration ) ;
app . enable ( 'trust proxy' ) ;
2023-03-21 14:46:09 +01:00
provider . proxy = true ;
2023-03-08 16:41:59 +01:00
2023-03-21 14:39:58 +01:00
app . set ( 'views' , path . join ( _ _dirname , 'oidc_templates' ) ) ;
app . set ( 'view engine' , 'ejs' ) ;
2023-03-21 13:54:40 +01:00
2023-03-21 14:46:09 +01:00
const json = middleware . 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 ( ) ;
}
2023-03-21 14:46:09 +01:00
app . get ( ` ${ ROUTE _PREFIX } /interaction/:uid ` , setNoCache , renderInteractionPage ( provider ) ) ;
app . post ( ` ${ ROUTE _PREFIX } /interaction/:uid/login ` , setNoCache , json , interactionLogin ( provider ) ) ;
app . post ( ` ${ ROUTE _PREFIX } /interaction/:uid/confirm ` , setNoCache , json , interactionConfirm ( provider ) ) ;
app . get ( ` ${ ROUTE _PREFIX } /interaction/:uid/abort ` , setNoCache , interactionAbort ( provider ) ) ;
2023-03-21 13:54:40 +01:00
2023-03-21 14:39:58 +01:00
app . use ( ROUTE _PREFIX , provider . callback ( ) ) ;
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 ) ) ( ) ;
2023-03-21 13:54:40 +01:00
2023-03-21 14:39:58 +01:00
gHttpServer = null ;
2023-03-21 13:54:40 +01:00
}