├── .editorconfig ├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .dockerignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── .prettierrc ├── Dockerfile ├── api │ ├── thirdpartyauth │ │ ├── controller.js │ │ └── router.js │ ├── zoom │ │ ├── controller.js │ │ ├── middleware.js │ │ └── router.js │ └── zoomapp │ │ ├── controller.js │ │ └── router.js ├── config.js ├── middleware.js ├── package-lock.json ├── package.json ├── public │ └── zoomverify │ │ └── verifyzoom.html.example ├── server.js ├── util │ ├── encrypt.js │ ├── store.js │ ├── zoom-api.js │ └── zoom-helpers.js ├── views │ └── error.pug └── wait-for-it.sh ├── docker-compose.yml ├── frontend ├── .dockerignore ├── .env.example ├── .gitignore ├── .npmrc ├── Dockerfile ├── config-overrides.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.css │ ├── App.js │ ├── apis.js │ ├── components │ ├── ApiScrollview.css │ ├── ApiScrollview.js │ ├── Auth0User.js │ ├── Authorization.js │ ├── Header.css │ ├── Header.js │ ├── IFrame.js │ ├── Image.css │ ├── Image.js │ ├── Test.js │ ├── UserInfo.css │ └── UserInfo.js │ ├── index.css │ ├── index.js │ ├── reportWebVitals.js │ ├── setupTests.js │ └── tests │ └── Test.test.js ├── rtms ├── README.md ├── sdk │ ├── .env.example │ ├── Dockerfile │ ├── index.js │ └── package.json ├── utils │ ├── audio.js │ ├── transcript.js │ └── video.js └── websocket │ ├── .env.example │ ├── Dockerfile │ ├── api │ ├── controller.js │ ├── helper.js │ └── router.js │ ├── index.js │ ├── package-lock.json │ └── package.json └── screenshots └── ngrok-https-origin.png /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # https (ngrok) public url 2 | PUBLIC_URL= 3 | 4 | ZOOM_HOST="https://zoom.us" 5 | 6 | # Secret for signing session cookies 7 | # The reference app uses this to sign the express-session instance 8 | # Refer to backend/middleware.js line 26 9 | # Lines starting with a '$' are terminal commands; you'll need the openssl program. 10 | # Run the command in your terminal and capture the output. 11 | # $ openssl rand -hex 16 12 | SESSION_SECRET="" 13 | 14 | # URL where your frontend is served from (can be localhost) 15 | # Will be set automatically by docker-compose, uncomment if not using docker-compose 16 | # ZOOM_APP_CLIENT_URL="" 17 | 18 | # OAuth client credentials (from Marketplace) 19 | # App Credentials > Development/Production > [Client ID, Client Secret] 20 | ZOOM_APP_CLIENT_ID="" 21 | ZOOM_APP_CLIENT_SECRET="" 22 | 23 | # OAuth redirect (must be configured in Marketplace) 24 | # App Credentials > Redirect URL for OAuth 25 | # Will be set automatically by docker-compose, uncomment if not using docker-compose 26 | # ZOOM_APP_REDIRECT_URI="" 27 | 28 | # For OAuth state validation 29 | # Why? Refer to: 30 | # https://marketplace.zoom.us/docs/guides/auth/oauth 31 | # https://auth0.com/docs/secure/attack-protection/state-parameters 32 | # https://www.rfc-editor.org/rfc/rfc6749#section-10.12 33 | 34 | # Lines starting with a '$' are terminal commands; you'll need the openssl program. 35 | # Run the command in your terminal and capture the output. 36 | # $ openssl rand -hex 16 37 | ZOOM_APP_OAUTH_STATE_SECRET="" 38 | 39 | # REDIS is used as the DB driver for session management (express-session). Other drivers could be used but 40 | # installation of additional packages may be necessary. 41 | # Refer to the bottom of the README as well 42 | # Lines starting with a '$' are terminal commands; you'll need the openssl program. 43 | # Run the command in your terminal and capture the output. 44 | # $ openssl rand -hex 16 45 | REDIS_ENCRYPTION_KEY="" 46 | REDIS_URL=redis://redis:6379/1 47 | 48 | # For 3rd party OAuth flow (Auth0 - optional) 49 | # Refer to: https://auth0.com/docs/get-started 50 | 51 | # Your Auth0 web app client ID 52 | AUTH0_CLIENT_ID="" 53 | 54 | # Your Auth0 web app client secret 55 | AUTH0_CLIENT_SECRET="" 56 | 57 | # Your Auth0 web app domain (ie. https://us.auth0.com) 58 | AUTH0_ISSUER_BASE_URL="" 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | 4 | .DS_Store 5 | 6 | 7 | data 8 | 9 | logs 10 | 11 | 12 | rtms/sdk/id_* 13 | 14 | rtms/sdk/Docker.local -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Zoom 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Advanced Zoom Apps Sample 2 | 3 | Advanced Sample covers most complex scenarios that might be needed in apps. 4 | 5 | App has reference implementation for: 6 | 7 | - Authentication flows: marketplace authentication, in-client authentication, third-party auth providers 8 | - REST API calls and retrieving user information 9 | - Zoom Apps SDK methods and events, including role-based permissions 10 | - Guest mode 11 | 12 | Tech stack: react js, node.js, express, docker (optional) 13 | 14 | ## Usage 15 | 16 | Requirements: 17 | 18 | - Docker 19 | - Ngrok or another reverse proxy 20 | 21 | ### Setup .env files 22 | 23 | Please see the `.env.example` file in the repository. 24 | 25 | - Create a `.env` file by copying the example and filling in the values 26 | - If you are in development, use the Client ID and Client secret under `Development` 27 | - Lines starting with a '$' are terminal commands; you'll need the openssl program. Run the command in your terminal and capture the output, or you can use what those values are currently set at for now. 28 | - Note that the three 'AUTH0' - prefixed fields are optional - see instructions for the Third Party OAuth below. Leaving out any of these three values will disable this demonstration feature. 29 | 30 | ### Start your Ngrok (reverse proxy) 31 | 32 | Zoom Apps do not support localhost, and must be served over https. To develop locally, you need to tunnel traffic to this application via https, because the application runs in Docker containers serving traffic from `http://localhost`. You can use Ngrok to do this. Once installed you may run this command from your terminal: 33 | 34 | ```bash 35 | ngrok http 3000 36 | ``` 37 | 38 | Ngrok will output the origin it has created for your tunnel, eg `https://9a20-38-99-100-7.ngrok.io`. You'll need to use this across your Zoom App configuration in the Zoom Marketplace (web) build flow (see below). 39 | 40 | Please copy the https origin from the Ngrok terminal output and paste it in the `PUBLIC_URL` value in the `.env` file. 41 | ![ngrok https origin](screenshots/ngrok-https-origin.png) 42 | 43 | Please note that this ngrok URL will change once you restart ngrok (unless you purchased your own ngrok pro account). If you shut down your Ngrok (there's no harm to leaving it on), upon restart you'll need to copy and paste the new origin into the `.env` file AND also to your Marketplace build flow. 44 | 45 | ### Setup in Zoom Marketplace app build flow 46 | 47 | The Zoom Marketplace build flow for a Zoom App may be found [here](https://marketplace.zoom.us/develop/create). You will need a developer account with Zoom Apps enabled. 48 | 49 | The following are steps to take in each of the tabs in the build flow . . . 50 | 51 | #### App Credentials 52 | 53 | If you enabled the "List app on Marketplace to be added by any Zoom user" toggle while creating your app on the Marketplace, you will see the following two sections: Development and Production. 54 | Note: The above option should only be selected if you intend to publish the app to the marketplace. This option can be enabled later as well under the "Activation" tab. The "Activation" tab only appears if you have not selected to list the app to be published 55 | 56 | `your Ngrok origin` = ie. `https://9a20-38-99-100-7.ngrok.io` 57 | Follow these instructions for the "Development" section 58 | 59 | - Add `/api/zoomapp/home` in the Home URL field 60 | - Copy and paste your Client ID and Client secret (from the "Development" section, not "Production" section) into the `.env` file for this application 61 | - Add `/api/zoomapp/auth` in the Redirect URL for OAuth field 62 | - Add `/api/zoomapp/auth` in the OAuth allow list 63 | - Add `/api/zoomapp/proxy#/userinfo` in the OAuth allow list 64 | - Not needed if you are not using in-client oauth. This is the exact window location where authorize method is invoked 65 | - Add your Ngrok domain only (no protocol, eg `9a20-38-99-100-7.ngrok.io`) in the Domain allow list 66 | - Add the SDK url `appssdk.zoom.us` in the Domain allow list 67 | - Add `images.unsplash.com` to the Domain allow list 68 | - Add any other required domains (eg `my-cdn.example.com`) in the Domain allow list\* 69 | 70 | \*Important: All requests to domains **NOT** in the Domain allow list in the app's Marketplace build flow will be blocked in the Zoom Apps embedded browser 71 | 72 | #### Information 73 | 74 | - Please fill out the developer contact name and developer contact email fields to test the application locally. In order to submit the application for review, you will need to fill out the rest of the fields. 75 | 76 | #### Features 77 | 78 | - Under `Zoom App SDK` click **Add APIs** 79 | - For the purposes of this app, please add the following APIs and events: 80 | - `allowParticipantToRecord` 81 | - `authorize` 82 | - `cloudRecording` 83 | - `connect` 84 | - `expandApp` 85 | - `getMeetingContext` 86 | - `getMeetingJoinUrl` 87 | - `getMeetingParticipants` 88 | - `getMeetingUUID` 89 | - `getRecordingContext` 90 | - `getRunningContext` 91 | - `getSupportedjsApis` 92 | - `getUserContext` 93 | - `listCameras` 94 | - `onActiveSpeakerChange` 95 | - `onAuthorized` 96 | - `onConnect` 97 | - `onMeeting` 98 | - `onMessage` 99 | - `onMyUserContextChange` 100 | - `onSendAppInvitation` 101 | - `onShareApp` 102 | - `openUrl` 103 | - `postMessage` 104 | - `promptAuthorize` 105 | - `removeVirtualBackground` 106 | - `sendAppInvitation` 107 | - `shareApp` 108 | - `showAppInvitationDialog` 109 | - `sendAppInvitationToMeetingOwner` 110 | - `sendAppInvitationToAllParticipants` 111 | - `setVideoMirrorEffect` 112 | - `setVirtualBackground` 113 | - `showNotification` 114 | - Users will be asked to consent to these scopes during the add flow before being allowed to use the Zoom App 115 | - Important: The added or checked items must at least include those in the "capabilities" list in the call to zoomSdk.config in the embedded browser, eg frontend/src/App.js 116 | - Select any additional features you would like to enable, eg Guest mode, In-client OAuth, Collaborate mode, etc. For this app, have Guest mode, In-client OAuth, and Collaborate Mode turned on. 117 | - Important: For legacy reasons, Guest mode is NOT enabled by default. Please make sure your app supports this - particularly relevant for applications live prior to June 2022. Newer applications will want to take advantage of these features from the start 118 | 119 | #### Scopes 120 | 121 | - Add the following Scopes required for this Advanced Sample Zoom App: `zoomapp:inmeeting`, `user:read` 122 | - The Scopes referred to here are for the Zoom API - they are not exclusive to Zoom Apps. Please find documentation for the Zoom API [here](https://marketplace.zoom.us/docs/api-reference/introduction) 123 | - As with the Zoom App SDK APIs and events from the 'Features' tab, scopes selected here will be presented to users for consent before they may use the Zoom App. 124 | 125 | ## Start developing 126 | 127 | ### Start containers 128 | 129 | - Use the `docker-compose` tool from the root directory to start both the backend and frontend containers: 130 | 131 | ``` 132 | docker-compose up 133 | ``` 134 | 135 | - Now, you should be getting logs from both the express server/backend and the webpack-dev-server that serves the frontend. 136 | 137 | ### Install the app 138 | 139 | Before proceeding, make sure to: 140 | 141 | - Log in to zoom.us on the web (if not already signed in there) 142 | - Make sure the user matches the user you've used to log in to the Zoom client 143 | - While developing, make sure the user is in the developer account 144 | 145 | To install your app and open it the Zoom client's embedded browser, visit: 146 | 147 | ``` 148 | /api/zoomapp/install 149 | ``` 150 | 151 | Any errors you encounter during the add flow are likely related to user mismatches or different/non-developer accounts. You may also want to double check that your Client ID and Client secret (in the `.env` file) are up to date. 152 | 153 | ### Validate It's Working 154 | 155 | After hitting the install URL, an authorization page will show up in the browser. After reviewing and accepting the permissions, the browser will prompt a redirect (AKA deeplink) to the Zoom Client. The client should open up and you should see the Reference App running in your client 156 | 157 | ### Develop 158 | 159 | #### UI 160 | 161 | The React-based UI will hot reload automatically with changes, thanks to the Webpack dev server. Visit each of the Zoom APIs demoed and test their functionality. 162 | 163 | #### Server 164 | 165 | The backend will update live with any changes as well, thanks to the nodemon npm package. 166 | 167 | The server will persist sessions for each device (eg system browser or embedded browser/Zoom client) that visits. A common mistake is to restart the Docker containers expecting user data to persist. This will cause problems. You should plan to revisit the install flow every time you restart your Docker containers. 168 | 169 | #### Logs and Enabling Developer Tools 170 | 171 | Look for helpful logs from the frontend by opening the browser developer tools console in the Zoom Apps embedded browser (you must enable it first - see following instructions), and from the backend in your terminal window. 172 | 173 | To enable the developer tools in the Zoom client: 174 | 175 | - For MacOS, run this command in your terminal: 176 | 177 | ``` 178 | defaults write ZoomChat webview.context.menu true 179 | ``` 180 | 181 | - For Windows machines: 182 | - Find your local Zoom data folder, eg `C:\Users\\AppData\Roaming\Zoom\data` 183 | - Find the "zoom.us.ini" file or create one if it does not exist 184 | - Paste in this line and save: 185 | 186 | ``` 187 | [ZoomChat] 188 | webview.context.menu=true 189 | ``` 190 | 191 | After following the above instructions, **Right Click** -> **Inspect Element** in the embedded browser (Zoom Apps panel in the Zoom client) to see the developer tools. 192 | 193 | ### App Components 194 | 195 | #### Frontend 196 | 197 | This Zoom App uses [create-react-app](https://create-react-app.dev/) for the frontend component. The express server/backend provides a proxy for this, so that it also can sit behind the Ngrok origin that you create. 198 | 199 | The frontend app is quite simple: 200 | 201 | - It calls a Zoom REST API endpoint for user data via a backend-hosted proxy that adds user access token to the request 202 | - It imports the Zoom SDK in `index.html` via a script `` 203 | - Offers an example Zoom App SDK configuration to get the running context of the Zoom App. 204 | - Includes examples of Zoom App SDK method invocations 205 | 206 | #### Backend 207 | 208 | The backend is a NodeJS/Express server. The routes include: 209 | 210 | - A home route to initialize a cookie-based session for embedded browser users 211 | - A proxy for the Zoom App frontend - serves frontend files from the frontend container 212 | - A proxy for the Zoom REST API - adds user access token and calls Zoom API from the server 213 | - Routes for the traditional/web-based add flow: install and authenticate 214 | - Routes for In-client OAuth add flow: authorize and on authorize 215 | - Routes for the 3rd Party OAuth flow (optional; example using an example Auth0 application) 216 | 217 | #### Database 218 | 219 | Redis: 220 | 221 | - Stores session data (cookie-based sessions using the express-session npm library) 222 | - Stores application data (users, access tokens for Zoom API and 3rd Party OAuth provider) 223 | 224 | ## Third party authentication example - optional and using Auth0 225 | 226 | The third party authentication example is optional - if you skip these steps, please leave the 'Auth0' - prefixed fields in the `.env` file empty or remove them entirely. Leaving out any of those three values from the `.env` file will disable this demonstration feature. 227 | 228 | - Auth0 steps for set up: 229 | 230 | 1. Sign up for an account [here](https://auth0.com/signup?place=header&type=button&text=sign%20up) 231 | 2. Create an application 232 | 3. Select `Regular Web Applications` 233 | 4. Select `Auth0 Management API` (Doesn't really matter here) 234 | 5. Permissions: Select `All` 235 | 6. In the section `Application Properties`, make sure (or change) `Token Endpoint Authentication Method` to `Post` 236 | 7. In the section `Application URIs`, fill in `Allowed Callback URLs` with `https:///api/auth0/auth` and `Allowed Web Origins` with `https://` 237 | 8. Under `Advanced Settings` -> `Grant Types`, check `Authorization Code` 238 | 9. Save Changes 239 | 10. Under `User Management` in the left-hand panel, select `Users` and create a user with username and password. 240 | 11. During the auth flow, use the credentials created in `Step 9` to login. 241 | 242 | ## What do I do next? 243 | 244 | Start building your app! You can check out the [Zoom Apps developer docs](https://developers.zoom.us/docs/zoom-apps/) for more information on the JS SDK. You can also explore the [Zoom REST API](https://developers.zoom.us/docs/api/) or use the third party OAuth to call a different API. 245 | 246 | ## Starting Docker Containers with Docker Compose: 247 | 248 | ###### Start and rebuild default containers (`frontend`, `backend`, and `redis`): 249 | 250 | ```bash 251 | docker compose up --build 252 | ``` 253 | 254 | ###### Start and rebuild only RTMS with websocket mode: 255 | 256 | ```bash 257 | docker-compose up --build rtms-websocket 258 | ``` 259 | 260 | ###### Start and rebuild only RTMS with @zoom/rtms mode: 261 | 262 | ```bash 263 | docker-compose up --build rtms-sdk 264 | ``` 265 | 266 | ###### Start and rebuild all containers and RTMS with websocket mode: 267 | 268 | ```bash 269 | docker compose --profile websocket up --build 270 | ``` 271 | 272 | ###### Start and rebuild all containers and RTMS with the @zoom/rtms mode 273 | 274 | ```bash 275 | docker compose --profile sdk up --build 276 | ``` 277 | 278 | ℹ️ For more details on setting up rtms, including the sdk and websocket modes, please refer to the README inside the rtms folder. 279 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /backend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "prettier"], 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": ["error"] 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2017 9 | }, 10 | "env": { 11 | "node": true, 12 | "es6": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .env 26 | 27 | *.nedb 28 | 29 | /public/zoomverify/verifyzoom.html 30 | -------------------------------------------------------------------------------- /backend/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | # Create app directory 4 | WORKDIR /home/node/app 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | RUN npm install 12 | # If you are building your code for production 13 | # RUN npm ci --only=production 14 | 15 | # Bundle app source 16 | COPY . . 17 | 18 | COPY wait-for-it.sh . 19 | RUN chmod +x wait-for-it.sh 20 | 21 | CMD [ "npm", "start:dev" ] 22 | -------------------------------------------------------------------------------- /backend/api/thirdpartyauth/controller.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const { createProxyMiddleware } = require('http-proxy-middleware') 3 | const zoomApi = require('../../util/zoom-api') 4 | const zoomHelpers = require('../../util/zoom-helpers') 5 | const store = require('../../util/store') 6 | 7 | module.exports = { 8 | // BEGIN 3RD PARTY OAUTH LOGIN HANDLER ============================================== 9 | // Invoked when user begins login to third party flow 10 | // Creates and saves state 11 | // Redirects to Zoom for authentication 12 | // (We need to authenticate to Zoom first to request a deeplink from Zoom API that links back to Zoom client) 13 | async begin(req, res) { 14 | console.log( 15 | 'BEGIN 3RD PARTY OAUTH LOGIN HANDLER ==============================================', 16 | '\n' 17 | ) 18 | // 1. Generate and save a random state value for this browser session to verify when code comes back from Zoom 19 | const zoomRequestState = zoomHelpers.generateState() 20 | req.session.zoomRequestState = zoomRequestState 21 | console.log( 22 | '1. Begin 3rd party oauth log in - generate state for zoom auth and save:', 23 | req.session.zoomRequestState, 24 | '\n' 25 | ) 26 | 27 | // 2. Create a redirect url, eg: https://zoom.us/oauth/authorize?client_id=XYZ&response_type=code&redirect_uri=https%3A%2F%2Fmydomain.com%2Fapi%2Fzoomapp%2Fauth&state=abc... 28 | // 2a. Set domain (with protocol prefix) 29 | const zoomDomain = process.env.ZOOM_HOST // https://zoom.us 30 | 31 | // 2b. Set path 32 | const zoomAuthPath = '/oauth/authorize' 33 | 34 | // 2c. Create the request params 35 | const params = { 36 | redirect_uri: `${process.env.PUBLIC_URL}/api/auth0/redirect`, // eg the 'Third Party Auth Redirect', below. This route is usually known to your 3rd party authenticator 37 | response_type: 'code', 38 | client_id: process.env.ZOOM_APP_CLIENT_ID, 39 | state: req.session.zoomRequestState, 40 | } 41 | 42 | const zoomAuthRequestParams = zoomHelpers.createRequestParamString(params) 43 | 44 | // 2d. Concatenate for the redirect url 45 | const redirectUrl = zoomDomain + zoomAuthPath + '?' + zoomAuthRequestParams 46 | console.log('2. Redirect url to authenticate to Zoom:', redirectUrl, '\n') 47 | 48 | // 3. Redirect to url - the user can authenticate and authorize the app scopes securely on zoom.us 49 | console.log('3. Redirecting to redirect url', '\n') 50 | res.redirect(redirectUrl) 51 | }, 52 | 53 | // ZOOM OAUTH REDIRECT HANDLER ============================================== 54 | // Handles the redirect from Zoom following user authenticaation to Zoom; request will include auth code and our (saved) state from Zoom 55 | // Gets and saves Zoom access token and user/id so we can save them, then in the next step (deeplinkToClient, below) invoke the Zoom API and get a deeplink 56 | // Redirects to third party for authentication 57 | async zoomAuth(req, res, next) { 58 | console.log( 59 | 'ZOOM OAUTH REDIRECT HANDLER ==============================================', 60 | '\n' 61 | ) 62 | // For security, forget this state value on 1st attempt 63 | const sessionZoomState = req.session.zoomRequestState 64 | req.session.zoomRequestState = null 65 | 66 | console.log( 67 | '1. Handling redirect from zoom.us with code and state following authentication to Zoom', 68 | '\n' 69 | ) 70 | // 1. Validate code and state 71 | // 1a. Check for auth code from zoom following authenication 72 | if (!req.query.code) { 73 | const error = new Error('No auth code was provided') 74 | error.status = 400 75 | return next(error) 76 | } 77 | 78 | console.log('1a. code param exists:', req.query.code) 79 | 80 | // 1b. Validate state passed following zoom.us login is correct 81 | if (req.query.state !== sessionZoomState) { 82 | const error = new Error('Invalid state parameter') 83 | error.status = 400 84 | 85 | return next(error) 86 | } 87 | 88 | console.log( 89 | '1b. state param is correct/matches ours:', 90 | req.query.state, 91 | '\n' 92 | ) 93 | 94 | try { 95 | console.log('2. Getting Zoom access token and user', '\n') 96 | // 2. Get and remember Zoom access token and Zoom user 97 | // 2a. Exchange Zoom authorization code for tokens 98 | const tokenResponse = await zoomApi.getZoomAccessToken( 99 | req.query.code, 100 | `${process.env.PUBLIC_URL}/api/auth0/redirect` 101 | ) 102 | const zoomAccessToken = tokenResponse.data.access_token 103 | console.log( 104 | '2a. Use code to get Zoom access token - response data: ', 105 | tokenResponse.data, 106 | '\n' 107 | ) 108 | 109 | // 2b. Get Zoom user info from Zoom API 110 | const userResponse = await zoomApi.getZoomUser(zoomAccessToken) 111 | const zoomUserId = userResponse.data.id 112 | 113 | console.log( 114 | '2b. Use access token to get Zoom user - response data: ', 115 | userResponse.data, 116 | '\n' 117 | ) 118 | 119 | // 2c. Persist user data on this session, to quickly look up user 120 | req.session.user = zoomUserId 121 | 122 | // 2d. In the redis store - we'll access the store again when the user visits from the Zoom App (keyed from user id in x-zoom-app-context header) 123 | await store.upsertUser( 124 | zoomUserId, 125 | tokenResponse.data.access_token, 126 | tokenResponse.data.refresh_token, 127 | Date.now() + tokenResponse.data.expires_in * 1000 128 | ) 129 | 130 | // 3. Formulate url to redirect now to 3rd party oauth provider 131 | console.log( 132 | '3. Formulate url to redirect now to 3rd party oauth provider', 133 | '\n' 134 | ) 135 | 136 | // 3a. Generate and save a state for the 3rd party to return following authentication 137 | const thirdPartyRequestState = zoomHelpers.generateState() 138 | req.session.thirdPartyRequestState = thirdPartyRequestState 139 | console.log('3a. state generated and saved:', thirdPartyRequestState) 140 | 141 | // 3b. Generate and save a code verifier to send the 3rd party to validate before they issue token 142 | const codeVerifier = zoomHelpers.generateCodeVerifier() 143 | req.session.codeVerifier = codeVerifier 144 | console.log('3b. Code verifier generated and saved:', codeVerifier) 145 | 146 | // 3c. Generate a code challenge to send with this 147 | const codeChallenge = zoomHelpers.generateCodeChallenge(codeVerifier) 148 | console.log('3c. Code challenge generated and saved:', codeChallenge) 149 | 150 | // 3d. Set domain 151 | const myAuth0TestDomain = process.env.AUTH0_ISSUER_BASE_URL 152 | console.log('3d. Domain:', myAuth0TestDomain) 153 | 154 | // 3e. Set path 155 | const auth0AuthPath = '/authorize' 156 | console.log('3e. Path:', auth0AuthPath) 157 | 158 | // 3f. Set params 159 | const params = { 160 | response_type: 'code', 161 | client_id: process.env.AUTH0_CLIENT_ID, 162 | scope: 'openid profile email', 163 | state: req.session.thirdPartyRequestState, 164 | code_challenge: codeChallenge, 165 | code_challenge_method: 'S256', 166 | redirect_uri: `${process.env.PUBLIC_URL}/api/auth0/auth`, 167 | } 168 | 169 | console.log('3f. Params for request:', params) 170 | const auth0AuthRequestParams = zoomHelpers.createRequestParamString( 171 | params 172 | ) 173 | 174 | // 3g. Concatenate above 175 | const redirectUrl = 176 | myAuth0TestDomain + auth0AuthPath + '?' + auth0AuthRequestParams 177 | console.log('3f. Redirect url/ completed: ', redirectUrl, '\n') 178 | 179 | // 4. Redirect to url - the user can authenticate and authorize the app scopes securely on Auth0 180 | console.log('4. Redirecting to redirect url', '\n') 181 | res.redirect(redirectUrl) 182 | } catch (error) { 183 | console.log('Error getting Zoom info or creds:', error) 184 | 185 | // For security reasons, destroy this session 186 | req.session.destroy() 187 | return next(error) 188 | } 189 | }, 190 | 191 | // AUTH0 REDIRECT HANDLER ============================================== 192 | // This route handles the redirect after the user has authorized the third party app 193 | // Requests will include the (saved) state we sent, and the authorization code 194 | // Validates the state parameter and saves the access token 195 | // Uses already-saved zoom access token to call zoom API for deeplink back to client 196 | async auth0Auth(req, res, next) { 197 | console.log( 198 | 'AUTH0 REDIRECT HANDLER ==============================================', 199 | '\n' 200 | ) 201 | console.log( 202 | '1. Handling redirect from Auth0 with code and state following authentication to Auth0 app', 203 | '\n' 204 | ) 205 | const codeVerifier = req.session.codeVerifier 206 | const thirdPartyRequestState = req.session.thirdPartyRequestState 207 | const sessionUser = req.session.user 208 | 209 | // For security reasons, destory this session 210 | req.session.destroy() 211 | 212 | // 1 Validate code and state 213 | // 1a. Check for auth code from Auth0 following authenication there 214 | if (!req.query.code) { 215 | const error = new Error('No auth code was provided') 216 | error.status = 400 217 | return next(error) 218 | } 219 | 220 | console.log('1a. code param exists:', req.query.code, '\n') 221 | 222 | // 1b. Validate the state parameter\ 223 | if (req.query.state !== thirdPartyRequestState) { 224 | const error = new Error('Invalid state parameter') 225 | error.status = 400 226 | 227 | return next(error) 228 | } 229 | 230 | console.log( 231 | '1b. state param is correct/matches ours:', 232 | req.query.state, 233 | '\n' 234 | ) 235 | 236 | // 2 Exchange Auth0 code for Auth0 API access token 237 | // 2a. Formulate http request options/axios 238 | const options = { 239 | method: 'POST', 240 | url: `${process.env.AUTH0_ISSUER_BASE_URL}/oauth/token`, 241 | headers: { 'content-type': 'application/json' }, 242 | data: { 243 | grant_type: 'authorization_code', 244 | client_id: process.env.AUTH0_CLIENT_ID, 245 | client_secret: process.env.AUTH0_CLIENT_SECRET, 246 | code_verifier: codeVerifier, 247 | code: req.query.code, 248 | redirect_uri: `${process.env.PUBLIC_URL}/api/auth0/auth`, 249 | audience: `${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/`, 250 | scope: 'openid profile email', 251 | }, 252 | } 253 | 254 | console.log('2. Make request to swap code for access token', '\n') 255 | console.log('2a. Options for request: ', options, '\n') 256 | 257 | try { 258 | // 2b. Make the request 259 | const auth0AccessToken = await axios 260 | .request(options) 261 | .then((res) => { 262 | console.log('auth0 access token success: ', res.data) 263 | return res.data 264 | }) 265 | .catch((err) => { 266 | console.error(err) 267 | throw new Error('auth0 access token request failed') 268 | }) 269 | 270 | console.log('2b. Auth0 response data/ token: ', auth0AccessToken, '\n') 271 | // 3. Save the access token 272 | // this will be used again when user visits the /proxy route 273 | // thirdPartyAccessToken is retrieved from store in requiresThirdPartyAuth middleware 274 | await store.updateUser(sessionUser, { 275 | thirdPartyAccessToken: auth0AccessToken.access_token, 276 | }) 277 | 278 | console.log('3. Save the access token in store: ', store, '\n') 279 | 280 | // 4. Finally, redirect the user back to the Zoom client 281 | console.log('4. Redirecting back to Zoom client', '\n') 282 | // 4a. Get accessToken saved in Zoom Oauth redirect handler 2e above 283 | const user = await store.getUser(sessionUser) 284 | const zoomAccessToken = user.accessToken 285 | 286 | console.log( 287 | '4a. Retrieve user from store to get Zoom access token:', 288 | user, 289 | '\n' 290 | ) 291 | 292 | // 4b. Generate a deep link to open Zoom App in client 293 | const deepLinkResponse = await zoomApi.getDeeplink(zoomAccessToken) 294 | const deeplink = deepLinkResponse.data.deeplink 295 | 296 | console.log( 297 | '4b. Generated deeplink from Zoom API using access token: ', 298 | deeplink, 299 | '\n' 300 | ) 301 | console.log('4c. Redirecting to Zoom client via deeplink . . .', '\n') 302 | 303 | // 4c. Redirect to deep link to open Zoom client with the (reloaded) app - now the 3rd party user data displays 304 | res.redirect(deeplink) 305 | } catch (error) { 306 | next(error) 307 | } 308 | }, 309 | 310 | // AUTH0 API PROXY ============================================== 311 | // Takes client requests and forwards to Auth0 Management API after adding user creds 312 | async proxy(req, res, next) { 313 | console.log( 314 | 'AUTH0 API PROXY ==============================================', 315 | '\n' 316 | ) 317 | console.log( 318 | 'Adding user third party access token to request:', 319 | req.thirdPartyAccessToken, 320 | '\n' 321 | ) 322 | const proxyOptions = { 323 | target: process.env.AUTH0_ISSUER_BASE_URL, 324 | changeOrigin: true, 325 | pathRewrite: { 326 | '^/api/auth0/proxy': '', 327 | }, 328 | headers: { 329 | 'content-type': 'application/json', 330 | authorization: `Bearer ${req.thirdPartyAccessToken}`, 331 | }, 332 | } 333 | 334 | createProxyMiddleware(proxyOptions)(req, res, next) 335 | }, 336 | 337 | // LOGOUT HANDLER ============================================== 338 | // Simply deletes the user's Auth0 creds from our store (will requre the user login again next visit) 339 | async logout(req, res, next) { 340 | console.log( 341 | 'LOGOUT HANDLER ==============================================', 342 | '\n' 343 | ) 344 | try { 345 | console.log('Removing user data from store', '\n') 346 | await store.logoutUser(req.session.user) 347 | res.json({ status: 'ok' }) 348 | } catch (error) { 349 | console.error(error) 350 | next(error) 351 | } 352 | }, 353 | } 354 | -------------------------------------------------------------------------------- /backend/api/thirdpartyauth/router.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express') 2 | const router = Router() 3 | const controller = require('./controller') 4 | const middleware = require('../../middleware') 5 | router 6 | .get('/login', controller.begin) // Called with openUrl in zoom app 7 | .get('/redirect', controller.zoomAuth) // Handles redirect from Zoom following authentication there with auth code etc from zoom.us & redirects to Auth0 8 | .get('/auth', controller.auth0Auth) // Handles third party app auth code from the Auth0 9 | .get('/proxy/*', middleware.requiresThirdPartyAuth, controller.proxy) // calls Auth0 app's Management API, adding user creds 10 | .get('/logout', controller.logout) // Logout of Auth0 (deletes user access tokens in store) 11 | 12 | module.exports = router 13 | -------------------------------------------------------------------------------- /backend/api/zoom/controller.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware') 2 | 3 | module.exports = { 4 | // Proxy requests to the Zoom REST API 5 | proxy: createProxyMiddleware({ 6 | target: process.env.ZOOM_HOST, 7 | changeOrigin: true, 8 | pathRewrite: { 9 | '^/zoom/api': '', 10 | }, 11 | 12 | onProxyRes: function (proxyRes, req, res) { 13 | console.log( 14 | 'ZOOM API PROXY ==============================================', 15 | '\n' 16 | ) 17 | 18 | var body = [] 19 | proxyRes 20 | .on('error', (err) => { 21 | console.error(err) 22 | }) 23 | .on('data', (chunk) => { 24 | body.push(chunk) 25 | }) 26 | .on('end', () => { 27 | body = Buffer.concat(body).toString() 28 | // At this point, we have the headers, method, url and body, and can now 29 | // do whatever we need to in order to respond to this request. 30 | console.log( 31 | `Zoom API Proxy => ${req.method} ${req.path} -> [${proxyRes.statusCode}] ${body}` 32 | ) 33 | 34 | res.end() 35 | }) 36 | }, 37 | }), 38 | } 39 | -------------------------------------------------------------------------------- /backend/api/zoom/middleware.js: -------------------------------------------------------------------------------- 1 | const zoomApi = require('../../util/zoom-api') 2 | const store = require('../../util/store') 3 | 4 | // API PROXY MIDDLEWARE ========================================================== 5 | // Middleware to automatically refresh an auth token in case of expiration 6 | const getUser = async (req, res, next) => { 7 | const zoomUserId = req?.session?.user 8 | if (!zoomUserId) { 9 | return next( 10 | new Error( 11 | 'No session or no user. You may need to close and reload or reinstall the application' 12 | ) 13 | ) 14 | } 15 | 16 | try { 17 | const appUser = await store.getUser(zoomUserId) 18 | req.appUser = appUser 19 | return next() 20 | } catch (error) { 21 | return next(new Error('Error getting user from session: ', error)) 22 | } 23 | } 24 | 25 | const refreshToken = async (req, res, next) => { 26 | console.log('1. Check validity of access token') 27 | 28 | const user = req.appUser 29 | const { expired_at = 0, refreshToken = null } = user 30 | 31 | if (!refreshToken) { 32 | return next(new Error('No refresh token saved for this user')) 33 | } 34 | 35 | if (expired_at && Date.now() >= expired_at - 5000) { 36 | try { 37 | console.log('2. User access token expired') 38 | console.log('2a. Refresh Zoom REST API access token') 39 | 40 | const tokenResponse = await zoomApi.refreshZoomAccessToken( 41 | user.refreshToken 42 | ) 43 | 44 | console.log('2b. Save refreshed user token') 45 | await store.updateUser(req.session.user, { 46 | accessToken: tokenResponse.data.access_token, 47 | refreshToken: tokenResponse.data.refresh_token, 48 | expired_at: Date.now() + tokenResponse.data.expires_in * 1000, 49 | }) 50 | 51 | } catch (error) { 52 | return next(new Error('Error refreshing user token.')) 53 | } 54 | } 55 | 56 | return next() 57 | } 58 | 59 | // AUTH HEADER MIDDLEWARE =================================================== 60 | const setZoomAuthHeader = async (req, res, next) => { 61 | try { 62 | if (!req.session.user) { 63 | throw new Error( 64 | 'No user in session - this happens when you restart docker and reload embedded browser' 65 | ) 66 | } 67 | const user = await store.getUser(req.session.user) 68 | if (!user) { 69 | throw new Error('User from this session not found') 70 | } else if (!user.accessToken) { 71 | throw new Error( 72 | 'No Zoom REST API access token for this user yet. This happens when user visits your home url from in-client oauth flow. Must use in-client oauth' 73 | ) 74 | } 75 | req.headers['Authorization'] = `Bearer ${user.accessToken}` 76 | return next() 77 | } catch (error) { 78 | return next(error) 79 | } 80 | } 81 | 82 | module.exports = { getUser, refreshToken, setZoomAuthHeader } 83 | -------------------------------------------------------------------------------- /backend/api/zoom/router.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express') 2 | const router = Router() 3 | const controller = require('./controller') 4 | const { getUser, refreshToken, setZoomAuthHeader } = require('./middleware') 5 | router.use( 6 | '/api', 7 | getUser, 8 | refreshToken, 9 | setZoomAuthHeader, 10 | controller.proxy 11 | ) 12 | module.exports = router 13 | -------------------------------------------------------------------------------- /backend/api/zoomapp/controller.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware') 2 | const zoomApi = require('../../util/zoom-api') 3 | const zoomHelpers = require('../../util/zoom-helpers') 4 | const store = require('../../util/store') 5 | 6 | module.exports = { 7 | // In-client OAuth 1/2 8 | async inClientAuthorize(req, res, next) { 9 | console.log( 10 | 'IN-CLIENT AUTHORIZE HANDLER ==========================================================', 11 | '\n' 12 | ) 13 | 14 | try { 15 | console.log('1. Generate code verifier, code challenge and state') 16 | const codeVerifier = zoomHelpers.generateCodeVerifier() 17 | const codeChallenge = codeVerifier 18 | const zoomInClientState = zoomHelpers.generateState() 19 | 20 | console.log('2. Save code verifier and state to session') 21 | req.session.codeVerifier = codeVerifier 22 | req.session.state = zoomInClientState 23 | 24 | console.log('3. Return code challenge and state to frontend') 25 | return res.json({ 26 | codeChallenge, 27 | state: zoomInClientState, 28 | }) 29 | } catch (error) { 30 | return next(error) 31 | } 32 | }, 33 | 34 | // In-Client OAuth 2/2 35 | async inClientOnAuthorized(req, res, next) { 36 | console.log( 37 | 'IN-CLIENT ON AUTHORIZED TOKEN HANDLER ==========================================================', 38 | '\n' 39 | ) 40 | 41 | const zoomAuthorizationCode = req.body.code 42 | const href = req.body.href 43 | const state = decodeURIComponent(req.body.state) 44 | const zoomInClientState = req.session.state 45 | const codeVerifier = req.session.codeVerifier 46 | 47 | console.log( 48 | '1. Verify code (from onAuthorized event in client) exists and state matches' 49 | ) 50 | 51 | try { 52 | if (!zoomAuthorizationCode || state !== zoomInClientState) { 53 | throw new Error('State mismatch') 54 | } 55 | 56 | console.log('2. Getting Zoom access token and user', '\n') 57 | const tokenResponse = await zoomApi.getZoomAccessToken( 58 | zoomAuthorizationCode, 59 | href, 60 | codeVerifier 61 | ) 62 | 63 | const zoomAccessToken = tokenResponse.data.access_token 64 | console.log( 65 | '2a. Use code to get Zoom access token - response data: ', 66 | tokenResponse.data, 67 | '\n' 68 | ) 69 | 70 | console.log('2b. Get Zoom user from Zoom API with access token') 71 | const userResponse = await zoomApi.getZoomUser(zoomAccessToken) 72 | const zoomUserId = userResponse.data.id 73 | req.session.user = zoomUserId 74 | 75 | console.log( 76 | '2c. Use access token to get Zoom user - response data: ', 77 | userResponse.data, 78 | '\n' 79 | ) 80 | 81 | console.log( 82 | '2d. Save the tokens in the store so we can look them up when the Zoom App is opened' 83 | ) 84 | 85 | // 2c. Save the tokens in the store so we can look them up when the Zoom App is opened: 86 | // When the home url for the app is requested on app open in the Zoom client, 87 | // the user id (uid field) is in the decrypted x-zoom-app-context header of the GET request 88 | await store.upsertUser( 89 | zoomUserId, 90 | tokenResponse.data.access_token, 91 | tokenResponse.data.refresh_token, 92 | Date.now() + tokenResponse.data.expires_in * 1000 93 | ) 94 | 95 | return res.json({ result: 'Success' }) 96 | } catch (error) { 97 | return next(error) 98 | } 99 | }, 100 | 101 | // INSTALL HANDLER ========================================================== 102 | // Main entry point for the web-based app install and Zoom user authorize flow 103 | // Kicks off the OAuth 2.0 based exchange with zoom.us 104 | install(req, res) { 105 | console.log( 106 | 'INSTALL HANDLER ==========================================================', 107 | '\n' 108 | ) 109 | // 1. Generate and save a random state value for this browser session 110 | req.session.state = zoomHelpers.generateState() 111 | console.log( 112 | '1. Begin add app - generated state for zoom auth and saved:', 113 | req.session.state, 114 | '\n' 115 | ) 116 | 117 | // 2. Create a redirect url, eg: https://zoom.us/oauth/authorize?client_id=XYZ&response_type=code&redirect_uri=https%3A%2F%2Fmydomain.com%2Fapi%2Fzoomapp%2Fauth&state=abc... 118 | // 2a. Set domain (with protocol prefix) 119 | const domain = process.env.ZOOM_HOST // https://zoom.us 120 | 121 | // 2b. Set path 122 | const path = 'oauth/authorize' 123 | 124 | // 2c. Create the request params 125 | const params = { 126 | redirect_uri: process.env.ZOOM_APP_REDIRECT_URI, 127 | response_type: 'code', 128 | client_id: process.env.ZOOM_APP_CLIENT_ID, 129 | state: req.session.state, // save state on this cookie-based session, to verify on return 130 | } 131 | 132 | const authRequestParams = zoomHelpers.createRequestParamString(params) 133 | 134 | // 2d. Concatenate 135 | const redirectUrl = domain + '/' + path + '?' + authRequestParams 136 | console.log('2. Redirect url to authenticate to Zoom:', redirectUrl, '\n') 137 | 138 | // 3. Redirect to url - the user can authenticate and authorize the app scopes securely on zoom.us 139 | console.log('3. Redirecting to redirect url', '\n') 140 | res.redirect(redirectUrl) 141 | }, 142 | 143 | // ZOOM OAUTH REDIRECT HANDLER ============================================== 144 | // This route is called after the user has authorized the Zoom App on the 145 | async auth(req, res, next) { 146 | console.log( 147 | 'ZOOM OAUTH REDIRECT HANDLER ==============================================', 148 | '\n' 149 | ) 150 | console.log( 151 | '1. Handling redirect from zoom.us with code and state following authentication to Zoom', 152 | '\n' 153 | ) 154 | // 1. Validate code and state 155 | const zoomAuthorizationCode = req.query.code 156 | const zoomAuthorizationState = req.query.state 157 | const zoomState = req.session.state 158 | 159 | // For security purposes, delete the browser session 160 | req.session.destroy() 161 | 162 | // 1a. Check for auth code as parameter on response from zoom.us 163 | if (!zoomAuthorizationCode) { 164 | const error = new Error('No authorization code was provided') 165 | error.status = 400 166 | return next(error) 167 | } 168 | 169 | console.log('1a. code param exists:', req.query.code, '\n') 170 | 171 | // 1b. Validate the state parameter is the same as the one we sent 172 | if (!zoomAuthorizationState || zoomAuthorizationState !== zoomState) { 173 | const error = new Error('Invalid state parameter') 174 | error.status = 400 175 | return next(error) 176 | } 177 | 178 | console.log( 179 | '1b. state param is correct/matches ours:', 180 | req.query.state, 181 | '\n' 182 | ) 183 | 184 | try { 185 | console.log('2. Getting Zoom access token and user', '\n') 186 | // 2. Get and remember Zoom access token and Zoom user 187 | // 2a. Exchange Zoom authorization code for tokens 188 | const tokenResponse = await zoomApi.getZoomAccessToken( 189 | zoomAuthorizationCode 190 | ) 191 | const zoomAccessToken = tokenResponse.data.access_token 192 | console.log( 193 | '2a. Use code to get Zoom access token - response data: ', 194 | tokenResponse.data, 195 | '\n' 196 | ) 197 | // other fields on token response: 198 | // tokenResponse.data.refresh_token 199 | // tokenResponse.data.expires_in 200 | 201 | // 2b. Get Zoom user info from Zoom API 202 | const userResponse = await zoomApi.getZoomUser(zoomAccessToken) 203 | const zoomUserId = userResponse.data.id 204 | 205 | console.log( 206 | '2b. Use access token to get Zoom user - response data: ', 207 | userResponse.data, 208 | '\n' 209 | ) 210 | 211 | console.log( 212 | '2c. Save the tokens in the store so we can look them up when the Zoom App is opened' 213 | ) 214 | 215 | // 2c. Save the tokens in the store so we can look them up when the Zoom App is opened: 216 | // When the home url for the app is requested on app open in the Zoom client, 217 | // the user id (uid field) is in the decrypted x-zoom-app-context header of the GET request 218 | await store.upsertUser( 219 | zoomUserId, 220 | tokenResponse.data.access_token, 221 | tokenResponse.data.refresh_token, 222 | Date.now() + tokenResponse.data.expires_in * 1000 223 | ) 224 | 225 | // 3. Get deeplink from Zoom API 226 | const deepLinkResponse = await zoomApi.getDeeplink(zoomAccessToken) 227 | const deeplink = deepLinkResponse.data.deeplink 228 | 229 | console.log( 230 | '3. Generated deeplink from Zoom API using access token: ', 231 | deeplink, 232 | '\n' 233 | ) 234 | console.log('4. Redirecting to Zoom client via deeplink . . .', '\n') 235 | 236 | // 4. Redirect to deep link to return user to the Zoom client 237 | res.redirect(deeplink) 238 | } catch (error) { 239 | return next(error) 240 | } 241 | }, 242 | 243 | // ZOOM APP HOME URL HANDLER ================================================== 244 | // This route is called when the app opens 245 | home(req, res, next) { 246 | console.log( 247 | 'ZOOM APP HOME URL HANDLER ==================================================', 248 | '\n' 249 | ) 250 | try { 251 | // 1. Decrypt the Zoom App context header 252 | if (!req.headers['x-zoom-app-context']) { 253 | throw new Error('x-zoom-app-context header is required') 254 | } 255 | 256 | const decryptedAppContext = zoomHelpers.decryptZoomAppContext( 257 | req.headers['x-zoom-app-context'], 258 | process.env.ZOOM_APP_CLIENT_SECRET 259 | ) 260 | 261 | // 2. Verify App Context has not expired 262 | if (!decryptedAppContext.exp || decryptedAppContext.exp < Date.now()) { 263 | throw new Error("x-zoom-app-context header is expired") 264 | } 265 | 266 | console.log('1. Decrypted Zoom App Context:', decryptedAppContext, '\n') 267 | console.log('2. Verifying Zoom App Context is not expired: ', new Date(decryptedAppContext.exp).toString(), '\n') 268 | console.log('3. Persisting user id and meetingUUIDa', '\n') 269 | 270 | // 3. Persist user id and meetingUUID 271 | req.session.user = decryptedAppContext.uid 272 | req.session.meetingUUID = decryptedAppContext.mid 273 | } catch (error) { 274 | return next(error) 275 | } 276 | 277 | // 4. Redirect to frontend 278 | console.log('4. Redirect to frontend', '\n') 279 | res.redirect('/api/zoomapp/proxy') 280 | }, 281 | 282 | // FRONTEND PROXY =========================================================== 283 | proxy: createProxyMiddleware({ 284 | target: process.env.ZOOM_APP_CLIENT_URL, 285 | changeOrigin: true, 286 | ws: true, 287 | }), 288 | } 289 | -------------------------------------------------------------------------------- /backend/api/zoomapp/router.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express') 2 | const router = Router() 3 | const controller = require('./controller') 4 | router 5 | .use('/proxy', controller.proxy) 6 | .use('/sockjs-node', controller.proxy) 7 | .get('/install', controller.install) 8 | .get('/auth', controller.auth) 9 | .get('/home', controller.home) 10 | .get('/authorize', controller.inClientAuthorize) 11 | .post('/onauthorized', controller.inClientOnAuthorized) 12 | 13 | module.exports = router 14 | -------------------------------------------------------------------------------- /backend/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | require('dotenv').config({ 4 | path: path.join(__dirname, `.env${process.env.NODE_ENV}`), 5 | }) 6 | 7 | require('dotenv').config() 8 | 9 | const envars = [ 10 | 'PORT', 11 | 'SESSION_SECRET', 12 | 'ZOOM_APP_CLIENT_URL', 13 | 'ZOOM_APP_CLIENT_ID', 14 | 'ZOOM_APP_CLIENT_SECRET', 15 | 'ZOOM_APP_REDIRECT_URI', 16 | 'ZOOM_HOST', 17 | 'ZOOM_APP_OAUTH_STATE_SECRET', 18 | 'REDIS_URL', 19 | 'REDIS_ENCRYPTION_KEY', 20 | ] 21 | 22 | envars.forEach((envar) => { 23 | if (!process.env[envar]) { 24 | const error = new Error(`${envar} was not detected in environment`) 25 | console.error(error) 26 | process.exit(1) 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /backend/middleware.js: -------------------------------------------------------------------------------- 1 | const session = require('express-session') 2 | const SessionStore = require('connect-redis')(session) 3 | const redis = require('redis') 4 | const store = require('./util/store') 5 | 6 | module.exports = { 7 | // Set up required OWASP HTTP response headers 8 | setResponseHeaders(req, res, next) { 9 | res.setHeader('Strict-Transport-Security', 'max-age=31536000') 10 | res.setHeader('X-Content-Type-Options', 'nosniff') 11 | // This CSP is an example, it might not work for your webpage(s) 12 | // You can generate correct CSP for your webpage here https://www.cspisawesome.com/ 13 | const publicUrl = process.env.PUBLIC_URL 14 | const { host } = new URL(publicUrl) 15 | res.setHeader( 16 | 'Content-Security-Policy', 17 | `default-src *; style-src 'self' 'unsafe-inline'; script-src * 'self' https://appssdk.zoom.us 'unsafe-inline'; connect-src * 'self' wss://${host}/sockjs-node; img-src 'self' data: https://images.unsplash.com; base-uri 'self'; form-action 'self';` 18 | ) 19 | res.setHeader('Referrer-Policy', 'same-origin') 20 | res.setHeader('X-Frame-Option', 'same-origin') 21 | next() 22 | }, 23 | 24 | // Zoom app session middleware 25 | session: session({ 26 | secret: process.env.SESSION_SECRET, 27 | resave: false, 28 | saveUninitialized: true, 29 | cookie: { 30 | path: '/', 31 | httpOnly: true, 32 | maxAge: 365 * 24 * 60 * 60 * 1000, 33 | }, 34 | store: new SessionStore({ 35 | client: redis.createClient({ 36 | url: process.env.REDIS_URL, 37 | }), 38 | }), 39 | }), 40 | 41 | // Protected route middleware 42 | // Routes behind this will only show if the user has a Zoom App session and an Auth0 id token 43 | async requiresThirdPartyAuth(req, res, next) { 44 | if (req.session.user) { 45 | try { 46 | const user = await store.getUser(req.session.user) 47 | req.thirdPartyAccessToken = user.thirdPartyAccessToken 48 | return next() 49 | } catch (error) { 50 | return next( 51 | new Error( 52 | 'Error getting app user from session. The user may have added from In-Client OAuth' 53 | ) 54 | ) 55 | } 56 | } else { 57 | next(new Error('Unkown or missing session')) 58 | } 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "za-reference-node", 3 | "version": "0.1.0", 4 | "description": "Zoom Reference App built on NodeJS & Express", 5 | "private": true, 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "node --inspect=0.0.0.0 server.js", 9 | "start:dev": "nodemon server.js" 10 | }, 11 | "dependencies": { 12 | "axios": "^1.7.9", 13 | "base64url": "^3.0.1", 14 | "connect-redis": "^5.1.0", 15 | "dotenv": "^8.2.0", 16 | "dotenv-expand": "^5.1.0", 17 | "express": "^4.21.2", 18 | "express-session": "^1.17.1", 19 | "http-proxy-middleware": "^2.0.9", 20 | "jwt-decode": "^3.1.2", 21 | "morgan": "^1.10.0", 22 | "path-to-regexp": "^8.1.0", 23 | "pug": "^3.0.2-canary-5", 24 | "react-router-dom": "^5.2.0", 25 | "ws": "^8.18.0" 26 | }, 27 | "overrides": {}, 28 | "devDependencies": { 29 | "eslint": "^7.17.0", 30 | "eslint-config-prettier": "^7.1.0", 31 | "eslint-plugin-prettier": "^3.3.1", 32 | "nodemon": "^3.1.9", 33 | "prettier": "^2.2.1", 34 | "redis": "^3.0.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /backend/public/zoomverify/verifyzoom.html.example: -------------------------------------------------------------------------------- 1 | your code here 2 | -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('./config') 4 | 5 | const http = require('http') 6 | const express = require('express') 7 | const morgan = require('morgan') 8 | 9 | const middleware = require('./middleware') 10 | 11 | const zoomAppRouter = require('./api/zoomapp/router') 12 | const zoomRouter = require('./api/zoom/router') 13 | const thirdPartyOAuthRouter = require('./api/thirdpartyauth/router') 14 | // Create app 15 | const app = express() 16 | 17 | // Set view engine (for system browser error pages) 18 | app.set('view engine', 'pug') 19 | 20 | // Set static file directory (for system browser error pages) 21 | app.use('/', express.static('public')) 22 | 23 | // Set universal middleware 24 | app.use(morgan('dev')) 25 | app.use(express.json()) 26 | app.use(express.urlencoded({ extended: false })) 27 | app.use(middleware.session) 28 | app.use(middleware.setResponseHeaders) 29 | 30 | // Zoom App routes 31 | app.use('/api/zoomapp', zoomAppRouter) 32 | if ( 33 | process.env.AUTH0_CLIENT_ID && 34 | process.env.AUTH0_CLIENT_SECRET && 35 | process.env.AUTH0_ISSUER_BASE_URL 36 | ) { 37 | app.use('/api/auth0', thirdPartyOAuthRouter) 38 | } else { 39 | console.log('Please add Auth0 env variables to enable the /auth0 route') 40 | } 41 | 42 | app.use('/zoom', zoomRouter) 43 | 44 | app.get('/hello', (req, res) => { 45 | res.send('Hello Zoom Apps!') 46 | }) 47 | 48 | // Handle 404 49 | app.use((req, res, next) => { 50 | const error = new Error('Not found') 51 | error.status = 404 52 | next(error) 53 | }) 54 | 55 | // Handle errors (system browser only) 56 | app.use((error, req, res) => { 57 | res.status(error.status || 500) 58 | res.render('error', { 59 | title: 'Error', 60 | message: error.message, 61 | stack: error.stack, 62 | }) 63 | }) 64 | 65 | // Start express server 66 | http.createServer(app).listen(process.env.PORT, () => { 67 | console.log('Zoom App is listening on port', process.env.PORT) 68 | }) 69 | -------------------------------------------------------------------------------- /backend/util/encrypt.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | module.exports = { 4 | afterSerialization(text) { 5 | const iv = crypto.randomBytes(16) 6 | const aes = crypto.createCipheriv( 7 | 'aes-256-cbc', 8 | process.env.REDIS_ENCRYPTION_KEY, 9 | iv 10 | ) 11 | let ciphertext = aes.update(text) 12 | ciphertext = Buffer.concat([iv, ciphertext, aes.final()]) 13 | return ciphertext.toString('base64') 14 | }, 15 | 16 | beforeDeserialization(ciphertext) { 17 | const ciphertextBytes = Buffer.from(ciphertext, 'base64') 18 | const iv = ciphertextBytes.subarray(0, 16) 19 | const data = ciphertextBytes.subarray(16) 20 | const aes = crypto.createDecipheriv( 21 | 'aes-256-cbc', 22 | process.env.REDIS_ENCRYPTION_KEY, 23 | iv 24 | ) 25 | let plaintextBytes = Buffer.from(aes.update(data)) 26 | plaintextBytes = Buffer.concat([plaintextBytes, aes.final()]) 27 | return plaintextBytes.toString() 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /backend/util/store.js: -------------------------------------------------------------------------------- 1 | const redis = require('redis') 2 | const encrypt = require('./encrypt') 3 | const util = require('util') 4 | /** 5 | * The auth token exchange happens before the Zoom App is launched. Therefore, 6 | * we need a place to store the tokens so we can later use them when a session 7 | * is established. 8 | * 9 | * We're using Redis here, but this could be replaced by a cache or other means 10 | * of persistence. 11 | */ 12 | 13 | const db = redis.createClient({ 14 | url: process.env.REDIS_URL, 15 | }) 16 | 17 | const getAsync = util.promisify(db.get).bind(db) 18 | const setAsync = util.promisify(db.set).bind(db) 19 | const delAsync = util.promisify(db.del).bind(db) 20 | 21 | db.on('error', console.error) 22 | 23 | module.exports = { 24 | getUser: async function (zoomUserId) { 25 | const user = await getAsync(zoomUserId) 26 | if (!user) { 27 | console.log( 28 | 'User not found. This is normal if the user has added via In-Client (or if you have restarted Docker without closing and reloading the app)' 29 | ) 30 | return Promise.reject('User not found') 31 | } 32 | return JSON.parse(encrypt.beforeDeserialization(user)) 33 | }, 34 | 35 | upsertUser: function (zoomUserId, accessToken, refreshToken, expired_at) { 36 | const isValidUser = Boolean( 37 | typeof zoomUserId === 'string' && 38 | typeof accessToken === 'string' && 39 | typeof refreshToken === 'string' && 40 | typeof expired_at === 'number' 41 | ) 42 | 43 | if (!isValidUser) { 44 | return Promise.reject('Invalid user input') 45 | } 46 | 47 | return setAsync( 48 | zoomUserId, 49 | encrypt.afterSerialization( 50 | JSON.stringify({ accessToken, refreshToken, expired_at }) 51 | ) 52 | ) 53 | }, 54 | 55 | updateUser: async function (zoomUserId, data) { 56 | const userData = await getAsync(zoomUserId) 57 | const existingUser = JSON.parse(encrypt.beforeDeserialization(userData)) 58 | const updatedUser = { ...existingUser, ...data } 59 | 60 | return setAsync( 61 | zoomUserId, 62 | encrypt.afterSerialization(JSON.stringify(updatedUser)) 63 | ) 64 | }, 65 | 66 | logoutUser: async function (zoomUserId) { 67 | const reply = await getAsync(zoomUserId) 68 | const decrypted = JSON.parse(encrypt.beforeDeserialization(reply)) 69 | delete decrypted.thirdPartyAccessToken 70 | return setAsync( 71 | zoomUserId, 72 | encrypt.afterSerialization(JSON.stringify(decrypted)) 73 | ) 74 | }, 75 | 76 | deleteUser: (zoomUserId) => delAsync(zoomUserId), 77 | 78 | storeInvite: (invitationID, tabState) => { 79 | const dbKey = `invite:${invitationID}` 80 | return setAsync(dbKey, tabState) 81 | }, 82 | 83 | getInvite: (invitationID) => { 84 | const dbKey = `invite:${invitationID}` 85 | return getAsync(dbKey) 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /backend/util/zoom-api.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const zoomHelpers = require('./zoom-helpers') 3 | 4 | const getZoomAccessToken = async ( 5 | zoomAuthorizationCode, 6 | redirect_uri = process.env.ZOOM_APP_REDIRECT_URI, 7 | pkceVerifier = undefined 8 | ) => { 9 | const params = { 10 | grant_type: 'authorization_code', 11 | code: zoomAuthorizationCode, 12 | redirect_uri, 13 | } 14 | 15 | if (typeof pkceVerifier === 'string') { 16 | params['code_verifier'] = pkceVerifier 17 | } 18 | 19 | const tokenRequestParamString = zoomHelpers.createRequestParamString(params) 20 | 21 | return await axios({ 22 | url: `${process.env.ZOOM_HOST}/oauth/token`, 23 | method: 'POST', 24 | headers: { 25 | 'Content-Type': 'application/x-www-form-urlencoded', 26 | }, 27 | auth: { 28 | username: process.env.ZOOM_APP_CLIENT_ID, 29 | password: process.env.ZOOM_APP_CLIENT_SECRET, 30 | }, 31 | data: tokenRequestParamString, 32 | }) 33 | } 34 | 35 | const refreshZoomAccessToken = async (zoomRefreshToken) => { 36 | const searchParams = new URLSearchParams() 37 | searchParams.set('grant_type', 'refresh_token') 38 | searchParams.set('refresh_token', zoomRefreshToken) 39 | 40 | return await axios({ 41 | url: `${process.env.ZOOM_HOST}/oauth/token?${searchParams.toString()}`, 42 | method: 'POST', 43 | auth: { 44 | username: process.env.ZOOM_APP_CLIENT_ID, 45 | password: process.env.ZOOM_APP_CLIENT_SECRET, 46 | }, 47 | }) 48 | } 49 | 50 | const getZoomUser = async (accessToken) => { 51 | return await axios({ 52 | url: `${process.env.ZOOM_HOST}/v2/users/me`, 53 | method: 'GET', 54 | headers: { 55 | 'Content-Type': 'application/json', 56 | Authorization: `Bearer ${accessToken}`, 57 | }, 58 | }) 59 | } 60 | 61 | const getDeeplink = async (accessToken) => { 62 | return await axios({ 63 | url: `${process.env.ZOOM_HOST}/v2/zoomapp/deeplink`, 64 | method: 'POST', 65 | headers: { 66 | Authorization: `Bearer ${accessToken}`, 67 | }, 68 | data: { 69 | action: JSON.stringify({ 70 | url: '/your/url', 71 | role_name: 'Owner', 72 | verified: 1, 73 | role_id: 0, 74 | }), 75 | }, 76 | }) 77 | } 78 | 79 | module.exports = { 80 | getZoomAccessToken, 81 | refreshZoomAccessToken, 82 | getZoomUser, 83 | getDeeplink, 84 | } 85 | -------------------------------------------------------------------------------- /backend/util/zoom-helpers.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | const base64url = require('base64url') 3 | 4 | // The Zoom App context header is an encrypted JSON string 5 | // This function unpacks, decrypts, and parses the context from the header 6 | function decryptZoomAppContext( 7 | context, 8 | secretKey = process.env.ZOOM_APP_CLIENT_SECRET 9 | ) { 10 | // Decode base64 11 | let buf = Buffer.from(context, 'base64') 12 | 13 | // Get iv length (1 byte) 14 | const ivLength = buf.readUInt8() 15 | buf = buf.subarray(1) 16 | 17 | // Get iv 18 | const iv = buf.subarray(0, ivLength) 19 | buf = buf.subarray(ivLength) 20 | 21 | // Get aad length (2 bytes) 22 | const aadLength = buf.readUInt16LE() 23 | buf = buf.subarray(2) 24 | 25 | // Get aad 26 | const aad = buf.subarray(0, aadLength) 27 | buf = buf.subarray(aadLength) 28 | 29 | // Get cipher length (4 bytes) 30 | const cipherLength = buf.readInt32LE() 31 | buf = buf.subarray(4) 32 | 33 | // Get cipherText 34 | const cipherText = buf.subarray(0, cipherLength) 35 | 36 | // Get tag 37 | const tag = buf.subarray(cipherLength) 38 | 39 | // AES/GCM decryption 40 | 41 | const decipher = crypto 42 | .createDecipheriv( 43 | 'aes-256-gcm', 44 | // hash the secret key first 45 | crypto.createHash('sha256').update(secretKey).digest(), 46 | iv, 47 | { authTagLength: 16 } 48 | ) 49 | .setAAD(aad) 50 | .setAuthTag(tag) 51 | .setAutoPadding(false) 52 | 53 | const decrypted = 54 | decipher.update(cipherText, 'hex', 'utf-8') + decipher.final('utf-8') 55 | 56 | // Return JS object 57 | return JSON.parse(decrypted) 58 | } 59 | 60 | const createRequestParamString = (params) => { 61 | const requestParams = new URLSearchParams() 62 | 63 | for (let param in params) { 64 | const value = params[param] 65 | requestParams.set(param, value) 66 | } 67 | 68 | return requestParams.toString() 69 | } 70 | 71 | const hmacBase64 = (str) => 72 | crypto 73 | .createHmac('sha256', process.env.ZOOM_APP_OAUTH_STATE_SECRET) 74 | .update(str) 75 | .digest('base64') 76 | 77 | const generateCodeVerifier = () => { 78 | return crypto.randomBytes(64).toString('hex') 79 | } 80 | 81 | const generateCodeChallenge = (codeVerifier) => { 82 | const base64Digest = crypto 83 | .createHash('sha256') 84 | .update(codeVerifier) 85 | .digest('base64') 86 | return base64url.fromBase64(base64Digest) 87 | } 88 | 89 | const generateState = () => { 90 | const ts = crypto.randomBytes(64).toString('hex') 91 | const hmac = hmacBase64(ts) 92 | return encodeURI([hmac, ts].join('.')).replace('+', '') // the replace is important because Auth0 encodes their returned state, eg with space instead of + 93 | } 94 | 95 | module.exports = { 96 | decryptZoomAppContext, 97 | createRequestParamString, 98 | generateCodeVerifier, 99 | generateCodeChallenge, 100 | generateState, 101 | } 102 | -------------------------------------------------------------------------------- /backend/views/error.pug: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title= title 4 | body 5 | h1= message 6 | pre= stack 7 | -------------------------------------------------------------------------------- /backend/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi 183 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | backend: 3 | build: ./backend/. 4 | tty: true 5 | working_dir: /home/node/app 6 | command: ['npm', 'run', 'start:dev'] 7 | platform: linux/amd64 8 | ports: 9 | - '127.0.0.1:3000:3000' 10 | - '127.0.0.1:9229:9229' 11 | env_file: 12 | - ${ENV_FILE:-.env} 13 | environment: 14 | - AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID} 15 | - AUTH0_CLIENT_SECRET=${AUTH0_CLIENT_SECRET} 16 | - AUTH0_ISSUER_BASE_URL=${AUTH0_ISSUER_BASE_URL} 17 | - PORT=3000 18 | - REDIS_ENCRYPTION_KEY=${REDIS_ENCRYPTION_KEY} 19 | - REDIS_URL=${REDIS_URL} 20 | - SESSION_SECRET=${SESSION_SECRET} 21 | - ZOOM_APP_CLIENT_ID=${ZOOM_APP_CLIENT_ID} 22 | - ZOOM_APP_CLIENT_SECRET=${ZOOM_APP_CLIENT_SECRET} 23 | - ZOOM_APP_CLIENT_URL=http://frontend:9090 24 | - ZOOM_APP_REDIRECT_URI=${PUBLIC_URL}/api/zoomapp/auth 25 | - ZOOM_APP_OAUTH_STATE_SECRET=${ZOOM_APP_OAUTH_STATE_SECRET} 26 | - ZOOM_HOST=${ZOOM_HOST} 27 | volumes: 28 | - ./backend:/home/node/app 29 | - /home/node/app/node_modules 30 | depends_on: 31 | - redis 32 | 33 | frontend: 34 | build: ./frontend/. 35 | tty: true 36 | working_dir: /home/node/app 37 | command: 'npm start' 38 | ports: 39 | - '127.0.0.1:3001:9090' 40 | environment: 41 | - PORT=9090 42 | - PUBLIC_URL=${PUBLIC_URL}/api/zoomapp/proxy 43 | - REACT_APP_PUBLIC_ROOT=${PUBLIC_URL} 44 | - REACT_APP_AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID} 45 | - REACT_APP_AUTH0_CLIENT_SECRET=${AUTH0_CLIENT_SECRET} 46 | - REACT_APP_AUTH0_ISSUER_BASE_URL=${AUTH0_ISSUER_BASE_URL} 47 | volumes: 48 | - ./frontend:/home/node/app 49 | - /home/node/app/node_modules 50 | 51 | redis: 52 | image: redis:alpine 53 | ports: 54 | - '127.0.0.1:6379:6379' 55 | volumes: 56 | - ./data:/data 57 | 58 | rtms-websocket: 59 | build: ./rtms/websocket/. 60 | profiles: ['websocket'] 61 | tty: true 62 | working_dir: /home/node/app 63 | platform: linux/amd64 64 | ports: 65 | - '127.0.0.1:3002:3002' 66 | volumes: 67 | - ./rtms/utils:/home/node/utils 68 | - ./rtms/websocket/logs:/home/node/app/logs 69 | - ./rtms/websocket/data:/home/node/app/data 70 | 71 | rtms-sdk: 72 | build: ./rtms/sdk/. 73 | profiles: ['sdk'] 74 | tty: true 75 | working_dir: /home/node/app 76 | platform: linux/amd64 77 | ports: 78 | - '127.0.0.1:3003:3003' 79 | volumes: 80 | - ./rtms/utils:/home/node/utils 81 | - ./rtms/sdk/logs:/home/node/app/logs 82 | - ./rtms/sdk/data:/home/node/app/data 83 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | PORT=9090 2 | CHOKIDAR_USEPOLLING=true 3 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .dir-locals.el 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | .eslintcache 27 | 28 | .env 29 | 30 | -------------------------------------------------------------------------------- /frontend/.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | # Create app directory 4 | WORKDIR /home/node/app 5 | 6 | # Install app dependencies 7 | # A wildcard is used to ensure both package.json AND package-lock.json are copied 8 | # where available (npm@5+) 9 | COPY package*.json ./ 10 | 11 | RUN npm install 12 | RUN npm install --only=dev 13 | RUN npm install chokidar -D 14 | # If you are building your code for production 15 | # RUN npm ci --only=production 16 | 17 | # Bundle app source 18 | COPY . . 19 | 20 | CMD [ "npm", "start" ] 21 | -------------------------------------------------------------------------------- /frontend/config-overrides.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devServer: (configFunction) => { 3 | return (proxy, allowedHost) => { 4 | const config = configFunction(proxy, allowedHost); 5 | config.headers = { 6 | "access-control-allow-origin": "*", 7 | }; 8 | return config; 9 | }; 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "za-reference-react", 3 | "version": "0.1.0", 4 | "description": "Reference Zoom App built with ReactJS", 5 | "private": true, 6 | "license": "MIT", 7 | "dependencies": { 8 | "@testing-library/jest-dom": "^5.11.4", 9 | "@testing-library/react": "^11.1.0", 10 | "@testing-library/user-event": "^12.1.10", 11 | "bootstrap": "^5.1.3", 12 | "react": "^17.0.1", 13 | "react-bootstrap": "^2.2.1", 14 | "react-dom": "^17.0.1", 15 | "react-router-dom": "^6.26.2", 16 | "react-select": "^4.3.1", 17 | "web-vitals": "^0.2.4" 18 | }, 19 | "scripts": { 20 | "start": "react-app-rewired start | cat", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject" 24 | }, 25 | "eslintConfig": { 26 | "extends": [ 27 | "react-app", 28 | "react-app/jest" 29 | ] 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | }, 43 | "overrides": { 44 | "@babel/runtime": "^7.27.0", 45 | "@babel/runtime-corejs3": "^7.27.0" 46 | }, 47 | "devDependencies": { 48 | "react-app-rewired": "^2.1.8", 49 | "react-scripts": "5.0.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zoom/zoomapps-advancedsample-react/c8bec052200a0853b3aac253c42905239c7cb085/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zoom/zoomapps-advancedsample-react/c8bec052200a0853b3aac253c42905239c7cb085/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zoom/zoomapps-advancedsample-react/c8bec052200a0853b3aac253c42905239c7cb085/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | padding: 20px; 3 | margin-bottom: 200px; 4 | } 5 | 6 | .tab-list { 7 | border-bottom: 1px solid #ccc; 8 | padding-left: 0; 9 | } 10 | 11 | .tab-list-item { 12 | display: inline-block; 13 | list-style: none; 14 | margin-bottom: -1px; 15 | padding: 0.5rem 0.75rem; 16 | } 17 | 18 | .tab-list-active { 19 | background-color: white; 20 | border: solid #ccc; 21 | border-width: 1px 1px 0 1px; 22 | } 23 | 24 | Button { 25 | margin-bottom: 5px; 26 | margin-right: 5px; 27 | min-width: 156px; 28 | font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", 29 | Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", 30 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 31 | } 32 | 33 | iframe { 34 | border: 1px solid black; 35 | } -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | /* globals zoomSdk */ 2 | import { useLocation, useNavigate } from 'react-router-dom' 3 | import { useCallback, useEffect, useState } from 'react' 4 | import { apis } from './apis' 5 | import { Authorization } from './components/Authorization' 6 | import ApiScrollview from './components/ApiScrollview' 7 | import './App.css' 8 | import 'bootstrap/dist/css/bootstrap.min.css' 9 | 10 | let once = 0 // to prevent increasing number of event listeners being added 11 | 12 | function App() { 13 | const navigate = useNavigate() 14 | const location = useLocation() 15 | const [error, setError] = useState(null) 16 | const [user, setUser] = useState(null) 17 | const [runningContext, setRunningContext] = useState(null) 18 | const [connected, setConnected] = useState(false) 19 | const [counter, setCounter] = useState(0) 20 | const [preMeeting, setPreMeeting] = useState(true) // start with pre-meeting code 21 | const [userContextStatus, setUserContextStatus] = useState('') 22 | const [rmtsMessage, setRtmsMessage] = useState('') 23 | 24 | useEffect(() => { 25 | async function configureSdk() { 26 | // to account for the 2 hour timeout for config 27 | const configTimer = setTimeout(() => { 28 | setCounter(counter + 1) 29 | }, 120 * 60 * 1000) 30 | 31 | try { 32 | // Configure the JS SDK, required to call JS APIs in the Zoom App 33 | // These items must be selected in the Features -> Zoom App SDK -> Add APIs tool in Marketplace 34 | const configResponse = await zoomSdk.config({ 35 | capabilities: [ 36 | // apis demoed in the buttons 37 | ...apis.map((api) => api.name), // IMPORTANT 38 | 39 | // demo events 40 | 'onSendAppInvitation', 41 | 'onShareApp', 42 | 'onActiveSpeakerChange', 43 | 'onMeeting', 44 | 45 | // connect api and event 46 | 'connect', 47 | 'onConnect', 48 | 'postMessage', 49 | 'onMessage', 50 | 51 | // in-client api and event 52 | 'authorize', 53 | 'onAuthorized', 54 | 'promptAuthorize', 55 | 'getUserContext', 56 | 'onMyUserContextChange', 57 | 'sendAppInvitationToAllParticipants', 58 | 'sendAppInvitation', 59 | 60 | // RTMS 61 | 'startRTMS', 62 | 'stopRTMS', 63 | ], 64 | version: '0.16.0', 65 | }) 66 | console.log('App configured', configResponse) 67 | // The config method returns the running context of the Zoom App 68 | setRunningContext(configResponse.runningContext) 69 | setUserContextStatus(configResponse.auth.status) 70 | zoomSdk.onSendAppInvitation((data) => { 71 | console.log(data) 72 | }) 73 | zoomSdk.onShareApp((data) => { 74 | console.log(data) 75 | }) 76 | } catch (error) { 77 | console.log(error) 78 | setError('There was an error configuring the JS SDK') 79 | } 80 | return () => { 81 | clearTimeout(configTimer) 82 | } 83 | } 84 | configureSdk() 85 | }, [counter]) 86 | 87 | // PRE-MEETING 88 | let on_message_handler_client = useCallback( 89 | (message) => { 90 | let content = message.payload.payload 91 | if (content === 'connected' && preMeeting === true) { 92 | console.log('Meeting instance exists.') 93 | zoomSdk.removeEventListener('onMessage', on_message_handler_client) 94 | console.log("Letting meeting instance know client's current state.") 95 | sendMessage(window.location.hash, 'client') 96 | setPreMeeting(false) // client instance is finished with pre-meeting 97 | } 98 | }, 99 | [preMeeting] 100 | ) 101 | 102 | // PRE-MEETING 103 | useEffect(() => { 104 | if (runningContext === 'inMainClient' && preMeeting === true) { 105 | zoomSdk.addEventListener('onMessage', on_message_handler_client) 106 | } 107 | }, [on_message_handler_client, preMeeting, runningContext]) 108 | 109 | async function sendMessage(msg, sender) { 110 | console.log('Message sent from ' + sender + ' with data: ' + JSON.stringify(msg)) 111 | console.log('Calling postmessage...', msg) 112 | await zoomSdk.postMessage({ 113 | payload: msg, 114 | }) 115 | } 116 | 117 | const receiveMessage = useCallback( 118 | (receiver, reason = '') => { 119 | let on_message_handler = (message) => { 120 | let content = message.payload.payload 121 | console.log('Message received ' + receiver + ' ' + reason + ': ' + content) 122 | navigate({ pathname: content }) 123 | } 124 | if (once === 0) { 125 | zoomSdk.addEventListener('onMessage', on_message_handler) 126 | once = 1 127 | } 128 | }, 129 | [navigate] 130 | ) 131 | 132 | useEffect(() => { 133 | async function connectInstances() { 134 | // only can call connect when in-meeting 135 | if (runningContext === 'inMeeting') { 136 | zoomSdk.addEventListener('onConnect', (event) => { 137 | console.log('Connected') 138 | setConnected(true) 139 | 140 | // PRE-MEETING 141 | // first message to send after connecting instances is for the meeting 142 | // instance to catch up with the client instance 143 | if (preMeeting === true) { 144 | console.log('Letting client know meeting instance exists.') 145 | sendMessage('connected', 'meeting') 146 | console.log("Adding message listener for client's current state.") 147 | let on_message_handler_mtg = (message) => { 148 | console.log('Message from client received. Meeting instance updating its state:', message.payload.payload) 149 | window.location.replace(message.payload.payload) 150 | zoomSdk.removeEventListener('onMessage', on_message_handler_mtg) 151 | setPreMeeting(false) // meeting instance is finished with pre-meeting 152 | } 153 | zoomSdk.addEventListener('onMessage', on_message_handler_mtg) 154 | } 155 | }) 156 | 157 | await zoomSdk.connect() 158 | console.log('Connecting...') 159 | } 160 | } 161 | 162 | if (connected === false) { 163 | console.log(runningContext, location.pathname) 164 | connectInstances() 165 | } 166 | }, [connected, location.pathname, preMeeting, runningContext]) 167 | 168 | // POST-MEETING 169 | useEffect(() => { 170 | async function communicateTabChange() { 171 | // only proceed with post-meeting after pre-meeting is done 172 | // just one-way communication from in-meeting to client 173 | if (runningContext === 'inMeeting' && connected && preMeeting === false) { 174 | sendMessage(location.pathname, runningContext) 175 | } else if (runningContext === 'inMainClient' && preMeeting === false) { 176 | receiveMessage(runningContext, 'for tab change') 177 | } 178 | } 179 | communicateTabChange() 180 | }, [connected, location, preMeeting, receiveMessage, runningContext]) 181 | 182 | if (error) { 183 | console.log(error) 184 | return ( 185 |
186 |

