Skip to content

Commit

Permalink
feat: Parabol Icebreaker Zoom App (#12)
Browse files Browse the repository at this point in the history
* feat: migrate the /auth and /install route logic

* fix: rename function

* feat: add the verifyzoom.html

* feat: add the zoom & github entries

* fix: fix the lint-staged lint issue

* fix: update Missing OWASP Secure Headers

* fix: add 'unsafe-inline' style-src CSP

* fix: allow all CSP policy
  • Loading branch information
JimmyLv authored Sep 7, 2022
1 parent d7e9318 commit 159e511
Show file tree
Hide file tree
Showing 13 changed files with 596 additions and 28 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
# misc
.DS_Store
*.pem
.idea/

# debug
npm-debug.log*
Expand Down
16 changes: 16 additions & 0 deletions components/icons/GitHub.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
interface Props {
className?: string;
}

export const GitHub = ({ className }: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
viewBox="0 0 496 512"
fill="#fff"
>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" />
</svg>
);
};
16 changes: 16 additions & 0 deletions components/icons/Zoom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
interface Props {
className?: string;
}

export const Zoom = ({ className }: Props) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
viewBox="0 0 576 512"
fill="#fff"
>
<path d="M0 128C0 92.7 28.7 64 64 64H320c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128zM559.1 99.8c10.4 5.6 16.9 16.4 16.9 28.2V384c0 11.8-6.5 22.6-16.9 28.2s-23 5-32.9-1.6l-96-64L416 337.1V320 192 174.9l14.2-9.5 96-64c9.8-6.5 22.4-7.2 32.9-1.6z" />
</svg>
);
};
37 changes: 37 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export const zoomApp = {
host: process.env.ZM_HOST || 'https://zoom.us',
clientId: process.env.ZM_CLIENT_ID || '',
clientSecret: process.env.ZM_CLIENT_SECRET || '',
redirectUrl: process.env.ZM_REDIRECT_URL || '',
sessionSecret: process.env.SESSION_SECRET || '',
};

//{
// name: 'session',
// httpOnly: true,
// keys: [zoomApp.sessionSecret],
// maxAge: 24 * 60 * 60 * 1000,
// secure: process.env.NODE_ENV === 'production',
// }
export const sessionOptions = {
cookieName: "parabol_session",
password: zoomApp.sessionSecret,
// secure: true should be used in production (HTTPS) but can't be used in development (HTTP)
cookieOptions: {
secure: process.env.NODE_ENV === "production",
},
};

// Zoom App Info
export const appName = process.env.APP_NAME || 'zoom-app';
export const redirectUri = zoomApp.redirectUrl;

// HTTP
export const port = process.env.PORT || '3000';

// require secrets are explicitly imported
export default {
appName,
redirectUri,
port,
};
29 changes: 29 additions & 0 deletions lib/zoom/with-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
import {
GetServerSidePropsContext,
GetServerSidePropsResult,
NextApiHandler,
} from "next";
import { sessionOptions } from '../config'

declare module "iron-session" {
interface IronSessionData {
state: string | null
verifier: string | null
}
}

export function withSessionRoute(handler: NextApiHandler) {
return withIronSessionApiRoute(handler, sessionOptions);
}

// Theses types are compatible with InferGetStaticPropsType https://nextjs.org/docs/basic-features/data-fetching#typescript-use-getstaticprops
export function withSessionSsr<
P extends { [key: string]: unknown } = { [key: string]: unknown },
>(
handler: (
context: GetServerSidePropsContext,
) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>,
) {
return withIronSessionSsr(handler, sessionOptions);
}
156 changes: 156 additions & 0 deletions lib/zoom/zoom-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// @ts-nocheck
import axios from "axios";
import { URL } from "url";
import { zoomApp } from "../config";
import crypto from "crypto";

// returns a base64 encoded url
const base64URL = (s) =>
s
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");

// returns a random string of format fmt
const rand = (fmt, depth = 32) => crypto.randomBytes(depth).toString(fmt);

// Get Zoom API URL from Zoom Host value
const host = new URL(zoomApp.host);
host.hostname = `api.${host.hostname}`;

const baseURL = host.href;

/**
* Generic function for getting access or refresh tokens
* @param {string} [id=''] - Username for Basic Auth
* @param {string} [secret=''] - Password for Basic Auth
* @param {Object} params - Request parameters (form-urlencoded)
*/
function tokenRequest(params, id = "", secret = "") {
const username = id || zoomApp.clientId;
const password = secret || zoomApp.clientSecret;

return axios({
data: new URLSearchParams(params).toString(),
baseURL: zoomApp.host,
url: "/oauth/token",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
auth: {
username,
password,
},
}).then(({ data }) => Promise.resolve(data));
}

/**
* Generic function for making requests to the Zoom API
* @param {string} method - Request method
* @param {string | URL} endpoint - Zoom API Endpoint
* @param {string} token - Access Token
* @param {object} [data=null] - Request data
*/
function apiRequest(method, endpoint, token, data = null) {
return axios({
data,
method,
baseURL,
url: `/v2${endpoint}`,
headers: {
Authorization: `Bearer ${token}`,
},
}).then(({ data }) => Promise.resolve(data));
}

