const http = require('http')
const queryString = require('querystring')
const cookie = require('cookie')
const UB = require('@unitybase/ub')
const App = UB.App
/**
* OpenIDConnect client for UnityBase
*
const openID = require('@unitybase/openid-connect')
let oIdEndPoint = openID.registerEndpoint('openIDConnect')
oIdEndPoint.registerProvider('Google', {
authUrl: 'https://accounts.google.com/o/oauth2/auth',
tokenUrl: 'https://accounts.google.com/o/oauth2/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v1/userinfo',
userInfoHTTPMethod: 'GET',
scope: 'openid',
nonce: '123',
response_type: 'code',
client_id: '350085411136-lpj0qvr87ce0r0ae0a3imcm25joj2t2o.apps.googleusercontent.com',
client_secret: 'dF4qmUxhHoBAj-E1R8YZUCqA',
// getOnFinishAction: function (response) {
// return 'opener.$App.onFinishOpenIDAuth(' + JSON.stringify(response) + '); close();'
// },
getUserID: function(userInfo) {
let uID = UB.Repository('uba_user').attrs(['ID'])
.where('[name]', '=', userInfo.id).selectScalar()
return uID || null
}
})
*
* @module @unitybase/openid-connect
* @tutorial security_openidconnect
*/
module.exports.registerEndpoint = registerOpenIDEndpoint
const endpoints = {}
const OIDC_SESSID_COOKIE = 'OIDC_SESSID'
/**
* OpenID endpoint. Able to register providers
* @typedef {object} openIDEndpoint
* @propety {Function} registerProvider
*/
/**
* Register openID connect endpoint. In case endpoint already registered - return existed
*
* @method
* @param {string} endpointName
* @returns {openIDEndpoint} endpoint
*/
function registerOpenIDEndpoint (endpointName) {
const providers = {}
if (endpoints[endpointName]) {
return endpoints[endpointName]
}
App.registerEndpoint(endpointName, openIDConnect, false)
endpoints[endpointName] = {
/**
* Add provider to endpoint
* @param {string} name
* @param {object} providerConfig
* @param {string} providerConfig.authUrl Provider's authorisation url
* @param {string} providerConfig.tokenUrl Provider's token url
* @param {string} providerConfig.userInfoUrl provider's userinfo url
* @param {string} [providerConfig.logoutUrl] Logout url
* @param {string} [providerConfig.userInfoHTTPMethod='GET'] Http method for userinfo request. Default GET
* @param {string} providerConfig.scope Requested scopes delimited by '+' symbol
* @param {string} [providerConfig.resource] Requested resource (required for MS ADFS3 windows server 2012)
* @param {string} [providerConfig.nonce] nonce TODO - generate random and cache in GlobalCache with expire
* @param {string} providerConfig.response_type response type. Must contain code. This module use code responce type.
* @param {string} providerConfig.client_id client_id. Get it from provider
* @param {string} [providerConfig.client_secret] client_secret. Get it from provider (not needed for ADFS3 - windows server 2012)
* @param {string} [providerConfig.cert] id.gov.ua specific - a BASE64 key distribution protocol certificate
* to which the response will be encrypted by the authentication server. see https://id.gov.ua/downloads/IDInfoProcessingD.pdf
* @param {string} [providerConfig.fields] id.gov.ua specific - comma separated names of the requested user certificate fields.
* If the names of the fields are not specified, then all available certificate fields are returned
* with information about the user (certificate owner) and the issuer during call to `userInfoUrl` provider endpoint
* @param {Function} providerConfig.getCustomFABody Function, that returns custom text included to final html success/fail response
* @param {string} providerConfig.response_mode One of: form_post, fragment, query
* @param {Function} providerConfig.getOnFinishAction Function, that returns client-side code to be run after success/fail response from OpenID provider.
* For example: `opener.$App.onFinishOpenIDAuth`. In case of success will be called with `{success: true, data: userData, secretWord: secretWord}`
* In case of fail `{success: false}`
* @param {Function} providerConfig.getUserID Called with one 1 parameter - provider's response for userInfo request. Must return user ID from uba_user entity if user is authorised or null else
* @memberOf openIDEndpoint
*/
registerProvider: function (name, providerConfig) {
// todo: check all parameters and add documentation for class
if (providers[name]) {
throw new Error('Provider already registered')
}
if (!providerConfig.getOnFinishAction) {
// throw new Error('getOnFinishAction must me be a function')
providerConfig.getOnFinishAction = function (response) {
if (response && response.success === false) {
return `const url = '${App.externalURL}' + '/openIDConnect/${name}?logout=true'
document.location.assign(url)`
}
return `
var response = ${JSON.stringify(response)};
(opener || window).postMessage(response, "*");
window.close();
`
}
}
providers[name] = providerConfig
},
getProviderList: function () {
return Object.keys(providers)
},
getProvider: function (providerName) {
return providers[providerName]
}
}
return endpoints[endpointName]
}
/**
* OpenID endpoint implementation
* If called as host:port[/app]/endpoint - return a list of registered openIDConnect providers,
* If called as host:port[/app]/endpoint/provider without parameters - redirect to provider `authUrl`
* If called as host:port[/app]/endpoint/provider with parameters `code` and `state` - call doProviderAuthHandshake method
* If called as host:port[/app]/endpoint/provider with parameters `logout` - redirect to logout url
*
* @param {THTTPRequest} req
* @param {THTTPResponse} resp
* @protected
*/
function openIDConnect (req, resp) {
const providerName = req.uri
const url = req.url.split('?')[0]
const endpointUrl = providerName ? url.substr(0, url.length - providerName.length - 1) : url
const endpointName = endpointUrl.substr(endpointUrl.lastIndexOf('/') - endpointUrl.length + 1)
const endpoint = endpoints[endpointName]
if (!endpoint) {
returnInvalidRequest(resp, 'Endpoint is not registered')
return
}
if (!providerName) {
returnProviderList(resp, endpoint)
return
}
const provider = endpoint.getProvider(providerName)
if (!provider) {
returnInvalidRequest(resp, 'Provider not registered')
return
}
const redirectUrl = App.externalURL + (App.externalURL[App.externalURL.length - 1] === '/' ? '' : '/') + endpointName + '/' + providerName
const paramStr = (req.method === 'GET') ? req.parameters : req.read()
const params = (req.method === 'GET') ? req.parsedParameters : queryString.parse(paramStr)
if (!paramStr || params.mode === 'auth') {
redirectToProviderAuth(req, resp, provider, redirectUrl, params)
return
}
if (params.code && params.state) {
// if (!redirectUrl)
// redirectUrl = atob(params.state);
const origin = req.getHeader('origin') || ''
// validate session ID from cookies match state
const cookiesStr = req.getHeader('Cookie') || ''
console.debug('Cookie header value:', cookiesStr)
const cookies = cookie.parse(cookiesStr)
const ssid = cookies[OIDC_SESSID_COOKIE]
let err = false
let ssidKey
if (!ssid) {
// App.globalCachePut(OIDC_SESSID_COOKIE + randomSid, randomState)
// resp.writeHead(`Set-Cookie: ${OIDC_SESSID_COOKIE}=${randomSid}; Secure; HttpOnly`)
console.error('OIDC: Cookie', OIDC_SESSID_COOKIE, ' is required')
err = true
} else {
ssidKey = OIDC_SESSID_COOKIE + ssid
const sessionState = App.globalCacheGet(ssidKey)
if (!sessionState) {
console.error('OIDC: state for ssid', ssidKey, ' not found')
err = true
} else if (sessionState !== params.state) {
console.error('OIDC: initial state for ssid', ssidKey, 'is', sessionState, 'but got', params.state)
err = true
}
}
if (err) {
notifyProviderError(resp, provider)
} else {
App.globalCachePut(ssidKey, null) // remove key
doProviderAuthHandshake(resp, params.code, params.state, provider, redirectUrl, origin)
}
} else if (params.logout) {
redirectToProviderLogout(req, resp, provider, params)
} else {
// - Add custom params for openID auth
redirectToProviderAuth(req, resp, provider, redirectUrl, params)
// notifyProviderError(resp, provider)
}
}
function returnProviderList (resp, endpoint) {
resp.statusCode = 200
resp.writeEnd(JSON.stringify(endpoint.getProviderList()))
}
/**
*
* @param {object} customHeaders
* @returns {string}
* @private
*/
function getAuthCustomHeadersString (customHeaders) {
if (customHeaders === null) {
return ''
}
if (typeof customHeaders !== 'object') {
throw new Error('custom headers must be an object')
}
const headerStringArray = []
Object.keys(customHeaders).forEach(function (key) {
headerStringArray.push(key + '=' + customHeaders[key])
})
return '&' + headerStringArray.join('&')
}
function redirectToProviderLogout (req, resp, providerConfig, requestParams) {
resp.statusCode = 302
resp.writeEnd('')
resp.writeHead('Location: ' + providerConfig.logoutUrl)
}
function randomStr () {
// eslint-disable-next-line no-undef
return nsha256(createGuid()).substring(1, 12)
}
function genRandomSid () {
let t = 1
let res
while (t < 10) {
res = randomStr()
if (!App.globalCacheGet(OIDC_SESSID_COOKIE + res)) break
t++
}
return t === 10 ? res + 'C' : res
}
function redirectToProviderAuth (req, resp, providerConfig, redirectUrl, requestParams) {
resp.statusCode = 302
let customHeaders = ''
if (typeof providerConfig.getAuthCustomHeaders === 'function') {
customHeaders = getAuthCustomHeadersString(providerConfig.getAuthCustomHeaders(requestParams))
}
const randomState = randomStr() // Create a state token to prevent request forgery
const randomSid = genRandomSid() // Session ID, stored in cookie
resp.statusCode = 302
resp.writeEnd('')
resp.writeHead('Location: ' + providerConfig.authUrl +
'?state=' + randomState +
(providerConfig.scope ? '&scope=' + providerConfig.scope : '') +
(providerConfig.resource ? '&resource=' + providerConfig.resource : '') +
(providerConfig.nonce ? '&nonce=' + providerConfig.nonce : '') +
'&redirect_uri=' + redirectUrl +
'&response_type=' + providerConfig.response_type +
'&client_id=' + providerConfig.client_id +
'&response_mode=' + (providerConfig.response_mode || 'form_post') +
customHeaders
)
App.globalCachePut(OIDC_SESSID_COOKIE + randomSid, randomState) // store session state token in global cache
resp.writeHead(`Set-Cookie: ${OIDC_SESSID_COOKIE}=${randomSid}; Secure; HttpOnly`)
}
/**
* @param {THTTPResponse} resp
* @param provider
* @private
*/
function notifyProviderError (resp, provider) {
resp.statusCode = 200
resp.write('<!DOCTYPE html><html>')
resp.write('<head><meta http-equiv="X-UA-Compatible" content="IE=edge" /></head>')
resp.write('<body>')
if (provider.getCustomFABody) {
const customFABody = provider.getCustomFABody({ success: false })
if (customFABody) {
resp.write(customFABody)
}
}
resp.write('<script>')
resp.write(provider.getOnFinishAction({ success: false }))
resp.write('</script></body>')
resp.writeEnd('</html>')
resp.writeHead('Content-Type: text/html')
}
function doProviderAuthHandshake (resp, code, state, provider, redirectUrl, orign) {
let responseData
let request = http.request(provider.tokenUrl)
request.options.method = 'POST'
request.setHeader('Content-Type', 'application/x-www-form-urlencoded')
request.write('grant_type=authorization_code')
request.write('&code=' + code)
request.write('&client_id=' + provider.client_id)
request.write('&client_secret=' + provider.client_secret)
request.write('&redirect_uri=' + redirectUrl)
let response = request.end()
if (response.statusCode === 200) {
if (provider.userInfoUrl) {
responseData = response.json() // response._http.responseText
if (responseData.id_token) {
provider.id_token = responseData.id_token
}
if (provider.userInfoHTTPMethod === 'POST') {
request = http.request(provider.userInfoUrl)
request.options.method = 'POST'
request.write('access_token=' + responseData.access_token)
if (provider.fields) { // id.gov.ua specific
request.write('&fields=' + provider.fields)
}
if (responseData.user_id) {
request.write('&user_id=' + responseData.user_id)
}
if (provider.cert) {
request.write('&cert=' + provider.cert) // id.gov.ua specific
} else {
request.write('&client_id=' + provider.client_id)
}
if (provider.client_secret) {
request.write('&client_secret=' + provider.client_secret)
}
request.setHeader('Content-Type', 'application/x-www-form-urlencoded')
} else {
request = http.request(provider.userInfoUrl + '?access_token=' + responseData.access_token)
}
response = request.end()
}
if (response.statusCode === 200) {
responseData = JSON.parse(response.read()) // response._http.responseText
const userID = provider.getUserID(responseData, { resp, code, state, provider, redirectUrl, orign })
if (userID === false) {
return
}
if (!userID) {
notifyProviderError(resp, provider)
} else {
const loginResp = UB.Session.setUser(userID, code)
const objConnectParam = { success: true, data: JSON.parse(loginResp), secretWord: code }
resp.statusCode = 200
resp.write('<!DOCTYPE html><html>')
resp.write('<head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge" /></head>')
resp.write('<body>')
if (provider.getCustomFABody) {
const customFABody = provider.getCustomFABody(objConnectParam)
if (customFABody) {
resp.write(customFABody)
}
}
resp.write('<div style="display: none;" id="connectParam" >')
resp.write(JSON.stringify(objConnectParam))
resp.write('</div><div id="mainElm" onClick="window.close();" style="width:100%; height:100vh" ></div><script>')
resp.write(provider.getOnFinishAction(objConnectParam))
resp.write('</script></body>')
resp.writeEnd('</html>')
resp.writeHead('Content-Type: text/html')
resp.writeHead('Access-Control-Allow-Credentials:true')
resp.writeHead('Access-Control-Allow-Methods:GET, OPTIONS')
// Xhr back redirect return Origin equal null. Null without space ignored in header
if (orign && orign.trim() === 'null') {
resp.writeHead('Access-Control-Allow-Origin: null')
} else {
resp.writeHead('Access-Control-Allow-Origin:' + orign ? orign.trim() : 'null')
}
}
}
} else {
console.error('OpenIDConnect provider return invalid response', response.statusCode, '. Headers:', response.headers)
notifyProviderError(resp, provider)
}
}
function returnInvalidRequest (resp, message) {
resp.statusCode = 400
resp.writeEnd(message)
}