From d56b4b1f56a7fe1ff391f65fcd9147961a747c66 Mon Sep 17 00:00:00 2001 From: Takeshi Nakatani Date: Tue, 15 Aug 2023 11:28:00 +0900 Subject: [PATCH] Supported for multiple OIDC authentication --- app.js | 16 +- package.json | 22 +- routes/index.js | 6 +- routes/lib/libr3tokens.js | 20 +- routes/lib/userValidateCredential.js | 4 +- routes/lib/userValidateOidc.js | 149 ++++++++-- routes/oidc.js | 278 ++++++++++++++---- src/components/r3appbar.jsx | 129 +++++++- src/components/r3container.jsx | 28 +- src/components/r3theme.jsx | 10 + src/util/r3context.js | 106 ++++++- .../__snapshots__/r3aboutdialog-test.jsx.snap | 1 + .../r3accountdialog-test.jsx.snap | 2 + .../r3createpathdialog-test.jsx.snap | 2 + .../r3createservicedialog-test.jsx.snap | 3 + .../r3createservicetenantdialog-test.jsx.snap | 2 + .../r3localtenantdialog-test.jsx.snap | 5 + .../r3pathinfodialog-test.jsx.snap | 1 + .../__snapshots__/r3policy-test.jsx.snap | 7 +- .../r3popupmsgdialog-test.jsx.snap | 1 + .../__snapshots__/r3resource-test.jsx.snap | 11 + .../__snapshots__/r3role-test.jsx.snap | 16 + .../__snapshots__/r3service-test.jsx.snap | 4 + .../r3signincreddialog-test.jsx.snap | 3 + views/include/globalval.ejs | 1 + views/index.ejs | 2 +- 26 files changed, 687 insertions(+), 142 deletions(-) diff --git a/app.js b/app.js index 871cb77..8492db6 100644 --- a/app.js +++ b/app.js @@ -77,20 +77,28 @@ app.use('/status.html', express.static(__dirname + '/public/status.html')); // Setup extension router // var cfgExtRouter= appConf.getExtRouter(); + if(r3util.isSafeEntity(cfgExtRouter)){ + var routerObject = {}; Object.keys(cfgExtRouter).forEach(function(routername){ // check name/path if(r3util.isSafeString(cfgExtRouter[routername].name) && r3util.isSafeString(cfgExtRouter[routername].path)){ - var entry = require('./routes/' + cfgExtRouter[routername].name); + var routerTypeName = cfgExtRouter[routername].name; + + if(!r3util.isSafeEntity(routerObject[routerTypeName])){ + routerObject[routerTypeName] = require('./routes/' + cfgExtRouter[routername].name); + } + // check setConfig function - if(r3util.isSafeEntity(entry.setConfig) && 'function' == typeof entry.setConfig){ + if(r3util.isSafeEntity(routerObject[routerTypeName].setConfig) && 'function' == typeof routerObject[routerTypeName].setConfig){ var routerConfig = r3util.isSafeEntity(cfgExtRouter[routername].config) ? cfgExtRouter[routername].config : null; - if(!entry.setConfig(routerConfig)){ + if(!routerObject[routerTypeName].setConfig(routerConfig, routername)){ console.error('failed to set configuration for extension router(name=' + JSON.stringify(cfgExtRouter[routername].name) + ', path=' + JSON.stringify(cfgExtRouter[routername].path) + ').'); } } + // set router - app.use(cfgExtRouter[routername].path, entry.router); + app.use(cfgExtRouter[routername].path, routerObject[routerTypeName].router); console.log('success set extension router(name=' + JSON.stringify(cfgExtRouter[routername].name) + ', path=' + JSON.stringify(cfgExtRouter[routername].path) + ').'); }else{ console.error('something wrong extrouter configration(name=' + JSON.stringify(cfgExtRouter[routername].name) + ', path=' + JSON.stringify(cfgExtRouter[routername].path) + ').'); diff --git a/package.json b/package.json index 78449ce..bb206d1 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.14.1", - "@mui/material": "^5.14.1", + "@mui/icons-material": "^5.14.3", + "@mui/material": "^5.14.5", "ajv": "^8.12.0", "body-parser": "^1.20.2", "config": "^3.3.9", @@ -42,21 +42,21 @@ "views": "views" }, "devDependencies": { - "@babel/core": "^7.22.9", - "@babel/eslint-parser": "^7.22.9", + "@babel/core": "^7.22.10", + "@babel/eslint-parser": "^7.22.10", "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/plugin-proposal-private-methods": "^7.18.6", - "@babel/plugin-proposal-decorators": "^7.22.7", + "@babel/plugin-proposal-decorators": "^7.22.10", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@babel/preset-env": "^7.22.9", + "@babel/preset-env": "^7.22.10", "@babel/preset-react": "^7.22.5", - "babel-jest": "^29.6.1", + "babel-jest": "^29.6.2", "babel-loader": "^9.1.3", "css-loader": "^6.8.1", - "eslint": "^8.45.0", - "eslint-plugin-react": "^7.33.0", - "jest": "^29.6.1", - "jest-environment-jsdom": "^29.6.1", + "eslint": "^8.47.0", + "eslint-plugin-react": "^7.33.1", + "jest": "^29.6.2", + "jest-environment-jsdom": "^29.6.2", "license-checker": "^25.0.1", "react-test-context-provider": "^2.2.0", "react-test-renderer": "^18.2.0", diff --git a/routes/index.js b/routes/index.js index c5fc6ea..9313f8c 100644 --- a/routes/index.js +++ b/routes/index.js @@ -76,6 +76,7 @@ router.get('/', function(req, res, next) // eslint-disable-line no-unused- var signintype = tokensObj.getSignInType(); var signinurl = tokensObj.getSignInUrl(); var signouturl = tokensObj.getSignOutUrl(); + var configname = tokensObj.getConfigName(); // If using ExtRouter(ex. OIDC) and has a token, the config name that created the token is set. var uselocaltenant = _appConf.useLocalTenant(); var lang = _appConf.getLang(); var dbgheader = ''; @@ -102,8 +103,9 @@ router.get('/', function(req, res, next) // eslint-disable-line no-unused- username: username, unscopedtoken: token, signintype: signintype, - signinurl: signinurl, - signouturl: signouturl, + signinurl: escape(JSON.stringify(signinurl)), + signouturl: escape(JSON.stringify(signouturl)), + configname: escape(JSON.stringify(configname)), uselocaltenant: uselocaltenant, lang: lang, dbgheader: dbgheader, diff --git a/routes/lib/libr3tokens.js b/routes/lib/libr3tokens.js index a04f203..c0182f9 100644 --- a/routes/lib/libr3tokens.js +++ b/routes/lib/libr3tokens.js @@ -53,13 +53,13 @@ var R3UserToken = (function() }else{ this.sinintype = null; } - if(r3util.isSafeEntity(this.appConfig.getUserValidatorObj().getSginInUri)){ - this.signinUrl = this.appConfig.getUserValidatorObj().getSginInUri(req); + if(r3util.isSafeEntity(this.appConfig.getUserValidatorObj().getSignInUri)){ + this.signinUrl = this.appConfig.getUserValidatorObj().getSignInUri(req); }else{ this.signinUrl = null; } - if(r3util.isSafeEntity(this.appConfig.getUserValidatorObj().getSginOutUri)){ - this.signoutUrl = this.appConfig.getUserValidatorObj().getSginOutUri(req); + if(r3util.isSafeEntity(this.appConfig.getUserValidatorObj().getSignOutUri)){ + this.signoutUrl = this.appConfig.getUserValidatorObj().getSignOutUri(req); }else{ this.signoutUrl = null; } @@ -68,6 +68,11 @@ var R3UserToken = (function() }else{ this.otherToken = null; } + if(r3util.isSafeEntity(this.appConfig.getUserValidatorObj().getConfigName)){ + this.configName = this.appConfig.getUserValidatorObj().getConfigName(req); + }else{ + this.configName = null; + } }; var proto = R3UserToken.prototype; @@ -226,10 +231,15 @@ var R3UserToken = (function() return (null !== this.rawExtractUserToken(req)); }; + proto.getConfigName = function() + { + return this.configName; + }; + proto.getUnscopedUserToken = function(callback) { if(!r3util.isSafeString(this.username)){ - var errobj = new Error('User name is not specified(not found backyard cookie)'); + var errobj = new Error('User name is not specified(not found authentication cookie)'); console.error(errobj.message); callback(errobj, null); return; diff --git a/routes/lib/userValidateCredential.js b/routes/lib/userValidateCredential.js index 34a2a4b..90f3304 100644 --- a/routes/lib/userValidateCredential.js +++ b/routes/lib/userValidateCredential.js @@ -32,12 +32,12 @@ exports.getUserName = function(req) // eslint-disable-line no-unused-vars return null; }; -exports.getSginInUri = function(req) // eslint-disable-line no-unused-vars +exports.getSignInUri = function(req) // eslint-disable-line no-unused-vars { return null; }; -exports.getSginOutUri = function(req) // eslint-disable-line no-unused-vars +exports.getSignOutUri = function(req) // eslint-disable-line no-unused-vars { return null; }; diff --git a/routes/lib/userValidateOidc.js b/routes/lib/userValidateOidc.js index 7a5970c..cfe49e0 100644 --- a/routes/lib/userValidateOidc.js +++ b/routes/lib/userValidateOidc.js @@ -30,23 +30,27 @@ var { decode } = require('jose').base64url; // var rawGetOtherToken = function(req) { - var config = oidc.getConfig(); - if( !r3util.isSafeEntity(config) || - !r3util.isSafeEntity(config.params) || - !r3util.isSafeEntity(config.params.cookiename) ) + var configName = rawGetOidcConfigName(req); + var config = oidc.getConfig(); + + if( !r3util.isSafeString(configName) || + !r3util.isSafeEntity(config) || + !r3util.isSafeEntity(config[configName]) || + !r3util.isSafeEntity(config[configName].params) || + !r3util.isSafeEntity(config[configName].params.cookiename) ) { console.error('Not find cookie name in configuration.'); return null; } + var cookieName = config[configName].params.cookiename; - if( !r3util.isSafeEntity(req) || - !r3util.isSafeEntity(req.cookies) || - !r3util.isSafeEntity(req.cookies[config.params.cookiename])) + if( !r3util.isSafeEntity(req) || + !r3util.isSafeEntity(req.cookies) || + !r3util.isSafeEntity(req.cookies[cookieName]) ) { return null; } - - return req.cookies[config.params.cookiename]; + return req.cookies[cookieName]; }; // @@ -54,15 +58,18 @@ var rawGetOtherToken = function(req) // var rawGetOidcUsername = function(req) { - var config = oidc.getConfig(); - if( !r3util.isSafeEntity(config) || - !r3util.isSafeEntity(config.params) || - !r3util.isSafeEntity(config.params.cookiename) || - !r3util.isSafeEntity(config.params.usernamekey) ) + var configName = rawGetOidcConfigName(req); + var config = oidc.getConfig(); + if( !r3util.isSafeString(configName) || + !r3util.isSafeEntity(config) || + !r3util.isSafeEntity(config[configName]) || + !r3util.isSafeEntity(config[configName].params) || + !r3util.isSafeEntity(config[configName].params.usernamekey) ) { - console.error('Not find cookie/user name key in configuration.'); + console.error('Not find user name key in configuration.'); return null; } + var userNameKey = config[configName].params.usernamekey; var oidc_token = rawGetOtherToken(req); if(!r3util.isSafeString(oidc_token)){ @@ -83,13 +90,38 @@ var rawGetOidcUsername = function(req) } var payload = JSON.parse(raw_payload); - if(!r3util.isSafeEntity(payload) || !r3util.isSafeEntity(payload[config.params.usernamekey])){ - console.error('payload does not have user name key(' + config.params.usernamekey + ')'); + if(!r3util.isSafeEntity(payload) || !r3util.isSafeEntity(payload[userNameKey])){ + console.error('payload does not have user name key(' + userNameKey + ')'); + return null; + } + return payload[userNameKey]; +}; + +// +// rawGetOidcConfigName +// +var rawGetOidcConfigName = function(req) +{ + if( !r3util.isSafeEntity(req) || + !r3util.isSafeEntity(req.cookies) || + !r3util.isSafeEntity(req.cookies[oidc.oidcConfigCookieName])) + { return null; } - return payload[config.params.usernamekey]; + return req.cookies[oidc.oidcConfigCookieName]; }; +// +// Return object or null: +// { +// '': { +// 'display': '', +// 'url': '' +// }, +// ... +// ... +// } +// var rawGetSignInUrl = function(req) { if( !r3util.isSafeEntity(req) || @@ -101,16 +133,49 @@ var rawGetSignInUrl = function(req) } var config = oidc.getConfig(); - if( r3util.isSafeEntity(config) && - r3util.isSafeEntity(config.params) && - r3util.isSafeEntity(config.params.redirectUrl) ) + + if(!r3util.isSafeEntity(config)){ + console.error('no valid SignInUrl'); + return null; + } + + var allSignUrls = {}; + var isSet = false; + + Object.keys(config).forEach(function(oidcName) { - return config.params.redirectUrl; + if(r3util.isSafeEntity(config[oidcName].params) && r3util.isSafeEntity(config[oidcName].params.redirectUrl)){ + var oneOidc = {}; + oneOidc.url = config[oidcName].params.redirectUrl; + + if(r3util.isSafeString(config[oidcName].displayName)){ + oneOidc.display = config[oidcName].displayName; + }else{ + oneOidc.display = oidcName; + } + allSignUrls[oidcName] = oneOidc; + isSet = true; + }else{ + console.error('no valid SignInUrl for ' + oidcName + ' in config, so skip this'); + } + }); + + if(!isSet){ + console.error('no valid SignInUrl'); + return null; } - console.error('no valid SignInUrl'); - return null; + + return allSignUrls; }; +// +// Return object or null: +// { +// '': '', +// ... +// ... +// } +// var rawGetSignOutUrl = function(req) { if( !r3util.isSafeEntity(req) || @@ -122,13 +187,30 @@ var rawGetSignOutUrl = function(req) } var config = oidc.getConfig(); - if( r3util.isSafeEntity(config) && - r3util.isSafeEntity(config.logoutUrl) ) + + if(!r3util.isSafeEntity(config)){ + console.error('no valid SignOutUrl'); + return null; + } + + var allSignUrls = {}; + var isSet = false; + + Object.keys(config).forEach(function(oidcName) { - return config.logoutUrl; + if(r3util.isSafeString(config[oidcName].logoutUrl)){ + allSignUrls[oidcName] = config[oidcName].logoutUrl; + isSet = true; + }else{ + console.error('no valid SignOutUrl for ' + oidcName + ' in config, so skip this'); + } + }); + + if(!isSet){ + console.error('no valid SignInUrl'); + return null; } - console.error('no valid SignOutUrl'); - return null; + return allSignUrls; }; //--------------------------------------------------------- @@ -144,12 +226,17 @@ exports.getUserName = function(req) return rawGetOidcUsername(req); }; -exports.getSginInUri = function(req) +exports.getConfigName = function(req) +{ + return rawGetOidcConfigName(req); +}; + +exports.getSignInUri = function(req) { return rawGetSignInUrl(req); }; -exports.getSginOutUri = function(req) +exports.getSignOutUri = function(req) { return rawGetSignOutUrl(req); }; diff --git a/routes/oidc.js b/routes/oidc.js index 0bd468b..4b3e531 100644 --- a/routes/oidc.js +++ b/routes/oidc.js @@ -26,69 +26,153 @@ // K2HR3 APP configuration file(ex, production.json/local.json/etc). // // To enable this OIDC, register this module as an 'extrouter'. -// Then set the keys and values shown in the example below. +// Then set the keys and values shown in the example below: // // 'extrouter': { -// 'oidc': { +// 'oidc': { <---- default name // 'name': 'oidc', // 'path': '/oidc', // 'config': { +// 'displayName': 'Default OpenID Connect' // 'debug': true, -// 'logoutUrl': '', +// 'logoutUrl': '/oidc/logout', // 'mainUrl': '', // 'oidcDiscoveryUrl': '', // 'params': { // 'client_secret': '', // 'client_id': '', -// 'redirectUrl': '', +// 'redirectUrl': '/oidc/login/cb', // 'usernamekey': '', // 'cookiename': '', // 'cookieexpire': '' // }, // 'scope': '' // } +// }, +// 'oidc@other': { +// 'name': 'oidc', +// 'path': '/oidc@other', +// 'config': { +// 'displayName': 'OpenID Connect to Other' +// 'debug': true, +// 'logoutUrl': '/oidc@other/logout', +// 'mainUrl': '', +// 'oidcDiscoveryUrl': '', +// 'params': { +// 'client_secret': '', +// 'client_id': '', +// 'redirectUrl': '/oidc@other/login/cb', +// 'usernamekey': '', +// 'cookiename': '', +// 'cookieexpire': '' +// }, +// 'scope': '' +// } +// }, +// ... +// ... +// }, +// +// [NOTE] +// The 'oidc' object is required and used as the default OIDC authorization. +// If you have "other" objects, you can use them for its OIDC authentication +// logic. +// The 'name' field must be 'oidc' to recognize it as 'oidc'. The 'redirectUrl' +// and 'logoutUrl' should be '/login/cb' and '/logout' +// ( is such as 'oidc' or 'oidc@other'). +// The 'URL PATH' must always match one of 'extrouter name'. +// +// Each OIDC setting item has the following format: +// +// '': { +// 'name': 'oidc', +// 'path': '/', +// 'config': { +// 'displayName': '' +// 'debug': , +// 'logoutUrl': '//logout', +// 'mainUrl': '', +// 'oidcDiscoveryUrl': '', +// 'params': { +// 'client_secret': '', +// 'client_id': '', +// 'redirectUrl': '//login/cb', +// 'usernamekey': '', +// 'cookiename': '', +// 'cookieexpire': '' +// }, +// 'scope': '' // } // }, // -// This 'oidc' object should contain the following keys(objects). The -// contents of each setting are explained. +// A description of each item is shown below: +// +// [oidc name] +// A unique name for each OIDC. (Do not include space characters +// whenever possible) +// Note that other values should also match this name string in places. // // [name] -// Set 'oidc' as the value. +// For this OIDC authentication, specify 'oidc'. +// // [path] -// Specifies the filename path(relative to the '/route' directory) -// without the suffix of this module. If the file name has not been -// changed, it will be '/oidc'. +// This path will be the entry point on server for OIDC authentication. +// Be sure to specify '/'. +// For example, if 'oidc name' is 'oidc', it should be set '/oidc'. +// // [config] -// An object of configuration for this module. +// An object of configuration for this module. +// +// [displayName] +// If there are multiple OIDC authentication settings, they should +// be distinguished in the 'Sign in' menu of the K2HR3 APP. +// Then the 'Sign in' menu will have a submenu and this 'displayName' +// will be the submenu name. +// This item can be omitted, and if omitted, '' will be used. +// If there is only one OIDC authentication setting, then even if this +// value is set, it will not be used for display. +// // [debug] // Set true to display the contents of communication with the OpenId // Connect server. +// // [logoutUrl] -// Specify the entry point for logout processing. -// For example, if the URL of K2HR3 APP is 'https://k2hr3-app/', set -// 'https://k2hr3-app/oidc/logout'.(The URL path is arbitrary.) +// Specifies the entry point for logout processing. +// Please specify K2HR3 APP server name including schema, path +// including port number. +// The path must always include '/logout'. +// For example, 'https://k2hr3-app:3000//logout'. +// // [mainUrl] // Specify the URL of the K2HR3 APP top page. -// For example, 'https://k2hr3-app/'. +// For example, 'https://k2hr3-app:3000/'. +// // [oidcDiscoveryUrl] // Specify the Issuer URL for OpenId Connect. +// // [params] // An object of some parameters for this module. +// // [client_secret] // Specify the client Secret for OpenId Connect. +// // [client_id] // Specify the Client Id for OpenId Connect. +// // [redirectUrl] -// Specify the URL on the K2HR3 APP called from OpenId Connect. -// For example, if the URL of K2HR3 APP is 'https://k2hr3-app/', set -// 'https://k2hr3-app/oidc/login/cb'.(The URL path is arbitrary.) +// Specifies the entry point for login callback processing. +// Please specify K2HR3 APP server name including schema, path +// including port number. +// The path must always include ''. +// For example, 'https://k2hr3-app:3000//login/cb'. +// // [usernamekey] // If there is a key indicating the user name in the Payload of the // Token returned by OpenId Connect, specify that key name. // If there is no key, it can be omitted(not specified). // If omitted, the value of the 'sub' key in Payload will be used as // the user name. +// // [cookiename] // Specifies the cookie name for temporarily storing the token // returned by OpenId Connect. This authentication process using @@ -96,9 +180,11 @@ // redirect destination. // Please specify the name of this cookie. If omitted, 'id_token' // will be used. +// // [cookieexpire] // Specify the cookie validity time in seconds on this page. // If omitted, set to 60 seconds. +// // [scope] // Specify 'openid profile email' value for this key. // @@ -120,7 +206,8 @@ var { jwtVerify } = require('jose'); // // Configration for OIDC // -var oidcConfig = null; +var oidcConfig = {}; +var oidcConfigCookieName= 'oidc_config_name'; // // Setup session @@ -143,6 +230,42 @@ router.use(session({ router.use(passport.initialize()); router.use(passport.session()); +//-------------------------------------------------------------- +// Utility +//-------------------------------------------------------------- +function rawGetExtRouterName(req) +{ + if(!r3util.isSafeEntity(req) || !r3util.isSafeString(req.baseUrl)){ + console.error('Request base URL is somthing wrong, but returns default extrouter name(oidc).'); + return 'oidc'; // default + } + + var urlparts = decodeURI(req.baseUrl).split('/'); + if(!r3util.isArray(urlparts)){ + console.error('Request base URL is somthing wrong, but returns default extrouter name(oidc).'); + return 'oidc'; // default + } + + // + // Try to find '...//login/...' or '...//logout/...' + // + var extRounterName = null; + for(var cnt = 0; cnt < urlparts.length; ++cnt){ + if(!r3util.isSafeString(urlparts[cnt])){ + continue; + } + if(r3util.compareCaseString(urlparts[cnt], 'login') || r3util.compareCaseString(urlparts[cnt], 'logout')){ + break; + } + extRounterName = urlparts[cnt]; + } + if(!r3util.isSafeString(extRounterName)){ + console.error('Failed to extract extRouter name from base URL(' + req.baseUrl + '), so returns default extrouter name(oidc).'); + return 'oidc'; // default + } + return extRounterName; +} + //-------------------------------------------------------------- // Mountpath : //login //-------------------------------------------------------------- @@ -153,9 +276,11 @@ router.use(passport.session()); // // Login async function // -async function oidcLogin() +async function oidcLogin(Request) { - if(!r3util.isSafeEntity(oidcConfig)){ + var extRouterName = rawGetExtRouterName(Request); + + if(!r3util.isSafeEntity(oidcConfig[extRouterName])){ var error = new Error('Please check your configuarion(json) because it is invalid.'); console.error(error.message); throw error; @@ -167,7 +292,7 @@ async function oidcLogin() // Issuer.discovery returns Promise // https://github.com/panva/node-openid-client/blob/main/lib/issuer.js#L210 // - var oidcDiscovery = Issuer.discover(oidcConfig.oidcDiscoveryUrl); + var oidcDiscovery = Issuer.discover(oidcConfig[extRouterName].oidcDiscoveryUrl); // // Try to login @@ -176,7 +301,7 @@ async function oidcLogin() // // put debug message // - if(r3util.isSafeBoolean(oidcConfig.debug) && oidcConfig.debug){ + if(r3util.isSafeBoolean(oidcConfig[extRouterName].debug) && oidcConfig[extRouterName].debug){ // debug message console.log('[OIDC debug] Discovered issuer %s %O', oidcIssuer.issuer, oidcIssuer.metadata); @@ -211,9 +336,9 @@ async function oidcLogin() // Create a client handler // var clientParams = { - client_id: oidcConfig.params.client_id, - client_secret: oidcConfig.params.client_secret, - redirect_uris: [ oidcConfig.params.redirectUrl ] + client_id: oidcConfig[extRouterName].params.client_id, + client_secret: oidcConfig[extRouterName].params.client_secret, + redirect_uris: [ oidcConfig[extRouterName].params.redirectUrl ] }; var client = new oidcIssuer.Client(clientParams); client[custom.clock_tolerance] = 5; // to allow a second 5 skew @@ -241,6 +366,8 @@ async function oidcLogin() // var authenticate = async function(Request, Response, Next) { + var extRouterName = rawGetExtRouterName(Request); + // // Login by invoking passport middleware to get an token // @@ -253,7 +380,7 @@ var authenticate = async function(Request, Response, Next) passport.authenticate( 'oidc', { - scope: oidcConfig.scope + scope: oidcConfig[extRouterName].scope }, function(error, token){ if(error){ @@ -291,13 +418,13 @@ async function oidcAuthenticate(Request, Response, Next) // // Utility function for token // -async function oidcVerifyToken(token) +async function oidcVerifyToken(token, extRouterName) { var jwtParam = { - issuer: oidcConfig.oidcDiscoveryUrl, - audience: oidcConfig.params.client_id + issuer: oidcConfig[extRouterName].oidcDiscoveryUrl, + audience: oidcConfig[extRouterName].params.client_id }; - var strurl = oidcConfig.oidcDiscoveryUrl + '/keys'; + var strurl = oidcConfig[extRouterName].oidcDiscoveryUrl + '/keys'; var JWKS = createRemoteJWKSet(new URL(strurl)); await jwtVerify(token, JWKS, jwtParam).catch(function(error){ @@ -311,17 +438,20 @@ async function oidcVerifyToken(token) // var sessionize = async function(Request, Response, Next) { - if(!r3util.isSafeEntity(oidcConfig)){ + var extRouterName = rawGetExtRouterName(Request); + + if(!r3util.isSafeEntity(oidcConfig) || !r3util.isSafeEntity(oidcConfig[extRouterName])){ var error = 'Please check your configuarion(json) because it is invalid.'; console.error('Failed to sessionize init, ' + error); - Response.status(500); // 500: Internal Server Error + Response.status(500); // 500: Internal Server Error return; } // // Get oidc token in request // - await oidcAuthenticate(Request, Response, Next).then(async function(oidc_token){ + await oidcAuthenticate(Request, Response, Next).then(async function(oidc_token) + { // // get payload in oidc token // @@ -329,14 +459,14 @@ var sessionize = async function(Request, Response, Next) if(2 > parts.length){ var error = 'Failed to parse payload from oidc token.'; console.error(error); - Response.status(401); // 401: Unauthorized + Response.status(401); // 401: Unauthorized return; } var raw_payload = new TextDecoder().decode(decode(parts[1])); if(!r3util.isSafeJSON(raw_payload)){ error = 'Failed to decode json payload from oidc token.'; console.error(error); - Response.status(401); // 401: Unauthorized + Response.status(401); // 401: Unauthorized return; } var payload = JSON.parse(raw_payload); @@ -344,21 +474,21 @@ var sessionize = async function(Request, Response, Next) // // put debug message // - if(r3util.isSafeBoolean(oidcConfig.debug) && oidcConfig.debug){ + if(r3util.isSafeBoolean(oidcConfig[extRouterName].debug) && oidcConfig[extRouterName].debug){ console.log('[OIDC debug] payload = ' + JSON.stringify(payload)); } // // check user name key // - if(r3util.isSafeString(oidcConfig.params.usernamekey)){ + if(r3util.isSafeString(oidcConfig[extRouterName].params.usernamekey)){ var found_key = false; Object.keys(payload).forEach(function(onekey){ - if(onekey == oidcConfig.params.usernamekey){ + if(onekey == oidcConfig[extRouterName].params.usernamekey){ found_key = true; } }); - if(!found_key || !r3util.isSafeString(payload[oidcConfig.params.usernamekey])){ + if(!found_key || !r3util.isSafeString(payload[oidcConfig[extRouterName].params.usernamekey])){ error = 'Not find or empty user name in oidc token.'; console.error(error); Response.status(401); // 401: Unauthorized @@ -369,30 +499,41 @@ var sessionize = async function(Request, Response, Next) // // Verify token // - await oidcVerifyToken(oidc_token).then(function(){ + await oidcVerifyToken(oidc_token, extRouterName).then(function() + { // // oidc token verified // // sessionize - Response.session = null; // session removed - Response.cookie(oidcConfig.params.cookiename, oidc_token, { + Response.session = null; // session removed + + // token cookie + Response.cookie(oidcConfig[extRouterName].params.cookiename, oidc_token, { httpOnly: true, secure: Request.protocol === 'https', - maxAge: oidcConfig.params.cookieexpire * 1000, // set expire + maxAge: oidcConfig[extRouterName].params.cookieexpire * 1000, // set expire }); + + // oidc name cookie + Response.cookie(oidcConfigCookieName, extRouterName, { + httpOnly: true, + secure: Request.protocol === 'https', + maxAge: oidcConfig[extRouterName].params.cookieexpire * 1000, // set expire + }); + Response.redirect('/'); }).catch(function(err){ console.error('Failed to verify oidc token by ' + err.message); - Response.status(401); // 401: Unauthorized + Response.status(401); // 401: Unauthorized return; }); }).catch(function(err){ error = 'Failed to get oidc token in request.' + err.message; console.error(error); - Response.status(401); // 401: Unauthorized + Response.status(401); // 401: Unauthorized return; }); }; @@ -400,6 +541,9 @@ var sessionize = async function(Request, Response, Next) // // GET '//login/cb' : Login callback url // +// URL Arguments +// extrouter : +// router.get('/login/cb', sessionize); //-------------------------------------------------------------- @@ -408,9 +552,14 @@ router.get('/login/cb', sessionize); // // GET '//logout' : logout for OIDC // +// URL Arguments +// extrouter : +// router.get('/logout', function(Request, Response, Next) // eslint-disable-line no-unused-vars { - if(!r3util.isSafeEntity(oidcConfig)){ + var extRouterName = rawGetExtRouterName(Request); + + if(!r3util.isSafeEntity(oidcConfig[extRouterName])){ var error = 'Please check your configuarion(json) because it is invalid.'; console.error('Failed logout processing, ' + error); Response.status(500); // 500: Internal Server Error @@ -421,9 +570,10 @@ router.get('/logout', function(Request, Response, Next) // eslint-disable-line // // Cleanup : clear the cookie if exist // - Response.clearCookie(oidcConfig.params.cookiename); // cookie name(id_token as deafult) + Response.clearCookie(oidcConfig[extRouterName].params.cookiename); // cookie name(id_token as deafult) + Response.clearCookie(oidcConfigCookieName); // cookie name(oidc config name) - Response.redirect(oidcConfig.mainUrl); + Response.redirect(oidcConfig[extRouterName].mainUrl); return; }); @@ -435,7 +585,7 @@ router.get('/logout', function(Request, Response, Next) // eslint-disable-line // setConfig is called in app.js to set configurations that are // defined in configuration file(json) // -var setConfig = function(config) +var setConfig = function(config, extRouterName) { // check required member in config if( !r3util.isSafeEntity(config) || @@ -450,18 +600,27 @@ var setConfig = function(config) console.error('Please check your configuarion(json) because it is invalid : config = ' + JSON.stringify(config)); return false; } - oidcConfig = config; + if(!r3util.isSafeString(extRouterName)){ + console.error('Please check your configuarion(json) because it does not have ' + JSON.stringify(extRouterName) + ' entity or it is empty.'); + return false; + } + if(r3util.isSafeEntity(oidcConfig[extRouterName])){ + console.error('Please check your configuarion(json) because it has multi ' + JSON.stringify(extRouterName) + ' entities.'); + return false; + } + + oidcConfig[extRouterName] = config; - if(!r3util.isSafeEntity(oidcConfig.params.usernamekey)){ + if(!r3util.isSafeEntity(oidcConfig[extRouterName].params.usernamekey)){ console.warn('The key name in configuration(usernamekey) is empty, then it check will no longer be performed.'); } - if(!r3util.isSafeEntity(oidcConfig.params.cookiename)){ + if(!r3util.isSafeEntity(oidcConfig[extRouterName].params.cookiename)){ console.warn('The cookie name in configuration(cookiename) is empty, so id_token is used as default.'); - oidcConfig.params.cookiename = 'id_token'; + oidcConfig[extRouterName].params.cookiename = 'id_token'; } - if(!r3util.isSafeEntity(oidcConfig.params.cookieexpire) || 'number' != typeof oidcConfig.params.cookieexpire){ + if(!r3util.isSafeEntity(oidcConfig[extRouterName].params.cookieexpire) || 'number' != typeof oidcConfig[extRouterName].params.cookieexpire){ console.warn('The cookie expire(sec) in configuration(cookieexpire) is empty, so id_token is used as default.'); - oidcConfig.params.cookieexpire = 60; // 60 sec as default + oidcConfig[extRouterName].params.cookieexpire = 60; // 60 sec as default } return true; }; @@ -486,9 +645,10 @@ var getConfig = function() // Exports //--------------------------------------------------------- module.exports = { - router: router, - setConfig: setConfig, - getConfig: getConfig + router: router, + setConfig: setConfig, + getConfig: getConfig, + oidcConfigCookieName: oidcConfigCookieName }; /* diff --git a/src/components/r3appbar.jsx b/src/components/r3appbar.jsx index f69e44c..5e5bb6b 100644 --- a/src/components/r3appbar.jsx +++ b/src/components/r3appbar.jsx @@ -54,6 +54,7 @@ const menuValues = { license: 'LICENSES_TOP', noLicense: 'NOLICENSE', sign: 'SIGNINOUT', + signSub: 'SIGNINOUT_SUB', userName: 'USERNAME', account: 'ACCOUNT' }; @@ -63,9 +64,11 @@ const tooltipValues = { mainMenu: 'mainMenu' }; -const mainMenuId = 'appbar-main-menu'; -const licenseMenuId = 'appbar-license-menu'; -const accountMenuId = 'appbar-sign-menu'; +const mainMenuId = 'appbar-main-menu'; +const licenseMenuId = 'appbar-license-menu'; +const accountMenuId = 'appbar-sign-menu'; +const signinSubMenuId = 'appbar-sign-sub-menu'; +const signinSubMenuPrefix = 'appbar-signin-sub-menu-'; // // AppBar Class @@ -102,6 +105,7 @@ export default class R3AppBar extends React.Component r3Message: null, mainMenuAnchorEl: null, signMenuAnchorEl: null, + signSubMenuAnchorEl: null, licenseMenuAnchorEl: null, tooltips: { @@ -131,12 +135,15 @@ export default class R3AppBar extends React.Component if(this.checkContentUpdating()){ if('SIGNINOUT' === value){ this.props.onSign(); + }else if(0 == value.indexOf(signinSubMenuPrefix)){ + this.props.onSign(value.replace(signinSubMenuPrefix, '')); } } // closing menu this.setState({ - signMenuAnchorEl: null + signMenuAnchorEl: null, + signSubMenuAnchorEl: null }); }; @@ -144,6 +151,7 @@ export default class R3AppBar extends React.Component { this.setState({ signMenuAnchorEl: event.currentTarget, + signSubMenuAnchorEl: null, tooltips: { accountMenuTooltip: false } @@ -153,7 +161,8 @@ export default class R3AppBar extends React.Component handleSignMenuClose(event) // eslint-disable-line no-unused-vars { this.setState({ - signMenuAnchorEl: null + signMenuAnchorEl: null, + signSubMenuAnchorEl: null }); } @@ -196,6 +205,16 @@ export default class R3AppBar extends React.Component } } + }else if(menuValues.signSub === value){ + if(this.checkContentUpdating()){ + if(!this.state.signSubMenuAnchorEl){ + this.setState({ + signSubMenuAnchorEl: event.currentTarget + }); + return; + } + } + }else if(!isNaN(value)){ let _appmenu= this.context.r3Context.getSafeAppMenu(); let _pos = parseInt(value); @@ -211,6 +230,7 @@ export default class R3AppBar extends React.Component this.setState({ mainMenuAnchorEl: null, licenseMenuAnchorEl: null, + signSubMenuAnchorEl: null, tooltips: { mainMenuTooltip: false } @@ -284,18 +304,45 @@ export default class R3AppBar extends React.Component return true; } + getSigninSubMenuItems() + { + let signInObj = this.context.r3Context.getSafeSignInUrl(); + let _menuitems = []; + let _this = this; + + Object.keys(signInObj).forEach(function(configName) + { + let _menuName = signinSubMenuPrefix + configName; + _menuitems.push( + _this.handleSignMenuChange(event, _menuName) } + > + { r3IsEmptyString(signInObj[configName].display) ? configName : signInObj[configName].display } + + ); + }); + return _menuitems; + } + getAccountButton() { const { theme, r3provider } = this.props; - let accountButton = (this.context.r3Context.isLogin() ? theme.r3AppBar.signinButton : theme.r3AppBar.signoutButton); - let accountButtonIcon = (this.context.r3Context.isLogin() ? this.sxClasses.signinButton : this.sxClasses.signoutButton); - let btnText = (this.context.r3Context.isLogin() ? r3provider.getR3TextRes().tResSignoutMenu : r3provider.getR3TextRes().tResSigninMenu); + let isLogined = this.context.r3Context.isLogin(); + let accountButton = (isLogined ? theme.r3AppBar.signinButton : theme.r3AppBar.signoutButton); + let accountButtonIcon = (isLogined ? this.sxClasses.signinButton : this.sxClasses.signoutButton); let userMenuItem; let menuDivider; let menuAccountItem; - if(this.context.r3Context.isLogin()){ + let menuSignInItem; + let menuSignOutItem; + + if(isLogined){ + // + // Current signin + // userMenuItem = ( ); + menuDivider = ( ); + menuAccountItem = ( ); + + menuSignOutItem = ( + this.handleSignMenuChange(event, menuValues.sign) } + > + { r3provider.getR3TextRes().tResSignoutMenu } + + ); + + }else{ + // + // Current signout + // + if(1 < this.context.r3Context.getSafeConfigCount(true)){ + // + // Has many singin logic + // + menuSignInItem = ( + this.handleMenuChange(event, menuValues.signSub) } + > + { r3provider.getR3TextRes().tResSigninMenu } + + + + + { this.getSigninSubMenuItems() } + + + ); + + }else{ + // + // Has only one singin logic + // + menuSignInItem = ( + this.handleSignMenuChange(event, menuValues.sign) } + > + { r3provider.getR3TextRes().tResSigninMenu } + + ); + } } return ( @@ -349,12 +452,8 @@ export default class R3AppBar extends React.Component { userMenuItem } { menuDivider } { menuAccountItem } - this.handleSignMenuChange(event, menuValues.sign) } - > - { btnText } - + { menuSignInItem } + { menuSignOutItem } ); diff --git a/src/components/r3container.jsx b/src/components/r3container.jsx index 2e358d5..e9ba622 100644 --- a/src/components/r3container.jsx +++ b/src/components/r3container.jsx @@ -1143,7 +1143,7 @@ export default class R3Container extends React.Component // // Handle Singin/Signout // - handleSign() + handleSign(configName) { let type = this.r3provider.getR3Context().getSignInType(); @@ -1152,10 +1152,32 @@ export default class R3Container extends React.Component // Unscoped Token Login Type // let signurl = ''; + if(this.r3provider.getR3Context().isLogin()){ - signurl = this.r3provider.getR3Context().getSafeSignOutUrl(); + if(!r3IsEmptyString(configName)){ + console.info('Signout does not require config name, but ' + configName + ' is specified, it is ignored.'); + } + configName = this.r3provider.getR3Context().getSafeConfigName(); + signurl = this.r3provider.getR3Context().getSafeSignOutUrl(configName); // value is 'URL' + }else{ - signurl = this.r3provider.getR3Context().getSafeSignInUrl(); + if(!r3IsEmptyString(configName)){ + signurl = this.r3provider.getR3Context().getSafeSignInUrl(configName).url; // value is 'URL' + + }else if(1 <= this.r3provider.getR3Context().getSafeConfigCount(true)){ + let signinObj = this.r3provider.getR3Context().getSafeSignInUrl(); + + Object.keys(signinObj).forEach(function(_configName){ + // Get first config name + if(r3IsEmptyString(configName)){ + configName = _configName; + } + }); + signurl = this.r3provider.getR3Context().getSafeSignInUrl(configName).url; // value is 'URL' + + }else{ + console.info('Signin URL does not find.'); + } } window.location.href = signurl; diff --git a/src/components/r3theme.jsx b/src/components/r3theme.jsx index 2e18b1a..72f471a 100644 --- a/src/components/r3theme.jsx +++ b/src/components/r3theme.jsx @@ -219,6 +219,16 @@ const r3Theme = createTheme({ 'aria-label': 'signout menu', 'aria-haspopup': 'true' }, + signinSubMenu: { + anchorOrigin: { + vertical: 'bottom', + horizontal: 'left' + }, + transformOrigin: { + vertical: 'top', + horizontal: 'right' + } + }, accountMenu: { anchorOrigin: { vertical: 'bottom', diff --git a/src/util/r3context.js b/src/util/r3context.js index 932ba97..28ffd80 100644 --- a/src/util/r3context.js +++ b/src/util/r3context.js @@ -20,7 +20,7 @@ */ import { kwApiHostForUD, kwIncludePathForUD, kwRoleTokenForSecret, kwRawRoleToken, kwRoleTokenForRoleYrn, signinUnknownType, signinUnscopedToken, signinCredential } from '../util/r3types'; -import { r3ConvertFromJSON, r3UnescapeHTML, r3CompareCaseString, r3IsEmptyString, r3IsEmptyEntity, r3IsSafeTypedEntity, r3DeepClone } from '../util/r3util'; +import { r3ConvertFromJSON, r3UnescapeHTML, r3CompareCaseString, r3IsEmptyString, r3IsEmptyEntity, r3IsSafeTypedEntity, r3IsEmptyEntityObject, r3DeepClone } from '../util/r3util'; // // Load Global object for K2HR3 Context @@ -123,6 +123,48 @@ const r3GlobalObject = (function() console.info('There is no crcobj.'); } + // signinurl + let _signinurl = null; + if(!r3IsEmptyEntity(r3globaltmp.signinurl)){ + let _signinurljson = unescape(r3globaltmp.signinurl); // decode + let _signinurlobj = r3ConvertFromJSON(_signinurljson); // parse + if(!r3IsEmptyEntity(_signinurlobj)){ + _signinurl = r3DeepClone(_signinurlobj); + }else{ + console.info('signinurl object is not safe object.'); + } + }else{ + console.info('There is no signinurl object.'); + } + + // signouturl + let _signouturl = null; + if(!r3IsEmptyEntity(r3globaltmp.signouturl)){ + let _signouturljson = unescape(r3globaltmp.signouturl); // decode + let _signouturlobj = r3ConvertFromJSON(_signouturljson); // parse + if(!r3IsEmptyEntity(_signouturlobj)){ + _signouturl = r3DeepClone(_signouturlobj); + }else{ + console.info('signouturl object is not safe object.'); + } + }else{ + console.info('There is no signouturl object.'); + } + + // configname + let _configname = null; + if(!r3IsEmptyEntity(r3globaltmp.configname)){ + let _confignamejson = unescape(r3globaltmp.configname); // decode + let _confignamestr = r3ConvertFromJSON(_confignamejson); // parse + if(!r3IsEmptyString(_confignamestr)){ + _configname = _confignamestr; + }else{ + console.info('configname is not safe string.'); + } + }else{ + console.info('There is no configname string.'); + } + // default object values let r3globalobj = { apischeme: (r3IsEmptyString(r3globaltmp.r3apischeme) ? '' : r3globaltmp.r3apischeme), @@ -137,8 +179,9 @@ const r3GlobalObject = (function() username: '', unscopedtoken: '', signintype: (r3CompareCaseString(r3globaltmp.signintype, signinUnscopedToken) ? signinUnscopedToken : r3CompareCaseString(r3globaltmp.signintype, signinCredential) ? signinCredential : signinUnknownType), - signinurl: (r3IsEmptyString(r3globaltmp.signinurl) ? null : r3globaltmp.signinurl), - signouturl: (r3IsEmptyString(r3globaltmp.signouturl) ? null : r3globaltmp.signouturl), + signinurl: _signinurl, + signouturl: _signouturl, + configname: _configname, uselocaltenant: (r3IsEmptyEntity(r3globaltmp.uselocaltenant) ? true : r3globaltmp.uselocaltenant), lang: (r3IsEmptyString(r3globaltmp.lang) ? 'en' : r3globaltmp.lang), dbgheader: (r3IsEmptyString(r3globaltmp.dbgheader) ? '' : r3globaltmp.dbgheader), @@ -180,6 +223,7 @@ export default class R3Context this.signintype = r3GlobalObject.signintype; // SignIn Type this.signinurl = r3GlobalObject.signinurl; // SignIn URL this.signouturl = r3GlobalObject.signouturl; // SignOut URL + this.configname = r3GlobalObject.configname; // Config Name(If using ExtRouter(ex. OIDC) and has a token, the config name that created the token is set.) this.uselocaltenant = r3GlobalObject.uselocaltenant;// Use Local Tenant this.lang = r3GlobalObject.lang; // Text resource language this.dbgHeaderName = r3GlobalObject.dbgheader; // Debug header name(= 'x-k2hr3-debug') @@ -441,14 +485,62 @@ export default class R3Context return this.signintype; } - getSafeSignInUrl() + getSafeSignInUrl(configName) { - return (r3IsEmptyString(this.signinurl) ? '' : r3UnescapeHTML(this.signinurl)); + if(r3IsEmptyString(configName)){ + // + // Return all object + // + return (r3IsEmptyEntity(this.signinurl) ? {} : r3DeepClone(this.signinurl)); + + }else if(!r3IsEmptyEntityObject(this.signinurl, configName)){ + // + // Return single object + // + return this.signinurl[configName]; + }else{ + return null; + } + } + + getSafeSignOutUrl(configName) + { + if(r3IsEmptyString(configName)){ + // + // Return all object + // + return (r3IsEmptyEntity(this.signouturl) ? {} : r3DeepClone(this.signouturl)); + + }else if(!r3IsEmptyEntityObject(this.signouturl, configName)){ + // + // Return single string + // + return this.signouturl[configName]; + }else{ + return null; + } + } + + getSafeConfigName() + { + return (r3IsEmptyEntity(this.configname) ? '' : r3UnescapeHTML(this.configname)); } - getSafeSignOutUrl() + getSafeConfigCount(isSignin) { - return (r3IsEmptyString(this.signouturl) ? '' : r3UnescapeHTML(this.signouturl)); + if(isSignin){ + if(r3IsEmptyEntity(this.signinurl)){ + return 0; + }else{ + return Object.keys(this.signinurl).length; + } + }else{ + if(r3IsEmptyEntity(this.signouturl)){ + return 0; + }else{ + return Object.keys(this.signouturl).length; + } + } } useLocalTenant() diff --git a/tests/__tests__/__snapshots__/r3aboutdialog-test.jsx.snap b/tests/__tests__/__snapshots__/r3aboutdialog-test.jsx.snap index d1433da..fe7b78c 100644 --- a/tests/__tests__/__snapshots__/r3aboutdialog-test.jsx.snap +++ b/tests/__tests__/__snapshots__/r3aboutdialog-test.jsx.snap @@ -36,6 +36,7 @@ exports[`R3AboutDialog test snapshot for R3AboutDialog 1`] = ` "zIndex": -1, }, [Function], + [Function], ], "defaultProps": undefined, "render": [Function], diff --git a/tests/__tests__/__snapshots__/r3accountdialog-test.jsx.snap b/tests/__tests__/__snapshots__/r3accountdialog-test.jsx.snap index 2b092eb..32a1be9 100644 --- a/tests/__tests__/__snapshots__/r3accountdialog-test.jsx.snap +++ b/tests/__tests__/__snapshots__/r3accountdialog-test.jsx.snap @@ -36,6 +36,7 @@ exports[`R3AccountDialog test snapshot for R3AccountDialog 1`] = ` "zIndex": -1, }, [Function], + [Function], ], "defaultProps": undefined, "render": [Function], @@ -112,6 +113,7 @@ exports[`R3AccountDialog test snapshot for R3AccountDialog 1`] = `