{error.message}

187 |
188 | ) 189 | } 190 | 191 | const handleStartRTMS = async () => { 192 | try { 193 | const res = await zoomSdk.callZoomApi('startRTMS') 194 | setRtmsMessage(`startRTMS success response: ${res}`) 195 | } catch (error) { 196 | setRtmsMessage(`startRTMS error response: ${error}`) 197 | } 198 | } 199 | 200 | const handleStopRTMS = async () => { 201 | try { 202 | const res = await zoomSdk.callZoomApi('stopRTMS') 203 | setRtmsMessage(`stopRTMS success response: ${res}`) 204 | } catch (error) { 205 | setRtmsMessage(`stopRTMS error response: ${error}`) 206 | } 207 | } 208 | 209 | return ( 210 |
211 |

212 | Hello 213 | {user ? ` ${user.first_name} ${user.last_name}` : ' Zoom Apps user'}! 214 |

215 |

{`User Context Status: ${userContextStatus}`}

216 |

{runningContext ? `Running Context: ${runningContext}` : 'Configuring Zoom JavaScript SDK...'}

217 | 218 | {rmtsMessage &&

{rmtsMessage}

} 219 | 220 | 221 | 228 |
229 | ) 230 | } 231 | 232 | export default App 233 | -------------------------------------------------------------------------------- /frontend/src/apis.js: -------------------------------------------------------------------------------- 1 | /* globals zoomSdk */ 2 | 3 | const invokeZoomAppsSdk = (api) => () => { 4 | const { name, buttonName = '', options = null } = api 5 | const zoomAppsSdkApi = zoomSdk[name].bind(zoomSdk) 6 | 7 | zoomAppsSdkApi(options) 8 | .then((clientResponse) => { 9 | console.log(`${buttonName || name} success with response: ${JSON.stringify(clientResponse)}`) 10 | }) 11 | .catch((clientError) => { 12 | console.log(`${buttonName || name} error: ${JSON.stringify(clientError)}`) 13 | }) 14 | } 15 | 16 | const sortListByName = (curr, next) => { 17 | const currName = curr.name.toLowerCase() 18 | const nextName = next.name.toLowerCase() 19 | if (currName < nextName) { 20 | return -1 21 | } 22 | if (currName > nextName) { 23 | return 1 24 | } 25 | return 0 26 | } 27 | 28 | // New apis are constantly created and may not be included here 29 | // Please visit the Zoom Apps developer docs for comprehensive list 30 | const apis = [ 31 | { 32 | name: 'setVirtualBackground', 33 | options: { 34 | fileUrl: 35 | 'https://images.unsplash.com/photo-1533743983669-94fa5c4338ec?ixid=MXwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHw%3D&ixlib=rb-1.2.1&auto=format&fit=crop&w=983&q=80', 36 | }, 37 | }, 38 | { 39 | name: 'removeVirtualBackground', 40 | }, 41 | { 42 | name: 'getSupportedJsApis', 43 | }, 44 | { 45 | name: 'openUrl', 46 | options: { url: 'https://www.google.com/' }, 47 | }, 48 | { 49 | name: 'getMeetingContext', 50 | }, 51 | { 52 | name: 'getRunningContext', 53 | }, 54 | { 55 | name: 'showNotification', 56 | options: { 57 | type: 'info', 58 | title: 'Hello Zoom Apps', 59 | message: 'Testing notification', 60 | }, 61 | }, 62 | { 63 | name: 'sendAppInvitationToAllParticipants', 64 | }, 65 | { 66 | name: 'sendAppInvitationToMeetingOwner', 67 | }, 68 | { 69 | name: 'showAppInvitationDialog', 70 | }, 71 | { 72 | name: 'getMeetingParticipants', 73 | }, 74 | { 75 | name: 'getMeetingUUID', 76 | }, 77 | { 78 | name: 'getMeetingJoinUrl', 79 | }, 80 | { 81 | name: 'listCameras', 82 | }, 83 | { 84 | name: 'expandApp', 85 | }, 86 | { 87 | name: 'allowParticipantToRecord', 88 | }, 89 | { 90 | name: 'getRecordingContext', 91 | }, 92 | { 93 | buttonName: 'cloudRecording (start)', 94 | name: 'cloudRecording', 95 | options: { action: 'start' }, 96 | }, 97 | { 98 | buttonName: 'cloudRecording (stop)', 99 | name: 'cloudRecording', 100 | options: { action: 'stop' }, 101 | }, 102 | { 103 | buttonName: 'cloudRecording (pause)', 104 | name: 'cloudRecording', 105 | options: { action: 'pause' }, 106 | }, 107 | { 108 | buttonName: 'cloudRecording (resume)', 109 | name: 'cloudRecording', 110 | options: { action: 'resume' }, 111 | }, 112 | { 113 | buttonName: 'setVideoMirrorEffect (true)', 114 | name: 'setVideoMirrorEffect', 115 | options: { 116 | mirrorMyVideo: true, 117 | }, 118 | }, 119 | { 120 | buttonName: 'setVideoMirrorEffect (false)', 121 | name: 'setVideoMirrorEffect', 122 | options: { 123 | mirrorMyVideo: false, 124 | }, 125 | }, 126 | { 127 | buttonName: 'shareApp (start)', 128 | name: 'shareApp', 129 | options: { 130 | action: 'start', 131 | }, 132 | }, 133 | { 134 | buttonName: 'shareApp (stop)', 135 | name: 'shareApp', 136 | options: { 137 | action: 'stop', 138 | }, 139 | }, 140 | ].sort(sortListByName) 141 | 142 | module.exports = { apis, invokeZoomAppsSdk } 143 | -------------------------------------------------------------------------------- /frontend/src/components/ApiScrollview.css: -------------------------------------------------------------------------------- 1 | #api-scrollview-input { 2 | margin-top: 10px; 3 | margin-bottom: 10px; 4 | border-radius: .25rem; 5 | border: 1px solid #212529; 6 | padding: 3px; 7 | margin-left: 1px; 8 | } 9 | 10 | .api-scrollview { 11 | margin-top: 10px; 12 | margin-bottom: 0px; 13 | } 14 | 15 | .api-buttons-list { 16 | display: block; 17 | width: 300px; 18 | height: 250px; 19 | overflow-y: auto; 20 | overflow-x: hidden; 21 | } 22 | 23 | .api-button { 24 | min-width: 100%; 25 | max-height: 40px; 26 | } 27 | 28 | .hr-scroll-border { 29 | width: 300px; 30 | margin-top: 2px; 31 | } -------------------------------------------------------------------------------- /frontend/src/components/ApiScrollview.js: -------------------------------------------------------------------------------- 1 | import { React, useState } from 'react' 2 | import Button from 'react-bootstrap/Button' 3 | import { apis, invokeZoomAppsSdk } from '../apis' 4 | import './ApiScrollview.css' 5 | 6 | function ApiScrollview({ onStartRTMS, onStopRTMS }) { 7 | const [apiSearchText, setApiSearchText] = useState('') 8 | 9 | const searchHandler = (e) => { 10 | let lowerCase = e.target.value.toLowerCase() 11 | setApiSearchText(lowerCase) 12 | } 13 | 14 | const filteredApis = apis?.filter((api) => { 15 | if (apiSearchText === '') { 16 | return api 17 | } else { 18 | return api.name.toLowerCase().includes(apiSearchText) 19 | } 20 | }) 21 | 22 | return ( 23 |
24 | 25 | 26 |
27 | 30 | 33 | 34 | {filteredApis?.map((api) => ( 35 | 39 | ))} 40 |
41 |
42 |
43 | ) 44 | } 45 | 46 | export default ApiScrollview 47 | -------------------------------------------------------------------------------- /frontend/src/components/Auth0User.js: -------------------------------------------------------------------------------- 1 | /* globals zoomSdk */ 2 | import { useEffect, useCallback, useState } from "react"; 3 | import Spinner from "react-bootstrap/Spinner"; 4 | import Alert from "react-bootstrap/Alert"; 5 | import Button from "react-bootstrap/Button"; 6 | import "bootstrap/dist/css/bootstrap.min.css"; 7 | 8 | function Auth0User(props) { 9 | const [thirdPartyUser, setThirdPartyUser] = useState(null); 10 | const [isLoadingAuth0, setIsLoadingAuth0] = useState(true); 11 | const [isLoggingIn, setIsLoggingIn] = useState(false); 12 | 13 | const isMissingAuthVariables = 14 | !process.env.REACT_APP_AUTH0_CLIENT_ID || 15 | !process.env.REACT_APP_AUTH0_ISSUER_BASE_URL || 16 | !process.env.REACT_APP_AUTH0_CLIENT_SECRET; 17 | 18 | useEffect(() => { 19 | if (!isMissingAuthVariables) { 20 | async function getUserProfile() { 21 | const userDataResponse = await fetch( 22 | `${process.env.REACT_APP_PUBLIC_ROOT}/api/auth0/proxy/userinfo` 23 | ); 24 | 25 | if (userDataResponse.ok && userDataResponse.status === 200) { 26 | const userData = await userDataResponse.json(); 27 | setThirdPartyUser(userData); 28 | } else { 29 | console.log(userDataResponse) 30 | console.log('Request to Auth0 API has failed ^, likely because no Auth0 access token exists for this user. You must click Login to authenticate to 3rd party') 31 | } 32 | setIsLoadingAuth0(false); 33 | } 34 | getUserProfile(); 35 | } 36 | }, [props.user, isMissingAuthVariables]); 37 | 38 | const thirdPartyInstall = async () => { 39 | setIsLoggingIn(true); 40 | zoomSdk.openUrl({ 41 | url: `${process.env.REACT_APP_PUBLIC_ROOT}/api/auth0/login`, 42 | }); 43 | setTimeout(() => setIsLoggingIn(false), 3000); 44 | }; 45 | 46 | const thirdPartyLogout = useCallback(() => { 47 | setIsLoadingAuth0(true); 48 | fetch(`${process.env.REACT_APP_PUBLIC_ROOT}/api/auth0/logout`) 49 | .then(() => { 50 | setIsLoadingAuth0(false); 51 | setThirdPartyUser(null); 52 | }) 53 | .catch((error) => { 54 | setIsLoadingAuth0(false); 55 | console.error(error); 56 | //setError(error); 57 | }); 58 | }, []); 59 | 60 | return ( 61 |
62 |
 63 |         {isLoadingAuth0 && !isMissingAuthVariables ? (
 64 |           
 65 |             Loading...
 66 |           
 67 |         ) : thirdPartyUser ? (
 68 |           
69 |
70 | {JSON.stringify( 71 | thirdPartyUser, 72 | , 79 | 2 80 | )} 81 |
82 | 89 |
90 | ) : ( 91 | <> 92 | {isMissingAuthVariables && ( 93 | Missing Auth0 env variables 94 | )} 95 | 102 | 103 | )} 104 |
105 |
106 | ); 107 | } 108 | 109 | export default Auth0User; 110 | -------------------------------------------------------------------------------- /frontend/src/components/Authorization.js: -------------------------------------------------------------------------------- 1 | /* globals zoomSdk */ 2 | import React, { useEffect, useState } from "react"; 3 | import { Routes, Route, Navigate, useLocation } from "react-router-dom"; 4 | import Button from "react-bootstrap/Button"; 5 | import Auth0User from "./Auth0User"; 6 | import Header from "./Header"; 7 | import IFrame from "./IFrame"; 8 | import Image from "./Image"; 9 | import UserInfo from "./UserInfo"; 10 | 11 | export const Authorization = (props) => { 12 | const { 13 | handleError, 14 | handleUser, 15 | handleUserContextStatus, 16 | user, 17 | userContextStatus, 18 | } = props; 19 | const location = useLocation(); 20 | const [userAuthorized, setUserAuthorized] = useState(null); 21 | const [showInClientOAuthPrompt, setShowInClientOAuthPrompt] = useState(false); 22 | const [inGuestMode, setInGuestMode] = useState(false); 23 | 24 | const promptAuthorize = async () => { 25 | try { 26 | const promptAuthResponse = await zoomSdk.promptAuthorize(); 27 | console.log(promptAuthResponse); 28 | } catch (e) { 29 | console.error(e); 30 | } 31 | }; 32 | 33 | const authorize = async () => { 34 | setShowInClientOAuthPrompt(false); 35 | console.log("Authorize flow begins here"); 36 | console.log("1. Get code challenge and state from backend . . ."); 37 | let authorizeResponse; 38 | try { 39 | authorizeResponse = await (await fetch("/api/zoomapp/authorize")).json(); 40 | console.log(authorizeResponse); 41 | if (!authorizeResponse || !authorizeResponse.codeChallenge) { 42 | console.error( 43 | "Error in the authorize flow - likely an outdated user session. Please refresh the app" 44 | ); 45 | setShowInClientOAuthPrompt(true); 46 | return; 47 | } 48 | } catch (e) { 49 | console.error(e); 50 | } 51 | const { codeChallenge, state } = authorizeResponse; 52 | 53 | console.log("1a. Code challenge from backend: ", codeChallenge); 54 | console.log("1b. State from backend: ", state); 55 | 56 | const authorizeOptions = { 57 | codeChallenge: codeChallenge, 58 | state: state, 59 | }; 60 | console.log("2. Invoke authorize, eg zoomSdk.authorize(authorizeOptions)"); 61 | try { 62 | const zoomAuthResponse = await zoomSdk.authorize(authorizeOptions); 63 | console.log(zoomAuthResponse); 64 | } catch (e) { 65 | console.error(e); 66 | } 67 | }; 68 | 69 | useEffect(() => { 70 | // this is not the best way to make sure > 1 instances are not registered 71 | console.log("In-Client OAuth flow: onAuthorized event listener added"); 72 | zoomSdk.addEventListener("onAuthorized", (event) => { 73 | const { code, state } = event; 74 | console.log("3. onAuthorized event fired."); 75 | console.log( 76 | "3a. Here is the event passed to event listener callback, with code and state: ", 77 | event 78 | ); 79 | console.log( 80 | "4. POST the code, state to backend to exchange server-side for a token. Refer to backend logs now . . ." 81 | ); 82 | 83 | fetch("/api/zoomapp/onauthorized", { 84 | method: "POST", 85 | body: JSON.stringify({ 86 | code, 87 | state, 88 | href: window.location.href, 89 | }), 90 | headers: { 91 | "Content-Type": "application/json", 92 | }, 93 | }).then(() => { 94 | console.log( 95 | "4. Backend returns successfully after exchanging code for auth token. Go ahead and update the UI" 96 | ); 97 | setUserAuthorized(true); 98 | 99 | // the error === string 100 | handleError(null); 101 | }); 102 | }); 103 | }, [handleError]); 104 | 105 | useEffect(() => { 106 | zoomSdk.addEventListener("onMyUserContextChange", (event) => { 107 | handleUserContextStatus(event.status); 108 | }); 109 | async function fetchUser() { 110 | try { 111 | // An example of using the Zoom REST API via proxy 112 | const response = await fetch("/zoom/api/v2/users/me"); 113 | if (response.status !== 200) throw new Error(); 114 | const user = await response.json(); 115 | handleUser(user); 116 | setShowInClientOAuthPrompt(false); 117 | } catch (error) { 118 | console.error(error); 119 | console.log( 120 | "Request to Zoom REST API has failed ^, likely because no Zoom access token exists for this user. You must use the authorize API to get an access token" 121 | ); 122 | setShowInClientOAuthPrompt(true); 123 | } 124 | } 125 | 126 | if (userContextStatus === "authorized") { 127 | setInGuestMode(false); 128 | fetchUser(); 129 | } else if ( 130 | userContextStatus === "unauthenticated" || 131 | userContextStatus === "authenticated" 132 | ) { 133 | setInGuestMode(true); 134 | } 135 | }, [handleUser, handleUserContextStatus, userAuthorized, userContextStatus]); 136 | 137 | return ( 138 | <> 139 |

You are on this route: {location.pathname}

140 | 141 | {!inGuestMode && ( 142 | 148 | )} 149 | 150 |
151 |
154 | 155 | } /> 156 | 166 | } 167 | /> 168 | } /> 169 | } /> 170 | 171 |
172 |
173 | 174 | 175 | ); 176 | }; 177 | -------------------------------------------------------------------------------- /frontend/src/components/Header.css: -------------------------------------------------------------------------------- 1 | header { 2 | width: 100%; 3 | height: 5rem; 4 | background-color: #044599; 5 | margin-bottom: 10px; 6 | margin-top: 10px; 7 | border-radius: 0.25em; 8 | } 9 | 10 | header nav { 11 | height: 100%; 12 | } 13 | 14 | header ul { 15 | height: 100%; 16 | list-style: none; 17 | display: flex; 18 | padding: 0; 19 | margin: 0; 20 | align-items: center; 21 | justify-content: left; 22 | } 23 | 24 | header li { 25 | margin: 0 1rem; 26 | } 27 | 28 | header a { 29 | color: white; 30 | text-decoration: none; 31 | } 32 | 33 | header a:hover, 34 | header a:active, 35 | header a.active { 36 | color: #95bcf0; 37 | padding-bottom: 0.25rem; 38 | border-bottom: 4px solid #95bcf0; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/Header.js: -------------------------------------------------------------------------------- 1 | import { NavLink } from "react-router-dom"; 2 | import "./Header.css"; 3 | 4 | function Header(props) { 5 | const navLinks = props.navLinks; 6 | 7 | return ( 8 |
9 |
    10 | {Object.entries(navLinks).map((navLink) => { 11 | const route = navLink[0]; 12 | const navName = navLink[1]; 13 | return ( 14 |
  • 15 | 16 | {navName} 17 | 18 |
  • 19 | ); 20 | })} 21 |
22 |
23 | ); 24 | } 25 | 26 | export default Header; 27 | -------------------------------------------------------------------------------- /frontend/src/components/IFrame.js: -------------------------------------------------------------------------------- 1 | function IFrame() { 2 | return ( 3 |