2023-03-08 16:41:59 +01:00
'use strict' ;
exports = module . exports = {
getProvider ,
2023-03-10 17:13:33 +01:00
attachInteractionRoutes
2023-03-08 16:41:59 +01:00
} ;
2023-03-10 17:13:33 +01:00
const assert = require ( 'assert' ) ,
debug = require ( 'debug' ) ( 'box:oidc' ) ,
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-13 17:01:52 +01:00
BoxError = require ( './boxerror.js' ) ,
HttpError = require ( 'connect-lastmile' ) . HttpError ,
2023-03-13 19:08:41 +01:00
HttpSuccess = require ( 'connect-lastmile' ) . HttpSuccess ,
2023-03-13 17:01:52 +01:00
users = require ( './users.js' ) ,
safe = require ( 'safetydance' ) ,
2023-03-08 16:41:59 +01:00
settings = require ( './settings.js' ) ;
class CloudronAdapter {
/ * *
*
* 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"
*
* /
constructor ( name ) {
this . name = name ;
this . fileStorePath = path . join ( paths . PLATFORM _DATA _DIR , ` oidc- ${ name } .json ` ) ;
debug ( ` Creating adapter for ${ name } backed by ${ this . fileStorePath } ` ) ;
let data = { } ;
try {
data = JSON . parse ( fs . readFileSync ( this . fileStorePath ) , 'utf8' ) ;
} catch ( e ) {
debug ( ` filestore for adapter ${ name } not found, start with new one ` ) ;
}
this . store = data ;
}
/ * *
*
* 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 .
*
* /
async upsert ( id , payload , expiresIn ) {
debug ( ` [ ${ this . name } ] upsert id: ${ id } expiresIn: ${ expiresIn } ` , payload ) ;
2023-03-09 18:59:04 +01:00
this . store [ id ] = { id , expiresIn , payload , consumed : false } ;
2023-03-08 16:41:59 +01:00
fs . writeFileSync ( this . fileStorePath , JSON . stringify ( this . store ) , 'utf8' ) ;
}
/ * *
*
* 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
*
* /
async find ( id ) {
debug ( ` [ ${ this . name } ] find id: ${ id } ` ) ;
if ( ! this . store [ id ] ) return false ;
2023-03-09 18:59:04 +01:00
return this . store [ id ] . payload ;
2023-03-08 16:41:59 +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
*
* /
async findByUserCode ( userCode ) {
debug ( ` [ ${ this . name } ] FIXME findByUserCode userCode: ${ userCode } ` ) ;
}
/ * *
*
* 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
*
* /
async findByUid ( uid ) {
debug ( ` [ ${ this . name } ] findByUid uid: ${ uid } ` ) ;
for ( let d in this . store ) {
2023-03-09 19:16:36 +01:00
if ( this . store [ d ] . payload . uid === uid ) return this . store [ d ] . payload ;
2023-03-08 16:41:59 +01:00
}
return false ;
}
/ * *
*
* 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
*
* /
async consume ( id ) {
2023-03-09 18:59:04 +01:00
debug ( ` [ ${ this . name } ] consume id: ${ id } ` ) ;
if ( this . store [ id ] ) this . store [ id ] . consumed = true ;
2023-03-08 16:41:59 +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
*
* /
async destroy ( id ) {
debug ( ` [ ${ this . name } ] destroy id: ${ id } ` ) ;
delete this . store [ id ] ;
}
/ * *
*
* 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
*
* /
async revokeByGrantId ( grantId ) {
debug ( ` [ ${ this . name } ] revokeByGrantId grantId: ${ grantId } ` ) ;
for ( let d in this . store ) {
if ( this . store [ d ] . grantId === grantId ) {
delete this . store [ d ] ;
return ;
}
}
}
}
2023-03-10 17:13:33 +01:00
const store = new Map ( ) ;
const logins = new Map ( ) ;
class Account {
constructor ( id , profile ) {
this . accountId = id || 'FIXME_someid' ;
this . profile = profile ;
store . set ( this . accountId , this ) ;
}
/ * *
* @ 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 claims ( use , scope ) { // eslint-disable-line no-unused-vars
if ( this . profile ) {
return {
sub : this . accountId , // it is essential to always return a sub claim
email : this . profile . email ,
email _verified : this . profile . email _verified ,
family _name : this . profile . family _name ,
given _name : this . profile . given _name ,
locale : this . profile . locale ,
name : this . profile . name ,
} ;
}
return {
sub : this . accountId , // it is essential to always return a sub claim
address : {
country : '000' ,
formatted : '000' ,
locality : '000' ,
postal _code : '000' ,
region : '000' ,
street _address : '000' ,
} ,
birthdate : '1987-10-16' ,
email : 'johndoe@example.com' ,
email _verified : false ,
family _name : 'Doe' ,
gender : 'male' ,
given _name : 'John' ,
locale : 'en-US' ,
middle _name : 'Middle' ,
name : 'John Doe' ,
nickname : 'Johny' ,
phone _number : '+49 000 000000' ,
phone _number _verified : false ,
picture : 'http://lorempixel.com/400/200/' ,
preferred _username : 'johnny' ,
profile : 'https://johnswebsite.com' ,
updated _at : 1454704946 ,
website : 'http://example.com' ,
zoneinfo : 'Europe/Berlin' ,
} ;
}
static async findByFederated ( provider , claims ) {
const id = ` ${ provider } . ${ claims . sub } ` ;
if ( ! logins . get ( id ) ) {
logins . set ( id , new Account ( id , claims ) ) ;
}
return logins . get ( id ) ;
}
static async findByLogin ( login ) {
if ( ! logins . get ( login ) ) {
logins . set ( login , new Account ( login ) ) ;
}
return logins . get ( login ) ;
}
static async findAccount ( ctx , id , token ) { // eslint-disable-line no-unused-vars
// token is a reference to the token used for which a given account is being loaded,
// it is undefined in scenarios where account claims are returned from authorization endpoint
// ctx is the koa request context
if ( ! store . get ( id ) ) new Account ( id ) ; // eslint-disable-line no-new
return store . get ( id ) ;
}
}
function attachInteractionRoutes ( routePrefix , app , provider ) {
assert . strictEqual ( typeof routePrefix , 'string' ) ;
assert . strictEqual ( typeof app , 'function' ) ; // express app
assert . strictEqual ( typeof provider , 'object' ) ;
function setNoCache ( req , res , next ) {
res . set ( 'cache-control' , 'no-store' ) ;
next ( ) ;
}
2023-03-14 10:47:01 +01:00
// FIXME duplicate, all those routes should go to server.js
const json = middleware . json ( { strict : true , limit : '2mb' } ) ; // application/json
2023-03-10 17:13:33 +01:00
app . get ( routePrefix + '/interaction/:uid' , setNoCache , async ( req , res , next ) => {
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
const client = await provider . Client . find ( params . client _id ) ;
switch ( prompt . name ) {
case 'login' : {
return res . render ( 'login' , {
client ,
2023-03-11 17:22:27 +01:00
submitUrl : ` ${ routePrefix } /interaction/ ${ uid } /login ` ,
2023-03-10 17:13:33 +01:00
uid ,
details : prompt . details ,
params ,
title : 'Sign-in' ,
session : session ? debug ( session ) : undefined ,
dbg : {
params : debug ( params ) ,
prompt : debug ( prompt ) ,
} ,
} ) ;
}
case 'consent' : {
return res . render ( 'interaction' , {
client ,
2023-03-11 17:22:27 +01:00
submitUrl : ` ${ routePrefix } /interaction/ ${ uid } /confirm ` ,
2023-03-10 17:13:33 +01:00
uid ,
details : prompt . details ,
params ,
title : 'Authorize' ,
session : session ? debug ( session ) : undefined ,
dbg : {
params : debug ( params ) ,
prompt : debug ( prompt ) ,
} ,
} ) ;
}
default :
return undefined ;
}
2023-03-14 10:47:01 +01:00
} catch ( error ) {
debug ( ` route interaction get uid: ${ uid } error ` ) ;
console . log ( error ) ;
return next ( error ) ;
2023-03-10 17:13:33 +01:00
}
} ) ;
2023-03-14 10:47:01 +01:00
app . post ( routePrefix + '/interaction/:uid/login' , json , setNoCache , async ( 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 } ) ;
// next(new HttpSuccess(200, { redirectTo }));
2023-03-10 17:13:33 +01:00
} ) ;
2023-03-14 10:47:01 +01:00
app . post ( routePrefix + '/interaction/:uid/confirm' , json , setNoCache , async ( 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 ) ;
}
} ) ;
app . get ( routePrefix + '/interaction/:uid/abort' , setNoCache , async ( req , res , next ) => {
2023-03-11 17:22:27 +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-09 20:17:27 +01:00
async function getProvider ( routePrefix ) {
2023-03-10 17:13:33 +01:00
assert . strictEqual ( typeof routePrefix , 'string' ) ;
2023-03-08 16:41:59 +01:00
const { Provider } = await import ( 'oidc-provider' ) ;
const configuration = {
2023-03-10 17:13:33 +01:00
// use the one from Account class I guess?
2023-03-09 19:16:36 +01:00
async findAccount ( ctx , id ) {
debug ( ` findAccount ctx: ${ ctx } id: ${ id } ` ) ;
return {
accountId : id ,
async claims ( use , scope ) { return { sub : id } ; } ,
} ;
} ,
2023-03-08 16:41:59 +01:00
adapter : CloudronAdapter ,
2023-03-09 20:17:27 +01:00
interactions : {
url : async function ( ctx , interaction ) {
return ` ${ routePrefix } /interaction/ ${ interaction . uid } ` ;
}
2023-03-10 16:07:45 +01:00
} ,
2023-03-11 17:22:27 +01:00
features : {
2023-03-13 19:08:41 +01:00
devInteractions : { enabled : false }
2023-03-11 17:22:27 +01:00
} ,
2023-03-08 16:41:59 +01:00
clients : [ {
client _id : 'foo' ,
client _secret : 'bar' ,
redirect _uris : [ 'https://openidconnect.net/callback' ] ,
} ] ,
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
}
} ;
const provider = new Provider ( ` https:// ${ settings . dashboardFqdn ( ) } ` , configuration ) ;
provider . proxy = true
return provider ;
}