ubjs/packages/openid-connect/openIDConnect.js

/**
 * 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.UB.view.LoginWindow.onFinishOpenIDAuth(' + JSON.stringify(response) + '); close();'
        },
        getUserID: function(userInfo) {
          let inst = UB.Repository('uba_user').attrs(['ID'])
             .where('[name]', '=', userInfo.id).select()
          return inst.eof ? null : inst.get('ID')
        }
      })
 *
 * @module @unitybase/openid-connect
 * @tutorial security_openidconnect
 */

const btoa = require('btoa')

module.exports.registerEndpoint = registerOpenIDEndpoint

let endpoints = {}
const queryString = require('querystring')

/**
 * OpenID endpoint. Able to register providers
 * @typedef {Object} openIDEndpoint
 * @propety {function} registerProvider
 */

/**
 * Register openID connect endpoint
 * @method
 * @param {String} endpointName
 * @returns {openIDEndpoint} endpoint
 */
function registerOpenIDEndpoint (endpointName) {
  let providers = {}
  if (endpoints[endpointName]) {
    throw new Error('Endpoints already registered')
  }

  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.userInfoHTTPMethod='GET'] Http method for userinfo request. Default GET
     * @param {String} providerConfig.scope requested scopes delimited by '+' symbol
     * @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
     * @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')
      }
      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
 *
 * @param {THTTPRequest} req
 * @param {THTTPResponse} resp
 * @protected
 */
function openIDConnect (req, resp) {
  let providerName = req.uri
  let url = req.url.split('?')[0]
  let endpointUrl = providerName ? url.substr(0, url.length - providerName.length - 1) : url
  let endpointName = endpointUrl.substr(endpointUrl.lastIndexOf('/') - endpointUrl.length + 1)
  let endpoint = endpoints[endpointName]

  if (!endpoint) {
    returnInvalidRequest(resp, 'Endpoint is not registered')
    return
  }
  if (!providerName) {
    returnProviderList(resp, endpoint)
    return
  }

  let provider = endpoint.getProvider(providerName)
  if (!provider) {
    returnInvalidRequest(resp, 'Provider not registered')
    return
  }

  // TODO - check Refer (if any) is starts from  App.serverURL
  // redirectUrl = getValueFromHeaders(req, 'Referer');
  // if (redirectUrl) {
  //    redirectUrl = redirectUrl.substr(0, redirectUrl.lastIndexOf('/')) + '/' + endpointName + '/' + providerName;
  // }
  let redirectUrl = App.serverURL + (App.serverURL[App.serverURL.length - 1] === '/' ? '' : '/') + endpointName + '/' + providerName
  let paramStr = (req.method === 'GET') ? req.parameters : req.read()
  let params = paramStr ? queryString.parse(paramStr) : null

  if (!paramStr || params.mode === 'auth') {
    redirectToProviderAuth(req, resp, provider, redirectUrl, params)
    return
  }

  params = queryString.parse(paramStr)
  if (params.code && params.state) {
    // if (!redirectUrl)
    //    redirectUrl = atob(params.state);
    let origin = ''
    let headers = (req.headers || '').split('\r\n')
    if (headers) {
      headers.forEach(function (v) {
        if (v && v.substring(0, 7) === 'Origin:') {
          origin = v.substring(7)
          return false
        }
      })
    }
    doProviderAuthHandshake(resp, params.code, params.state, provider, redirectUrl, origin)
  } else {
    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')
  }

  let headerStringArray = []

  Object.keys(customHeaders).forEach(function (key) {
    headerStringArray.push(key + '=' + customHeaders[key])
  })

  return '&' + headerStringArray.join('&')
}

function redirectToProviderAuth (req, resp, providerConfig, redirectUrl, requestParams) {
  resp.statusCode = 302
  let customHeaders = ''

  if (typeof providerConfig.getAuthCustomHeaders === 'function') {
    customHeaders = getAuthCustomHeadersString(providerConfig.getAuthCustomHeaders(requestParams))
  }
  resp.statusCode = 302
  resp.writeEnd('')
  resp.writeHead('Location: ' + providerConfig.authUrl +
    '?state=' + btoa('fakestate') +  //TODO - get it from global cache
    (providerConfig.scope ? '&scope=' + providerConfig.scope : '') +
    (providerConfig.nonce ? '&nonce=' + providerConfig.nonce : '') +
    '&redirect_uri=' + redirectUrl +
    '&response_type=' + providerConfig.response_type +
    '&client_id=' + providerConfig.client_id +
    '&response_mode=form_post' +
    '&referer=' + redirectUrl +
    // '&referer=BACK_REDIRECT_URL' +
    customHeaders
  )
}

/**
 * @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><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 http = require('http')
  let request
  let response
  let responseData
  let userID

  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)
  response = request.end()

  if (response.statusCode === 200) {
    responseData = JSON.parse(response.read()) // response._http.responseText
    if (provider.userInfoHTTPMethod === 'POST') {
      request = http.request(provider.userInfoUrl)
      request.options.method = 'POST'
      request.write('access_token=' + responseData.access_token)
      request.write('&client_id=' + provider.client_id)
      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._http.responseText)
      userID = provider.getUserID(responseData)

      if (!userID) {
        notifyProviderError(resp, provider)
      } else {
        let loginResp = Session.switchUser(userID, code)
        let objConnectParam = {success: true, data: JSON.parse(loginResp), secretWord: code}
        resp.statusCode = 200
        resp.write('<!DOCTYPE html><html>')
        resp.write('<head><meta http-equiv="X-UA-Compatible" content="IE=edge" /></head>')
        resp.write('<body>')
        resp.write('<div style="display: none;"  id="connectParam" >')
        resp.write(JSON.stringify(objConnectParam))
        resp.write('</div><div id="mainElm" onClick="javascript: 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')
        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)
}

function getValueFromHeaders (req, headerName) {
  let result = null
  req.headers.split('\r\n').forEach(function (value) {
    let pair = value.split(': ')
    if (pair[0].toLowerCase() === headerName.toLowerCase()) {
      result = pair[1]
    }
  })
  return result
}