/**
* Return the url, state and verifier for the Zoom App Install
* @return {{verifier: string, state: string, url: module:url.URL}}
*/
export function getInstallURL() {
const state = rand("base64");
const verifier = rand("ascii");

const digest = crypto
.createHash("sha256")
.update(verifier)
.digest("base64")
.toString();

const challenge = base64URL(digest);

const url = new URL("/oauth/authorize", zoomApp.host);

url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", zoomApp.clientId);
url.searchParams.set("redirect_uri", zoomApp.redirectUrl);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", state);

return { url, state, verifier };
}

/**
* Obtains an OAuth access token from Zoom
* @param {string} code - Authorization code from user authorization
* @param verifier - code_verifier for PKCE
* @return {Promise} Promise resolving to the access token object
*/
export async function getToken(code, verifier) {
if (!code || typeof code !== "string")
throw new Error("authorization code must be a valid string");

if (!verifier || typeof verifier !== "string")
throw new Error("code verifier code must be a valid string");

return tokenRequest({
code,
code_verifier: verifier,
redirect_uri: zoomApp.redirectUrl,
grant_type: "authorization_code",
});
}

/**
* Obtain a new Access Token from a Zoom Refresh Token
* @param {string} token - Refresh token to use
* @return {Promise<void>}
*/
export async function refreshToken(token) {
if (!token || typeof token !== "string")
throw new Error("refresh token must be a valid string");

return tokenRequest({
refresh_token: token,
grant_type: "refresh_token",
});
}

/**
* Use the Zoom API to get a Zoom User
* @param {string} uid - User ID to query on
* @param {string} token Zoom App Access Token
*/
export function getZoomUser(uid, token) {
return apiRequest("GET", `/users/${uid}`, token);
}

/**
* Return the DeepLink for opening Zoom
* @param {string} token - Zoom App Access Token
* @return {Promise}
*/
export function getDeeplink(token) {
// @ts-ignore
return apiRequest("POST", "/zoomapp/deeplink", token, {
action: JSON.stringify({
url: "/",
role_name: "Owner",
verified: 1,
role_id: 0,
}),
}).then((data) => Promise.resolve(data.deeplink));
}
26 changes: 26 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,38 @@ const CACHE_CONTROL_HEADER = {
key: "Cache-Control",
value: `public, max-age=${ONE_YEAR_SECONDS}, immutable`,
};
const ContentSecurityPolicy = `
default-src * 'unsafe-inline' 'unsafe-eval';
script-src * 'unsafe-inline' 'unsafe-eval';
connect-src * 'unsafe-inline';
img-src * data: blob: 'unsafe-inline';
frame-src *;
style-src * 'unsafe-inline';
`;
const SECURITY_HEADERS = [
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Content-Security-Policy",
value: ContentSecurityPolicy.replace(/\s{2,}/g, " ").trim(),
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin",
},
];

const nextConfig = {
reactStrictMode: true,
swcMinify: true,
headers: async () => {
return [
{
source: "/:path*",
headers: SECURITY_HEADERS,
},
{
source: "/img/:path*",
headers: [CACHE_CONTROL_HEADER],
Expand Down
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@
"prepare": "husky install"
},
"lint-staged": {
"*.{ts,tsx}": "yarn lint --fix"
"*.{ts,tsx}": [
"eslint --fix",
"git add"
]
},
"dependencies": {
"axios": "^0.27.2",
"chrome-aws-lambda": "^10.1.0",
"clsx": "^1.2.1",
"iron-session": "^6.2.0",
"next": "12.2.2",
"next-seo": "^5.5.0",
"puppeteer-core": "^15.5.0",
Expand All @@ -30,12 +35,12 @@
"eslint": "8.20.0",
"eslint-config-next": "12.2.2",
"eslint-config-prettier": "^8.5.0",
"husky": "^8.0.0",
"lint-staged": "^13.0.3",
"postcss": "^8.4.14",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.12",
"tailwindcss": "^3.1.6",
"typescript": "4.7.4",
"husky": "^8.0.0"
"typescript": "4.7.4"
}
}
34 changes: 34 additions & 0 deletions pages/api/zoom/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextApiRequest, NextApiResponse } from "next";
import { withSessionRoute } from "../../../lib/zoom/with-session";
import { getDeeplink, getToken } from "../../../lib/zoom/zoom-api";

type Data = {
name?: string;
error?: string;
};

export default withSessionRoute(authRoute);

async function authRoute(req: NextApiRequest, res: NextApiResponse<Data>) {
req.session.state = null;

console.log('========auth req.session========', req.session)
try {
const code = req.query.code;
const verifier = req.session.verifier;

req.session.verifier = null;

// get Access Token from Zoom
const { access_token: accessToken } = await getToken(code, verifier);

// fetch deeplink from Zoom API
const deeplink = await getDeeplink(accessToken);

await req.session.save();
// redirect the user to the Zoom Client
res.redirect(deeplink);
} catch (e: any) {
res.status(500).send({ error: e.message });
}
}
17 changes: 17 additions & 0 deletions pages/api/zoom/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { NextApiRequest, NextApiResponse } from "next";
import { withSessionRoute } from "../../../lib/zoom/with-session";
import { getInstallURL } from "../../../lib/zoom/zoom-api";

export default withSessionRoute(installRoute);

interface Data {}

async function installRoute(req: NextApiRequest, res: NextApiResponse<Data>) {
const { url, state, verifier } = getInstallURL();

req.session.state = state;
req.session.verifier = verifier;
await req.session.save();

res.redirect(url.href);
}
Loading

1 comment on commit 159e511

@vercel
Copy link

@vercel vercel bot commented on 159e511 Sep 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.