Integration Documentation

API Gateway (SSO)

packages/api-gateway/src/index.ts

import dotenv from 'dotenv'
import http from 'http'
import express from 'express'
import proxy from 'express-http-proxy'

dotenv.config()

// @todo: add username creation

interface User {
  id: number
  [field: string]: any
}

const getCurrentUserId = async (): Promise<User> => {
  // for this simple example, hardcode the records usually loaded from a local database...
  const users = [
    {
      id: 1,
      email: 'nodietidaccount@gmail.com',
      firstName: 'Jane',
      lastName: 'Doe',
      dietIdUserId: null
    },
    {
      id: 42,
      email: 'hasdietidaccount@gmail.com',
      firstName: 'John',
      lastName: 'Doe',
      dietIdUserId: 72
    }
  ]

  // ...and the id of the signed in user
  return users.find(user => user.id === 42)
}

interface DietIdConfig {
  server: string
  clientId: string
  clientSecret: string
}

const dietId: DietIdConfig = {
  server: process.env.DIET_ID_SERVER,
  clientId: process.env.DIET_ID_CLIENT_ID,
  clientSecret: process.env.DIET_ID_CLIENT_SECRET
}

const app = express()
const port = process.env.PORT || 3500

app.use(
  proxy(dietId.server, {
    proxyReqOptDecorator: async proxyReqOpts => {
      const sensitiveHeaders = ['Authorization', 'X-DietID-ClientId', 'X-DietID-PartnerUserId'].map(name =>
        name.toLowerCase()
      )

      // clear out any sensitive headers, preventing them from originating from the frontend.
      // "X-DietID-PartnerUserId" in particular is very sensitive because it scopes the request
      // at the user level, preventing access to other users' records.
      Object.keys(proxyReqOpts.headers).forEach(header => {
        if (sensitiveHeaders.includes(header.toLowerCase())) {
          proxyReqOpts.headers[header] = ''
        }
      })

      // Grab the record for the user that is currently logged in
      const user = await getCurrentUserId()

      // Only forward requests to Diet ID if there's a currently logged in user
      if (!user) {
        return Promise.reject('Please log in and try again.')
      }

      // "Authorization" and "X-DietID-ClientId" tell Diet ID who we are (partner-level access control).
      // "X-DietID-PartnerUserId" tells Diet ID who the user making the request is (user-level access control).
      proxyReqOpts.headers['Authorization'] = `Bearer ${dietId.clientSecret}`
      proxyReqOpts.headers['X-DietID-ClientId'] = dietId.clientId
      proxyReqOpts.headers['X-DietID-PartnerUserId'] = user.id

      return proxyReqOpts
    }
  })
)

http.createServer(app).listen(port, () => console.log(`Proxying requests from port ${port} to ${dietId.server}`))

packages/api-gateway/src/sso.ts

// @ts-nocheck
import CryptoJS from 'crypto-js'

export const encryptData = (data: object, secret: string): string => {
  const text = JSON.stringify(data)
  const key = CryptoJS.enc.Hex.parse(secret)
  const iv = CryptoJS.lib.WordArray.random(128 / 8)

  const result = CryptoJS.AES.encrypt(text, key, { iv })

  return [CryptoJS.enc.Base64.stringify(result.iv), CryptoJS.enc.Base64.stringify(result.ciphertext)].join('$')
}

export const decryptData = (string: string, secret: string): object => {
  const text = string.split('$')[1]
  const key = CryptoJS.enc.Hex.parse(secret)
  const iv = CryptoJS.enc.Base64.parse(string.split('$')[0])

  const result = CryptoJS.AES.decrypt(text, key, { iv })

  return JSON.parse(result.toString(CryptoJS.enc.Utf8))
}

/**
 * Returns a url the frontend can use to embed a Diet ID assessment in an iframe, webview, etc.
 * The user is automatically signed in and prompted to complete the assessment.
 *
 * @param user The required fields for finding or creating a Diet ID user (id, email, first name, last name)
 * @returns A url that signs in a user and prompts them to complete an assessment.
 *
 */
export const getSSO = (user: DietID.CreateUserParams, clientId: string, clientSecret: string): string =>
  `https://${clientId}.dietid.com/#/u/${encodeURIComponent(encryptData(user, clientSecret))}`

packages/api-gateway/.env.example

DIET_ID_SERVER=https://api.staging.dietid.com
DIET_ID_CLIENT_ID=ADD_CLIENT_ID_HERE
DIET_ID_CLIENT_SECRET=ADD_CLIENT_SECRET_HERE

packages/api-gateway/package.json

{
  "name": "@dietid/api-gateway",
  "version": "0.1.0",
  "description": "Backend that authenticates and proxies requests originating from the frontend to the Diet ID API",
  "homepage": "https://github.com/dqpn/diet-id-api-client",
  "module": "./src/index.js",
  "main": "./dist/index.js",
  "scripts": {
    "build": "tsc",
    "watch": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"yellow.bold,green.bold\" \"npm run watch:ts\" \"npm run watch:node\"",
    "watch:ts": "tsc -w",
    "watch:node": "nodemon dist/index.js"
  },
  "dependencies": {
    "crypto-js": "^4.0.0",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "express-http-proxy": "^1.6.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.3",
    "@types/express-http-proxy": "^1.5.12",
    "@types/node": "^13.7.6",
    "concurrently": "^5.1.0",
    "nodemon": "^2.0.2",
    "typescript": "~3.7.5"
  }
}

packages/api-gateway/tsconfig.json

{
  "extends": "../../tsconfig.base",
  "compilerOptions": {
    "lib": ["esnext"],
    "module": "commonjs",
    "outDir": "dist",
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}