diff --git a/README.md b/README.md index 498dcf1..d29323e 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,12 @@ To use Zoom Rivet effectively, you should understand three important concepts: a Zoom Rivet handles authentication for developers. All you have to do is provide your app's `ClientId` and `ClientSecret`. See the matrix in the table below to better how authentication works in each Rivet module: -| Module | Auth Type | -| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| Chatbot | [Client Credentials](https://developers.zoom.us/docs/team-chat-apps/installation-and-authentication/#authentication) | -| Video SDK | [JWT](https://developers.zoom.us/docs/video-sdk/api-request/) | -| Team Chat, Meetings, Phone, Accounts, Users | [User OAuth](https://developers.zoom.us/docs/integrations/), [Server OAuth](https://developers.zoom.us/docs/internal-apps/) | +| Module | Auth Type | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Chatbot | [Client Credentials](https://developers.zoom.us/docs/team-chat-apps/installation-and-authentication/#authentication) | +| Video SDK | [JWT](https://developers.zoom.us/docs/video-sdk/api-request/) | +| Commerce | [User OAuth](https://developers.zoom.us/docs/integrations/) | +| Accounts, Marketplace, Meetings, Phone, Team Chat, Users | [User OAuth](https://developers.zoom.us/docs/integrations/), [Server OAuth](https://developers.zoom.us/docs/internal-apps/) | ### Listening to Events diff --git a/accounts/accounts.cjs b/accounts/accounts.cjs index a30f182..256341b 100644 --- a/accounts/accounts.cjs +++ b/accounts/accounts.cjs @@ -426,6 +426,9 @@ class JwtStateStore { const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -452,7 +455,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -462,14 +468,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -581,9 +593,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -597,6 +606,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; } @@ -611,8 +633,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -629,69 +663,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, exports.StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -725,18 +774,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -745,86 +800,7 @@ class HttpReceiver { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -854,7 +830,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } @@ -1092,7 +1068,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -1113,7 +1091,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. diff --git a/accounts/accounts.d.ts b/accounts/accounts.d.ts index a8f146c..f791d6f 100644 --- a/accounts/accounts.d.ts +++ b/accounts/accounts.d.ts @@ -1,88 +1,8 @@ -import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { Server } from 'node:http'; import { ServerOptions } from 'node:https'; -type AllPropsOptional = Exclude<{ - [P in keyof T]: undefined extends T[P] ? True : False; -}[keyof T], undefined> extends True ? True : False; -type Constructor = new (...args: any[]) => T; -type MaybeArray = T | T[]; -type MaybePromise = T | Promise; -type StringIndexed = Record; - -/** - * {@link StateStore} defines methods for generating and verifying OAuth state. - * - * This interface is implemented internally for the default state store; however, - * it can also be implemented and passed to an OAuth client as well. - */ -interface StateStore { - /** - * Generate a new state string, which is directly appended to the OAuth `state` parameter. - */ - generateState(): MaybePromise; - /** - * Verify that the state received during OAuth callback is valid and not forged. - * - * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. - * - * @param state The state parameter that was received during OAuth callback - */ - verifyState(state: string): MaybePromise; -} -/** - * Guard if an object implements the {@link StateStore} interface — most notably, - * `generateState()` and `verifyState(state: string)`. - */ -declare const isStateStore: (obj: unknown) => obj is StateStore; - -interface TokenStore { - getLatestToken(): MaybePromise; - storeToken(token: Token): MaybePromise; -} - -interface RivetError extends Error { - readonly errorCode: ErrorCode; -} - -declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ - readonly ApiResponseError: "zoom_rivet_api_response_error"; - readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; - readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; - readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; - readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; - readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; - readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; - readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; - readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; - readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; - readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; - readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; - readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; - readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; - readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; - readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; - readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; -}[K]>; -declare const ApiResponseError: Constructor; -declare const AwsReceiverRequestError: Constructor; -declare const ClientCredentialsRawResponseError: Constructor; -declare const S2SRawResponseError: Constructor; -declare const CommonHttpRequestError: Constructor; -declare const ReceiverInconsistentStateError: Constructor; -declare const ReceiverOAuthFlowError: Constructor; -declare const HTTPReceiverConstructionError: Constructor; -declare const HTTPReceiverPortNotNumberError: Constructor; -declare const HTTPReceiverRequestError: Constructor; -declare const OAuthInstallerNotInitializedError: Constructor; -declare const OAuthTokenDoesNotExistError: Constructor; -declare const OAuthTokenFetchFailedError: Constructor; -declare const OAuthTokenRawResponseError: Constructor; -declare const OAuthTokenRefreshFailedError: Constructor; -declare const OAuthStateVerificationFailedError: Constructor; -declare const ProductClientConstructionError: Constructor; - declare enum LogLevel { ERROR = "error", WARN = "warn", @@ -142,6 +62,19 @@ declare class ConsoleLogger implements Logger { private static isMoreOrEqualSevere; } +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + interface AuthOptions { clientId: string; clientSecret: string; @@ -187,6 +120,17 @@ declare abstract class Auth { }>, "grant_type">): Promise; } +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} + interface S2SAuthToken { accessToken: string; expirationTimeIso: string; @@ -225,12 +169,31 @@ declare class EventManager { protected withContext, Context>(): ContextListener; } +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + interface HttpReceiverOptions extends Partial { endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; port?: number | string | undefined; - webhooksSecretToken: string; - logger?: Logger; - logLevel?: LogLevel; + webhooksSecretToken?: string | undefined; } type SecureServerOptions = { [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; @@ -243,10 +206,15 @@ declare class HttpReceiver implements Receiver { private logger; constructor(options: HttpReceiverOptions); canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; private getServerCreator; private hasEndpoint; private hasSecureOptions; init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; start(port?: number | string): Promise; stop(): Promise; private writeTemporaryRedirect; @@ -305,38 +273,68 @@ type CommonClientOptions = GetAuthOptions interface ClientReceiverOptions { receiver: R; } -type ClientConstructorOptions, R extends Receiver> = IsReceiverDisabled extends true ? O : O & (ClientReceiverOptions | HttpReceiverOptions); +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); type ExtractInstallerOptions = A extends InteractiveAuth ? [ ReturnType ] extends [true] ? WideInstallerOptions : object : object; type ExtractAuthTokenType = A extends Auth ? T : never; -type GenericClientOptions = CommonClientOptions; type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); -type IsReceiverDisabled> = [ - O["disableReceiver"] -] extends [true] ? true : false; type WideInstallerOptions = { installerOptions: InstallerOptions; }; declare abstract class ProductClient, ReceiverType extends Receiver> { private readonly auth; readonly endpoints: EndpointsType; - readonly webEventConsumer: EventProcessorType; + readonly webEventConsumer?: EventProcessorType | undefined; private readonly receiver?; constructor(options: ClientConstructorOptions); protected abstract initAuth(options: OptionsType): AuthType; protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; - protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; private initDefaultReceiver; - start(this: IsReceiverDisabled extends true ? never : this): Promise>; + start(): Promise>; } +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; +} +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; + +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} interface InstallerOptions { directInstall?: boolean | undefined; installPath?: string | undefined; redirectUri: string; redirectUriPath?: string | undefined; stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; } /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -350,36 +348,87 @@ interface InstallerOptions { */ declare abstract class InteractiveAuth extends Auth { installerOptions?: ReturnType; - getAuthorizationUrl(): Promise; + getAuthorizationUrl(): Promise; getFullRedirectUri(): string; - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }: InstallerOptions): { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { directInstall: boolean; installPath: string; redirectUri: string; redirectUriPath: string; stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; }; } -declare enum StatusCode { - OK = 200, - TEMPORARY_REDIRECT = 302, - BAD_REQUEST = 400, - NOT_FOUND = 404, - METHOD_NOT_ALLOWED = 405, - INTERNAL_SERVER_ERROR = 500 +/** + * Credentials for access token & refresh token, which are used to access Zoom's APIs. + * + * As access token is short-lived (usually a single hour), its expiration time is checked + * first. If it's possible to use the access token, it's used; however, if it has expired + * or is close to expiring, the refresh token should be used to generate a new access token + * before the API call is made. Refresh tokens are generally valid for 90 days. + * + * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} + * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. + * It's likely that this error will be rare, but it _can_ be thrown. + */ +interface OAuthToken { + accessToken: string; + expirationTimeIso: string; + refreshToken: string; + scopes: string[]; } -interface ReceiverInitOptions { - eventEmitter: GenericEventManager; - interactiveAuth?: InteractiveAuth | undefined; +declare class OAuth extends InteractiveAuth { + private assertResponseAccessToken; + private fetchAccessToken; + getToken(): Promise; + initRedirectCode(code: string): Promise; + private mapOAuthToken; + private refreshAccessToken; } -interface Receiver { - canInstall(): true | false; - init(options: ReceiverInitOptions): void; - start(...args: any[]): MaybePromise; - stop(...args: any[]): MaybePromise; + +interface RivetError extends Error { + readonly errorCode: ErrorCode; } +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + interface AwsLambdaReceiverOptions { webhooksSecretToken: string; } @@ -5615,33 +5664,6 @@ type AccountsEvents = AccountVanityUrlRejectedEvent | AccountCreatedEvent | Info declare class AccountsEventProcessor extends EventManager { } -/** - * Credentials for access token & refresh token, which are used to access Zoom's APIs. - * - * As access token is short-lived (usually a single hour), its expiration time is checked - * first. If it's possible to use the access token, it's used; however, if it has expired - * or is close to expiring, the refresh token should be used to generate a new access token - * before the API call is made. Refresh tokens are generally valid for 90 days. - * - * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} - * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. - * It's likely that this error will be rare, but it _can_ be thrown. - */ -interface OAuthToken { - accessToken: string; - expirationTimeIso: string; - refreshToken: string; - scopes: string[]; -} -declare class OAuth extends InteractiveAuth { - private assertResponseAccessToken; - private fetchAccessToken; - getToken(): Promise; - initRedirectCode(code: string): Promise; - private mapOAuthToken; - private refreshAccessToken; -} - type AccountsOptions = CommonClientOptions; declare class AccountsOAuthClient = AccountsOptions> extends ProductClient { protected initAuth({ clientId, clientSecret, tokenStore, ...restOptions }: OptionsType): OAuth; @@ -5656,4 +5678,4 @@ declare class AccountsS2SAuthClient typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -450,7 +453,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -460,14 +466,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -579,9 +591,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -595,6 +604,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? createServer : createServer$1; } @@ -609,8 +631,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -627,69 +661,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -723,18 +772,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -743,86 +798,7 @@ class HttpReceiver { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -852,7 +828,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } @@ -1090,7 +1066,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -1111,7 +1089,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. diff --git a/chatbot/chatbot.cjs b/chatbot/chatbot.cjs index d582ad4..a2cc767 100644 --- a/chatbot/chatbot.cjs +++ b/chatbot/chatbot.cjs @@ -426,6 +426,9 @@ class JwtStateStore { const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -450,7 +453,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -460,14 +466,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -579,9 +591,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -595,6 +604,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; } @@ -609,8 +631,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -627,69 +661,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, exports.StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -723,18 +772,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -794,7 +849,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -815,7 +872,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. @@ -823,86 +880,7 @@ class ProductClient { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -932,7 +910,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } diff --git a/chatbot/chatbot.d.ts b/chatbot/chatbot.d.ts index 1a8c162..8f527f8 100644 --- a/chatbot/chatbot.d.ts +++ b/chatbot/chatbot.d.ts @@ -1,88 +1,8 @@ -import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { Server } from 'node:http'; import { ServerOptions } from 'node:https'; -type AllPropsOptional = Exclude<{ - [P in keyof T]: undefined extends T[P] ? True : False; -}[keyof T], undefined> extends True ? True : False; -type Constructor = new (...args: any[]) => T; -type MaybeArray = T | T[]; -type MaybePromise = T | Promise; -type StringIndexed = Record; - -/** - * {@link StateStore} defines methods for generating and verifying OAuth state. - * - * This interface is implemented internally for the default state store; however, - * it can also be implemented and passed to an OAuth client as well. - */ -interface StateStore { - /** - * Generate a new state string, which is directly appended to the OAuth `state` parameter. - */ - generateState(): MaybePromise; - /** - * Verify that the state received during OAuth callback is valid and not forged. - * - * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. - * - * @param state The state parameter that was received during OAuth callback - */ - verifyState(state: string): MaybePromise; -} -/** - * Guard if an object implements the {@link StateStore} interface — most notably, - * `generateState()` and `verifyState(state: string)`. - */ -declare const isStateStore: (obj: unknown) => obj is StateStore; - -interface TokenStore { - getLatestToken(): MaybePromise; - storeToken(token: Token): MaybePromise; -} - -interface RivetError extends Error { - readonly errorCode: ErrorCode; -} - -declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ - readonly ApiResponseError: "zoom_rivet_api_response_error"; - readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; - readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; - readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; - readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; - readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; - readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; - readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; - readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; - readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; - readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; - readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; - readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; - readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; - readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; - readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; - readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; -}[K]>; -declare const ApiResponseError: Constructor; -declare const AwsReceiverRequestError: Constructor; -declare const ClientCredentialsRawResponseError: Constructor; -declare const S2SRawResponseError: Constructor; -declare const CommonHttpRequestError: Constructor; -declare const ReceiverInconsistentStateError: Constructor; -declare const ReceiverOAuthFlowError: Constructor; -declare const HTTPReceiverConstructionError: Constructor; -declare const HTTPReceiverPortNotNumberError: Constructor; -declare const HTTPReceiverRequestError: Constructor; -declare const OAuthInstallerNotInitializedError: Constructor; -declare const OAuthTokenDoesNotExistError: Constructor; -declare const OAuthTokenFetchFailedError: Constructor; -declare const OAuthTokenRawResponseError: Constructor; -declare const OAuthTokenRefreshFailedError: Constructor; -declare const OAuthStateVerificationFailedError: Constructor; -declare const ProductClientConstructionError: Constructor; - declare enum LogLevel { ERROR = "error", WARN = "warn", @@ -142,6 +62,19 @@ declare class ConsoleLogger implements Logger { private static isMoreOrEqualSevere; } +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + interface AuthOptions { clientId: string; clientSecret: string; @@ -187,6 +120,23 @@ declare abstract class Auth { }>, "grant_type">): Promise; } +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} +declare class ClientCredentialsAuth extends Auth { + private assertRawToken; + private fetchClientCredentials; + getToken(): Promise; + private mapClientCredentials; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} + interface S2SAuthToken { accessToken: string; expirationTimeIso: string; @@ -225,12 +175,31 @@ declare class EventManager { protected withContext, Context>(): ContextListener; } +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + interface HttpReceiverOptions extends Partial { endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; port?: number | string | undefined; - webhooksSecretToken: string; - logger?: Logger; - logLevel?: LogLevel; + webhooksSecretToken?: string | undefined; } type SecureServerOptions = { [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; @@ -243,10 +212,15 @@ declare class HttpReceiver implements Receiver { private logger; constructor(options: HttpReceiverOptions); canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; private getServerCreator; private hasEndpoint; private hasSecureOptions; init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; start(port?: number | string): Promise; stop(): Promise; private writeTemporaryRedirect; @@ -305,38 +279,68 @@ type CommonClientOptions = GetAuthOptions interface ClientReceiverOptions { receiver: R; } -type ClientConstructorOptions, R extends Receiver> = IsReceiverDisabled extends true ? O : O & (ClientReceiverOptions | HttpReceiverOptions); +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); type ExtractInstallerOptions = A extends InteractiveAuth ? [ ReturnType ] extends [true] ? WideInstallerOptions : object : object; type ExtractAuthTokenType = A extends Auth ? T : never; -type GenericClientOptions = CommonClientOptions; type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); -type IsReceiverDisabled> = [ - O["disableReceiver"] -] extends [true] ? true : false; type WideInstallerOptions = { installerOptions: InstallerOptions; }; declare abstract class ProductClient, ReceiverType extends Receiver> { private readonly auth; readonly endpoints: EndpointsType; - readonly webEventConsumer: EventProcessorType; + readonly webEventConsumer?: EventProcessorType | undefined; private readonly receiver?; constructor(options: ClientConstructorOptions); protected abstract initAuth(options: OptionsType): AuthType; protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; - protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; private initDefaultReceiver; - start(this: IsReceiverDisabled extends true ? never : this): Promise>; + start(): Promise>; +} + +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; } +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} interface InstallerOptions { directInstall?: boolean | undefined; installPath?: string | undefined; redirectUri: string; redirectUriPath?: string | undefined; stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; } /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -350,36 +354,79 @@ interface InstallerOptions { */ declare abstract class InteractiveAuth extends Auth { installerOptions?: ReturnType; - getAuthorizationUrl(): Promise; + getAuthorizationUrl(): Promise; getFullRedirectUri(): string; - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }: InstallerOptions): { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { directInstall: boolean; installPath: string; redirectUri: string; redirectUriPath: string; stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; }; } -declare enum StatusCode { - OK = 200, - TEMPORARY_REDIRECT = 302, - BAD_REQUEST = 400, - NOT_FOUND = 404, - METHOD_NOT_ALLOWED = 405, - INTERNAL_SERVER_ERROR = 500 -} -interface ReceiverInitOptions { - eventEmitter: GenericEventManager; - interactiveAuth?: InteractiveAuth | undefined; +/** + * Credentials for access token & refresh token, which are used to access Zoom's APIs. + * + * As access token is short-lived (usually a single hour), its expiration time is checked + * first. If it's possible to use the access token, it's used; however, if it has expired + * or is close to expiring, the refresh token should be used to generate a new access token + * before the API call is made. Refresh tokens are generally valid for 90 days. + * + * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} + * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. + * It's likely that this error will be rare, but it _can_ be thrown. + */ +interface OAuthToken { + accessToken: string; + expirationTimeIso: string; + refreshToken: string; + scopes: string[]; } -interface Receiver { - canInstall(): true | false; - init(options: ReceiverInitOptions): void; - start(...args: any[]): MaybePromise; - stop(...args: any[]): MaybePromise; + +interface RivetError extends Error { + readonly errorCode: ErrorCode; } +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + interface AwsLambdaReceiverOptions { webhooksSecretToken: string; } @@ -394,18 +441,6 @@ declare class AwsLambdaReceiver implements Receiver { stop(): Promise; } -interface ClientCredentialsToken { - accessToken: string; - expirationTimeIso: string; - scopes: string[]; -} -declare class ClientCredentialsAuth extends Auth { - private assertRawToken; - private fetchClientCredentials; - getToken(): Promise; - private mapClientCredentials; -} - interface CardContent { settings?: any; head?: Header; @@ -848,4 +883,4 @@ declare class ChatbotClient MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -577,9 +589,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -593,6 +602,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? createServer : createServer$1; } @@ -607,8 +629,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -625,69 +659,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -721,18 +770,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -792,7 +847,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -813,7 +870,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. @@ -821,86 +878,7 @@ class ProductClient { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -930,7 +908,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } diff --git a/commerce/commerce.cjs b/commerce/commerce.cjs new file mode 100644 index 0000000..69447c5 --- /dev/null +++ b/commerce/commerce.cjs @@ -0,0 +1,1053 @@ +'use strict'; + +var node_crypto = require('node:crypto'); +var node_http = require('node:http'); +var node_https = require('node:https'); +var axios = require('axios'); +var dayjs = require('dayjs'); +var node_buffer = require('node:buffer'); +var jose = require('jose'); +var FormData = require('form-data'); +var os = require('node:os'); +var node_path = require('node:path'); + +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +const isStateStore = (obj) => typeof obj.generateState === "function" && typeof obj.verifyState === "function"; + +const createRivetErrors = (errors) => ({ + createError: (errorCode) => class extends Error { + errorCode = errors[errorCode]; + constructor(message, options) { + const errorMessage = (message ?? + (options?.cause instanceof Error ? options.cause.message : errorCode)); + super(errorMessage, options); + this.name = errorCode; + Object.setPrototypeOf(this, new.target.prototype); + } + }, + isError: (obj, key) => key ? + Object.keys(errors).some((code) => code === key) && + typeof obj.errorCode === "string" && + obj.errorCode === errors[key] + : typeof obj.errorCode === "string" +}); + +const coreErrors = { + ApiResponseError: "zoom_rivet_api_response_error", + AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error", + ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error", + S2SRawResponseError: "zoom_rivet_s2s_raw_response_error", + CommonHttpRequestError: "zoom_rivet_common_http_request_error", + ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error", + ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error", + HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error", + HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error", + HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error", + OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error", + OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error", + OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error", + OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error", + OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error", + OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error", + ProductClientConstructionError: "zoom_rivet_product_client_construction_error" +}; +const { createError: createCoreError, isError: isCoreError } = createRivetErrors(coreErrors); +const ApiResponseError = createCoreError("ApiResponseError"); +const AwsReceiverRequestError = createCoreError("AwsReceiverRequestError"); +const ClientCredentialsRawResponseError = createCoreError("ClientCredentialsRawResponseError"); +const S2SRawResponseError = createCoreError("S2SRawResponseError"); +const CommonHttpRequestError = createCoreError("CommonHttpRequestError"); +const ReceiverInconsistentStateError = createCoreError("ReceiverInconsistentStateError"); +const ReceiverOAuthFlowError = createCoreError("ReceiverOAuthFlowError"); +const HTTPReceiverConstructionError = createCoreError("HTTPReceiverConstructionError"); +const HTTPReceiverPortNotNumberError = createCoreError("HTTPReceiverPortNotNumberError"); +const HTTPReceiverRequestError = createCoreError("HTTPReceiverRequestError"); +const OAuthInstallerNotInitializedError = createCoreError("OAuthInstallerNotInitializedError"); +const OAuthTokenDoesNotExistError = createCoreError("OAuthTokenDoesNotExistError"); +const OAuthTokenFetchFailedError = createCoreError("OAuthTokenFetchFailedError"); +const OAuthTokenRawResponseError = createCoreError("OAuthTokenRawResponseError"); +const OAuthTokenRefreshFailedError = createCoreError("OAuthTokenRefreshFailedError"); +const OAuthStateVerificationFailedError = createCoreError("OAuthStateVerificationFailedError"); +const ProductClientConstructionError = createCoreError("ProductClientConstructionError"); + +exports.LogLevel = void 0; +(function (LogLevel) { + LogLevel["ERROR"] = "error"; + LogLevel["WARN"] = "warn"; + LogLevel["INFO"] = "info"; + LogLevel["DEBUG"] = "debug"; +})(exports.LogLevel || (exports.LogLevel = {})); +class ConsoleLogger { + level; + name; + static labels = (() => { + const entries = Object.entries(exports.LogLevel); + const map = entries.map(([key, value]) => [value, `[${key}] `]); + return new Map(map); + })(); + static severity = { + [exports.LogLevel.ERROR]: 400, + [exports.LogLevel.WARN]: 300, + [exports.LogLevel.INFO]: 200, + [exports.LogLevel.DEBUG]: 100 + }; + constructor() { + this.level = exports.LogLevel.INFO; + this.name = ""; + } + getLevel() { + return this.level; + } + setLevel(level) { + this.level = level; + } + setName(name) { + this.name = name; + } + debug(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(exports.LogLevel.DEBUG, this.level)) { + console.debug(ConsoleLogger.labels.get(exports.LogLevel.DEBUG), this.name, ...msg); + } + } + info(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(exports.LogLevel.INFO, this.level)) { + console.info(ConsoleLogger.labels.get(exports.LogLevel.INFO), this.name, ...msg); + } + } + warn(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(exports.LogLevel.WARN, this.level)) { + console.warn(ConsoleLogger.labels.get(exports.LogLevel.WARN), this.name, ...msg); + } + } + error(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(exports.LogLevel.ERROR, this.level)) { + console.error(ConsoleLogger.labels.get(exports.LogLevel.ERROR), this.name, ...msg); + } + } + static isMoreOrEqualSevere(a, b) { + return ConsoleLogger.severity[a] >= ConsoleLogger.severity[b]; + } +} + +/** @internal */ +const hashUrlValidationEvent = ({ payload: { plainToken } }, webhooksSecretToken) => ({ + encryptedToken: node_crypto.createHmac("sha256", webhooksSecretToken).update(plainToken).digest("hex"), + plainToken +}); +const isHashedUrlValidation = (obj) => typeof obj.encryptedToken === "string" && + typeof obj.plainToken === "string"; +const isRawUrlValidationEvent = (obj) => obj.event === "endpoint.url_validation" && typeof obj.payload.plainToken === "string"; +const isSkeletonEvent = (obj) => typeof obj.event === "string"; +class CommonHttpRequest { + headers; + payload; + webhooksSecretToken; + constructor(headers, payload, webhooksSecretToken) { + this.headers = headers; + this.payload = payload; + this.webhooksSecretToken = webhooksSecretToken; + } + static buildFromAwsEvent({ body, headers, isBase64Encoded }, webhooksSecretToken) { + try { + const rawBody = body ?? ""; + const decodedBody = isBase64Encoded ? Buffer.from(rawBody, "base64").toString("ascii") : rawBody; + const payload = JSON.parse(decodedBody); + return new CommonHttpRequest(headers, payload, webhooksSecretToken); + } + catch (err) { + throw err instanceof SyntaxError ? + new CommonHttpRequestError("Failed to parse payload string to JSON.", err) + : err; + } + } + static async buildFromIncomingMessage(incomingMessage, webhooksSecretToken) { + const bufferAsString = () => { + return new Promise((resolve, reject) => { + const body = []; + incomingMessage.on("data", (chunk) => body.push(chunk)); + incomingMessage.on("error", (err) => { + reject(err); + }); + incomingMessage.on("end", () => { + resolve(Buffer.concat(body).toString()); + }); + }); + }; + try { + const payload = JSON.parse(await bufferAsString()); + return new CommonHttpRequest(incomingMessage.headers, payload, webhooksSecretToken); + } + catch (err) { + if (err instanceof SyntaxError) { + throw new CommonHttpRequestError("Failed to parse payload string to JSON.", err); + } + throw err; + } + } + isEventVerified() { + const { signature, requestTimestamp } = this.parseHeaders(); + const messageToVerify = `v0:${requestTimestamp.toString()}:${JSON.stringify(this.payload)}`; + const hashToVerify = node_crypto.createHmac("sha256", this.webhooksSecretToken).update(messageToVerify).digest("hex"); + const signatureToVerify = `v0=${hashToVerify}`; + return signatureToVerify === signature; + } + parseHeaders() { + const findHeader = (header) => { + const foundHeader = Object.keys(this.headers).find((key) => key.toLowerCase() === header.toLowerCase()); + return foundHeader && this.headers[foundHeader]; + }; + const headerSignature = findHeader("x-zm-signature"); + const headerRequestTimestamp = findHeader("x-zm-request-timestamp"); + if (!headerSignature && !headerRequestTimestamp) { + throw new CommonHttpRequestError("Request payload must have signature and request timestamp from Zoom."); + } + return { + signature: headerSignature, + requestTimestamp: Number(headerRequestTimestamp) + }; + } + processEvent() { + if (!isSkeletonEvent(this.payload)) { + throw new CommonHttpRequestError("Request payload structure does not match expected from Zoom."); + } + if (!this.isEventVerified()) { + throw new CommonHttpRequestError("Failed to verify event originated from Zoom."); + } + if (isRawUrlValidationEvent(this.payload)) { + return hashUrlValidationEvent(this.payload, this.webhooksSecretToken); + } + return this.payload; + } +} + +exports.StatusCode = void 0; +(function (StatusCode) { + StatusCode[StatusCode["OK"] = 200] = "OK"; + StatusCode[StatusCode["TEMPORARY_REDIRECT"] = 302] = "TEMPORARY_REDIRECT"; + StatusCode[StatusCode["BAD_REQUEST"] = 400] = "BAD_REQUEST"; + StatusCode[StatusCode["NOT_FOUND"] = 404] = "NOT_FOUND"; + StatusCode[StatusCode["METHOD_NOT_ALLOWED"] = 405] = "METHOD_NOT_ALLOWED"; + StatusCode[StatusCode["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR"; +})(exports.StatusCode || (exports.StatusCode = {})); + +class AwsLambdaReceiver { + eventEmitter; + webhooksSecretToken; + constructor({ webhooksSecretToken }) { + this.webhooksSecretToken = webhooksSecretToken; + } + buildResponse(statusCode, body) { + return { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + statusCode + }; + } + canInstall() { + return false; + } + init({ eventEmitter }) { + this.eventEmitter = eventEmitter; + } + start() { + return async (event, context) => { + console.debug("Processing Lambda event ", JSON.stringify(event), " with context ", JSON.stringify(context)); + try { + const request = CommonHttpRequest.buildFromAwsEvent(event, this.webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + return this.buildResponse(exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + return this.buildResponse(exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } + } + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + return this.buildResponse(exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + return this.buildResponse(exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } + } + }; + } + async stop() { + return Promise.resolve(); + } +} + +const prependSlashes = (strs) => { + const rawStrs = Array.isArray(strs) ? strs : [strs]; + const mappedStrs = rawStrs.map((rawStr) => (rawStr.startsWith("/") ? rawStr : `/${rawStr}`)); + return (Array.isArray(strs) ? mappedStrs : mappedStrs[0]); +}; + +class TokenMemoryStore { + currentToken; + getLatestToken() { + return this.currentToken; + } + storeToken(token) { + this.currentToken = token; + } +} + +/** @internal */ +const EXPIRATION_DELTA_SECONDS = 60; +/** @internal */ +const OAUTH_BASE_URL = "https://zoom.us"; +/** @internal */ +const OAUTH_TOKEN_PATH = "/oauth/token"; +/** + * {@link Auth} is the base implementation of authentication for Zoom's APIs. + * + * It only requires a `clientId` and `tokenStore`, as these options are shared across + * all authentication implementations, namely OAuth and server-to-server auth (client + * credentials, JWT, and server-to-server OAuth.) + */ +class Auth { + clientId; + clientSecret; + tokenStore; + logger; + constructor({ clientId, clientSecret, tokenStore, logger }) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.tokenStore = tokenStore ?? new TokenMemoryStore(); + this.logger = logger; + } + getBasicAuthorization() { + const clientCredentials = `${this.clientId}:${this.clientSecret}`; + return node_buffer.Buffer.from(clientCredentials).toString("base64"); + } + isAlmostExpired(isoTime) { + const currentDate = dayjs(); + return dayjs(isoTime).diff(currentDate, "seconds") <= EXPIRATION_DELTA_SECONDS; + } + async makeOAuthTokenRequest(grantType, payload) { + return await axios({ + method: "POST", + url: new URL(OAUTH_TOKEN_PATH, OAUTH_BASE_URL).toString(), + headers: { + Authorization: `Basic ${this.getBasicAuthorization()}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + data: new URLSearchParams({ grant_type: grantType, ...payload }), + validateStatus: (status) => status >= 200 && status <= 299 + }); + } +} + +const DEFAULT_EXPIRATION_SECONDS = 300; // 5 minutes +/** @internal */ +const ISSUER_URN = "urn:zoom:rivet-sdk"; +class JwtStateStore { + encodedSecret; + expirationSeconds; + constructor({ expirationSeconds, stateSecret }) { + this.encodedSecret = new TextEncoder().encode(stateSecret); + this.expirationSeconds = expirationSeconds ?? DEFAULT_EXPIRATION_SECONDS; + } + async generateState() { + const issuedTime = dayjs(); + const expirationTime = issuedTime.add(this.expirationSeconds, "seconds"); + return await new jose.SignJWT({ random: node_crypto.randomBytes(8).toString("hex") }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setExpirationTime(expirationTime.toDate()) + .setIssuedAt(issuedTime.toDate()) + .setIssuer(ISSUER_URN) + .sign(this.encodedSecret); + } + async verifyState(state) { + try { + await jose.jwtVerify(state, this.encodedSecret, { + algorithms: ["HS256"], + issuer: ISSUER_URN, + typ: "JWT" + }); + } + catch (err) { + throw new OAuthStateVerificationFailedError(`Failed to verify OAuth state: ${err.name}.`, { + cause: err + }); + } + } +} + +const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; +const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds +const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; +const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && + typeof obj.installerOptions.stateStore !== "undefined"; +/** + * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication + * is initiated server-side, but requires manual authorization from a user, by redirecting the user to Zoom. + * + * In addition to all required fields from {@link AuthOptions}, this class requires a `redirectUri`, as this + * value is appended to the authorization URL when the user is redirected to Zoom and subsequently redirected + * back to an endpoint on this server. + * + * @see {@link https://developers.zoom.us/docs/integrations/oauth/ | OAuth - Zoom Developers} + */ +class InteractiveAuth extends Auth { + installerOptions; + async getAuthorizationUrl() { + if (!this.installerOptions?.stateStore) { + throw new OAuthInstallerNotInitializedError("Cannot generate authorization URL, state store not initialized."); + } + const authUrl = new URL(OAUTH_AUTHORIZE_PATH, OAUTH_BASE_URL); + const generatedState = await Promise.resolve(this.installerOptions.stateStore.generateState()); + const { searchParams } = authUrl; + searchParams.set("client_id", this.clientId); + searchParams.set("redirect_uri", this.getFullRedirectUri()); + searchParams.set("response_type", "code"); + searchParams.set("state", generatedState); + return { + fullUrl: authUrl.toString(), + generatedState + }; + } + getFullRedirectUri() { + if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { + throw new OAuthInstallerNotInitializedError("Cannot generate full redirect URI, redirect URI or redirect URI path not initialized."); + } + return new URL(this.installerOptions.redirectUriPath, this.installerOptions.redirectUri).toString(); + } + // Don't return a type; we want it to be as narrow as possible (used for ReturnType). + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { + const updatedOptions = { + directInstall: Boolean(directInstall), + installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, + redirectUri, + redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE + }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } + this.installerOptions = updatedOptions; + return updatedOptions; + } +} + +const mergeDefaultOptions = (options, defaultOptions) => ({ ...defaultOptions, ...options }); + +const withDefaultTemplate = (cardContent, buttonContent) => ` + + + Zoom Rivet + + + + + + + + + +`; +/** + * Get the default HTML template that is shown to the developer/user when they visit the + * `installPath` endpoint, if Rivet currently has OAuth enabled. + * + * If `directInstall` is set to `true`, this function is not called; instead, the developer + * is directly redirected to Zoom's OAuth page. + */ +const defaultInstallTemplate = (authUrl) => withDefaultTemplate(`

Click the button below to navigate to Zoom to authorize your application for use with Rivet.

`, { href: authUrl, text: "Authorize with Zoom" }); +/** + * Get the default HTML template that is shown to the developer/user when they successfully + * authorize Rivet with a Zoom application. This is shown once they have already been redirected + * to Zoom, and the authorization attempt with Rivet was successful. + */ +const defaultCallbackSuccessTemplate = () => withDefaultTemplate(`

Your application has been successfully authorized with Rivet!

+

You may now close this page, or click the button below to redirect to Zoom's Marketplace.

`, { href: "https://marketplace.zoom.us", text: "Go to Marketplace" }); +/** + * Get the default HTML template that is shown to the developer when a known error occurs, meaning + * that the error is a core Rivet error. + */ +const defaultCallbackKnownErrorTemplate = (errName, errMessage) => withDefaultTemplate(`

An error occurred authorizing Rivet with Zoom.

+

[${errName}]: ${errMessage}

`); +/** + * Get the default HTML template that is shown to the developer when an unknown error occurs, + * meaning that the error is not known to be a core Rivet error and was thrown and not wrapped elsewhere. + */ +const defaultCallbackUnknownErrorTemplate = () => withDefaultTemplate(`

An unknown error occurred authorizing Rivet with Zoom. Please see stacktrace for details.

+

Please see stacktrace for further details.

`); + +const secureServerOptionKeys = [ + "ALPNProtocols", + "clientCertEngine", + "enableTrace", + "handshakeTimeout", + "rejectUnauthorized", + "requestCert", + "sessionTimeout", + "SNICallback", + "ticketKeys", + "pskCallback", + "pskIdentityHint", + "ca", + "cert", + "sigalgs", + "ciphers", + "clientCertEngine", + "crl", + "dhparam", + "ecdhCurve", + "honorCipherOrder", + "key", + "privateKeyEngine", + "privateKeyIdentifier", + "maxVersion", + "minVersion", + "passphrase", + "pfx", + "secureOptions", + "secureProtocol", + "sessionIdContext" +]; +class HttpReceiver { + /** @internal */ + static DEFAULT_ENDPOINT = "/zoom/events"; + eventEmitter; + interactiveAuth; + /** @internal */ + options; + server; + logger; + constructor(options) { + this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); + this.options.endpoints = prependSlashes(this.options.endpoints); + this.logger = + options.logger ?? + (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(options.logLevel ?? exports.LogLevel.ERROR); + return defaultLogger; + })(); + } + canInstall() { + return true; + } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } + getServerCreator() { + return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; + } + hasEndpoint(pathname) { + const { endpoints } = this.options; + return Array.isArray(endpoints) ? endpoints.includes(pathname) : endpoints === pathname; + } + hasSecureOptions() { + return Object.keys(this.options).some((option) => secureServerOptionKeys.includes(option)); + } + init({ eventEmitter, interactiveAuth }) { + this.eventEmitter = eventEmitter; + this.interactiveAuth = interactiveAuth; + } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } + start(port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { + const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; + this.logger.error(errorMessage); + throw new HTTPReceiverPortNotNumberError(errorMessage); + } + const listenPort = port ?? this.options.port; + return new Promise((resolve, reject) => { + this.server = this.getServerCreator()(this.options, (req, res) => void (async () => { + // `req.headers.host` should be used with care, as clients can manipulate this value. + // However, for this use case, the value is completely discarded and only `pathname` + // is used, which is why there's no further validation occurring. + const { pathname, searchParams } = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`); + const { interactiveAuth } = this; + this.logger.debug([pathname, searchParams]); + // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath + if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { + const { installerOptions } = interactiveAuth; + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); + await (installerOptions.directInstall ? + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); + return; + } + // The user has navigated to the redirect page; init the code + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); + try { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { + const errorMessage = "OAuth callback did not include code and/or state in request."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); + return; + } + catch (err) { + const htmlTemplate = isCoreError(err) ? + defaultCallbackKnownErrorTemplate(err.name, err.message) + : defaultCallbackUnknownErrorTemplate(); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); + return; + } + } + } + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; + } + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; + } + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } + } + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } + } + } + })()); + this.server.on("close", () => (this.server = undefined)); + this.server.on("error", (err) => { + this.logger.error(err.message); + reject(err); + }); + this.server.listen(listenPort, () => { + if (!this.server) { + throw new ReceiverInconsistentStateError(); + } + const { port: listeningPort } = this.server.address(); + this.logger.info(`Listening on port ${listeningPort.toString()}`); + resolve(this.server); + }); + }); + } + stop() { + if (!this.server) { + throw new ReceiverInconsistentStateError(); + } + return new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) { + this.logger.error(err.message); + reject(err); + } + }); + this.server = undefined; + resolve(); + }); + } + writeTemporaryRedirect(res, location, setCookie) { + return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } + res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); + res.end(() => { + resolve(); + }); + }); + } + writeResponse(res, statusCode, bodyContent, setCookie) { + return new Promise((resolve) => { + const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; + bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } + res.writeHead(statusCode, { "Content-Type": mimeType }); + res.end(bodyContent, () => { + resolve(); + }); + }); + } +} + +const version = "0.3.0"; + +class WebEndpoints { + /** @internal */ + static DEFAULT_BASE_URL = "https://api.zoom.us/v2"; + /** @internal */ + static DEFAULT_MIME_TYPE = "application/json"; + /** @internal */ + static DEFAULT_TIMEOUT = 0; + /** @internal */ + static GENERIC_ERROR_MESSAGE = "Request was unsuccessful with no further context"; + /** @internal */ + static TRACKING_ID_HEADER = "x-zm-trackingid"; + /** @internal */ + options; + constructor(options) { + this.options = mergeDefaultOptions(options, { + baseUrl: WebEndpoints.DEFAULT_BASE_URL, + hasCustomBaseUrl: typeof options.baseUrl !== "undefined", + timeout: WebEndpoints.DEFAULT_TIMEOUT + }); + } + buildEndpoint({ method, baseUrlOverride, urlPathBuilder, requestMimeType }) { + // @ts-expect-error: Some arguments may not be present, but we pass them to makeRequest() anyway. + // prettier-ignore + // Next AST node is ignored by Prettier, even though it exceed maximum line length, because TypeScript + // won't allow ts-expect-error directive on multiple lines (https://github.com/Microsoft/TypeScript/issues/19573). + return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); + } + buildUserAgent() { + return (`rivet/${version} ` + + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + + `${os.platform()}/${os.release()}`); + } + getHeaders(bearerToken, contentType) { + return { + Accept: "application/json", + Authorization: `Bearer ${bearerToken}`, + "Content-Type": contentType, + "User-Agent": this.buildUserAgent() + }; + } + getRequestBody(args, mimeType) { + if (mimeType === "multipart/form-data") { + const formData = new FormData(); + Object.entries(args).forEach(([key, value]) => { + formData.append(key, value); + }); + return formData; + } + return args; + } + isOk(response) { + return response.status >= 200 && response.status <= 299; + } + isZoomResponseError(obj) { + return (typeof obj.code !== "undefined" && + typeof obj.message !== "undefined"); + } + async makeRequest(method, baseUrlOverride, url, requestContentType, bodyArgs, queryArgs) { + const { auth, baseUrl, doubleEncodeUrl, hasCustomBaseUrl, timeout } = this.options; + const bearerToken = await Promise.resolve(auth.getToken()); + const urlToSend = doubleEncodeUrl ? encodeURIComponent(encodeURIComponent(url)) : url; + const response = await axios({ + url: urlToSend, + method, + baseURL: hasCustomBaseUrl ? baseUrl : (baseUrlOverride ?? baseUrl), + headers: this.getHeaders(bearerToken, requestContentType), + params: queryArgs, + data: bodyArgs && this.getRequestBody(bodyArgs, requestContentType), + timeout: timeout, + beforeRedirect: (options) => { + options.headers = { + ...this.getHeaders(bearerToken, requestContentType), + ...options.headers + }; + }, + validateStatus: () => true // All responses are valid, not just 2xx + }); + if (!this.isOk(response)) { + const { status: statusCode } = response; + if (this.isZoomResponseError(response.data)) { + const { code: errorCode, message: errorMessage } = response.data; + throw new ApiResponseError(`[${statusCode.toString()}/${errorCode.toString()}]: "${errorMessage}"`); + } + throw new ApiResponseError(`[${statusCode.toString()}]: ${WebEndpoints.GENERIC_ERROR_MESSAGE}`); + } + return { + data: response.data, + statusCode: response.status, + trackingId: response.headers[WebEndpoints.TRACKING_ID_HEADER] + }; + } +} + +class CommerceEndpoints extends WebEndpoints { + accountManagement = { + createEndCustomerAccount: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/account` }), + addContactsToExistingEndCustomerOrYourOwnAccount: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ accountKey }) => `/commerce/account/${accountKey}/contacts` }), + getsListOfAllAccountsAssociatedWithZoomPartnerSubResellerByAccountType: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/accounts` }), + getAccountDetailsForZoomPartnerSubResellerEndCustomer: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ accountKey }) => `/commerce/accounts/${accountKey}` }) + }; + billing = { + getsAllBillingDocumentsForDistributorOrReseller: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/billing_documents` }), + getsPDFDocumentForBillingDocumentID: this.buildEndpoint({ + method: "GET", + urlPathBuilder: ({ documentNumber }) => `/commerce/billing_documents/${documentNumber}/document` + }), + getDetailedInformationAboutSpecificInvoiceForDistributorOrReseller: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ invoiceNumber }) => `/commerce/invoices/${invoiceNumber}` }) + }; + dealRegistration = { + retrievesAllValidZoomCampaignsWhichDealRegistrationCanBeAssociatedWith: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/campaigns` }), + createsNewDealRegistrationForPartner: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/deal_registration` }), + getsAllValidDealRegistrationsForPartner: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/deal_registrations` }), + getsDetailsForDealRegistrationByDealRegistrationNumber: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ dealRegKey }) => `/commerce/deal_registrations/${dealRegKey}` }), + updatesExistingDealRegistration: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ dealRegKey }) => `/commerce/deal_registrations/${dealRegKey}` }) + }; + order = { + createsSubscriptionOrderForZoomPartner: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/order` }), + previewDeltaOrderMetricsAndSubscriptionsInOrder: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/order/preview` }), + getsAllOrdersForZoomPartner: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/orders` }), + getsOrderDetailsByOrderReferenceID: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ orderReferenceId }) => `/commerce/orders/${orderReferenceId}` }) + }; + productCatalog = { + getsZoomProductCatalogForZoomPartner: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/catalog` }), + getsDetailsForZoomProductOrOffer: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ offerId }) => `/commerce/catalog/${offerId}` }), + getsPricebookInDownloadableFile: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/pricebooks` }) + }; + subscription = { + getsSubscriptionsForZoomPartner: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/subscriptions` }), + getsSubscriptionDetailsForGivenSubscriptionNumber: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ subscriptionNumber }) => `/commerce/subscriptions/${subscriptionNumber}` }), + getsSubscriptionChangesVersionsForGivenSubscriptionNumber: this.buildEndpoint({ + method: "GET", + urlPathBuilder: ({ subscriptionNumber }) => `/commerce/subscriptions/${subscriptionNumber}/versions` + }) + }; +} + +class OAuth extends InteractiveAuth { + assertResponseAccessToken(data) { + if (typeof data.access_token !== "string" || + typeof data.refresh_token !== "string" || + typeof data.expires_in !== "number" || + typeof data.scope !== "string") { + throw new OAuthTokenRawResponseError(`Failed to match raw response (${JSON.stringify(data)}) to expected shape.`); + } + } + async fetchAccessToken(code) { + try { + const response = await this.makeOAuthTokenRequest("authorization_code", { + code, + redirect_uri: this.getFullRedirectUri() + }); + this.assertResponseAccessToken(response.data); + return this.mapOAuthToken(response.data); + } + catch (err) { + throw new OAuthTokenFetchFailedError("Failed to fetch OAuth token.", { cause: err }); + } + } + async getToken() { + const { tokenStore } = this; + const currentToken = await Promise.resolve(tokenStore.getLatestToken()); + // If we have no OAuth token, app most likely has not been previously authorized. + if (!currentToken) { + throw new OAuthTokenDoesNotExistError("Failed to find OAuth token. Authorize this app first."); + } + // If the OAuth token hasn't already expired (and isn't within the delta), return it. + if (!this.isAlmostExpired(currentToken.expirationTimeIso)) { + return currentToken.accessToken; + } + // Since the token has expired, refresh, store, and return it. + const refreshedToken = await this.refreshAccessToken(currentToken.refreshToken); + await Promise.resolve(tokenStore.storeToken(refreshedToken)); + return refreshedToken.accessToken; + } + async initRedirectCode(code) { + const { tokenStore } = this; + const accessToken = await this.fetchAccessToken(code); + await Promise.resolve(tokenStore.storeToken(accessToken)); + } + mapOAuthToken({ access_token, expires_in, refresh_token, scope }) { + return { + accessToken: access_token, + expirationTimeIso: dayjs().add(expires_in, "seconds").toISOString(), + refreshToken: refresh_token, + scopes: scope.includes(" ") ? scope.split(" ") : [scope] + }; + } + async refreshAccessToken(refreshToken) { + try { + const response = await this.makeOAuthTokenRequest("refresh_token", { + refresh_token: refreshToken + }); + this.assertResponseAccessToken(response.data); + return this.mapOAuthToken(response.data); + } + catch (err) { + throw new OAuthTokenRefreshFailedError("Failed to refresh OAuth token.", { cause: err }); + } + } +} + +// Utility functions for determining if client options include custom receiver, or, if not, +// a webhooks secret token, as one of those is required! +const hasExplicitReceiver = (obj) => typeof obj.receiver !== "undefined"; +const hasWebhooksSecretToken = (obj) => typeof obj.webhooksSecretToken !== "undefined"; +const isReceiverDisabled = (options) => typeof options.disableReceiver !== "undefined" && options.disableReceiver; +const DEFAULT_HTTP_RECEIVER_PORT = 8080; +const DEFAULT_LOGLEVEL = exports.LogLevel.ERROR; +class ProductClient { + auth; + endpoints; + webEventConsumer; + receiver; + constructor(options) { + this.auth = this.initAuth(options); + this.endpoints = this.initEndpoints(this.auth, options); + this.webEventConsumer = this.initEventProcessor(this.endpoints, options); + // Only create an instance of `this.receiver` if the developer did not explicitly disable it. + if (!isReceiverDisabled(options)) { + // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); + } + this.receiver = (hasExplicitReceiver(options) ? + options.receiver + : this.initDefaultReceiver(options)); + this.receiver.init({ + eventEmitter: this.webEventConsumer, + interactiveAuth: this.auth instanceof InteractiveAuth ? this.auth : undefined + }); + } + } + initDefaultReceiver({ port, webhooksSecretToken, logLevel }) { + return new HttpReceiver({ + port: port ?? DEFAULT_HTTP_RECEIVER_PORT, + webhooksSecretToken, + logLevel: logLevel ?? DEFAULT_LOGLEVEL + }); + } + async start() { + if (!this.receiver) { + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); + } + // Method call is wrapped in `await` and `Promise.resolve()`, as the call + // may or may not return a promise. This is not required when implementing `Receiver`. + return (await Promise.resolve(this.receiver.start())); + } +} + +class CommerceOAuthClient extends ProductClient { + initAuth({ clientId, clientSecret, tokenStore, ...restOptions }) { + const oAuth = new OAuth({ clientId, clientSecret, tokenStore }); + if (hasInstallerOptions(restOptions)) { + oAuth.setInstallerOptions(restOptions.installerOptions); + } + return oAuth; + } + initEndpoints(auth, options) { + return new CommerceEndpoints({ auth, ...options }); + } + initEventProcessor() { + return undefined; + } +} + +exports.ApiResponseError = ApiResponseError; +exports.AwsLambdaReceiver = AwsLambdaReceiver; +exports.AwsReceiverRequestError = AwsReceiverRequestError; +exports.ClientCredentialsRawResponseError = ClientCredentialsRawResponseError; +exports.CommerceEndpoints = CommerceEndpoints; +exports.CommerceOAuthClient = CommerceOAuthClient; +exports.CommonHttpRequestError = CommonHttpRequestError; +exports.ConsoleLogger = ConsoleLogger; +exports.HTTPReceiverConstructionError = HTTPReceiverConstructionError; +exports.HTTPReceiverPortNotNumberError = HTTPReceiverPortNotNumberError; +exports.HTTPReceiverRequestError = HTTPReceiverRequestError; +exports.HttpReceiver = HttpReceiver; +exports.OAuthInstallerNotInitializedError = OAuthInstallerNotInitializedError; +exports.OAuthStateVerificationFailedError = OAuthStateVerificationFailedError; +exports.OAuthTokenDoesNotExistError = OAuthTokenDoesNotExistError; +exports.OAuthTokenFetchFailedError = OAuthTokenFetchFailedError; +exports.OAuthTokenRawResponseError = OAuthTokenRawResponseError; +exports.OAuthTokenRefreshFailedError = OAuthTokenRefreshFailedError; +exports.ProductClientConstructionError = ProductClientConstructionError; +exports.ReceiverInconsistentStateError = ReceiverInconsistentStateError; +exports.ReceiverOAuthFlowError = ReceiverOAuthFlowError; +exports.S2SRawResponseError = S2SRawResponseError; +exports.isCoreError = isCoreError; +exports.isStateStore = isStateStore; diff --git a/commerce/commerce.d.ts b/commerce/commerce.d.ts new file mode 100644 index 0000000..735e791 --- /dev/null +++ b/commerce/commerce.d.ts @@ -0,0 +1,2449 @@ +import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; +import { Server } from 'node:http'; +import { ServerOptions } from 'node:https'; + +declare enum LogLevel { + ERROR = "error", + WARN = "warn", + INFO = "info", + DEBUG = "debug" +} +interface Logger { + /** + * Output debug message + * @param msg any data to be logged + */ + debug(...msg: unknown[]): void; + /** + * Output info message + * @param msg any data to be logged + */ + info(...msg: unknown[]): void; + /** + * Output warn message + * @param msg any data to be logged + */ + warn(...msg: unknown[]): void; + /** + * Output error message + * @param msg any data to be logged + */ + error(...msg: unknown[]): void; + /** + * Disables all logging below the given level + * @param level as a string, 'error' | 'warn' | 'info' | 'debug' + */ + setLevel(level: LogLevel): void; + /** + * Return the current LogLevel. + */ + getLevel(): LogLevel; + /** + * Name the instance so that it can be filtered when many loggers are sending output + * to the same destination. + * @param name as a string + */ + setName(name: string): void; +} +declare class ConsoleLogger implements Logger { + private level; + private name; + private static labels; + private static severity; + constructor(); + getLevel(): LogLevel; + setLevel(level: LogLevel): void; + setName(name: string): void; + debug(...msg: unknown[]): void; + info(...msg: unknown[]): void; + warn(...msg: unknown[]): void; + error(...msg: unknown[]): void; + private static isMoreOrEqualSevere; +} + +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + +interface AuthOptions { + clientId: string; + clientSecret: string; + tokenStore?: TokenStore | undefined; + logger?: Logger; +} +type OAuthGrantType = "authorization_code" | "client_credentials" | "refresh_token" | "account_credentials"; +interface BaseOAuthRequest { + grant_type: OAuthGrantType; +} +interface OAuthAuthorizationCodeRequest extends BaseOAuthRequest { + code: string; + grant_type: "authorization_code"; + redirect_uri?: string; +} +interface OAuthRefreshTokenRequest extends BaseOAuthRequest { + grant_type: "refresh_token"; + refresh_token: string; +} +interface S2SAuthTokenRequest extends BaseOAuthRequest { + grant_type: "account_credentials"; + account_id: string; +} +type OAuthRequest = OAuthAuthorizationCodeRequest | OAuthRefreshTokenRequest | S2SAuthTokenRequest; +/** + * {@link Auth} is the base implementation of authentication for Zoom's APIs. + * + * It only requires a `clientId` and `tokenStore`, as these options are shared across + * all authentication implementations, namely OAuth and server-to-server auth (client + * credentials, JWT, and server-to-server OAuth.) + */ +declare abstract class Auth { + protected readonly clientId: string; + protected readonly clientSecret: string; + protected readonly tokenStore: TokenStore; + protected readonly logger: Logger | undefined; + constructor({ clientId, clientSecret, tokenStore, logger }: AuthOptions); + protected getBasicAuthorization(): string; + abstract getToken(): MaybePromise; + protected isAlmostExpired(isoTime: string): boolean; + protected makeOAuthTokenRequest(grantType: T, payload?: Omit, "grant_type">): Promise; +} + +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} + +interface S2SAuthToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} +interface S2SAuthOptions { + accountId: string; +} +declare class S2SAuth extends Auth { + private accountId; + constructor({ accountId, ...restOptions }: AuthOptions & S2SAuthOptions); + private assertRawToken; + private fetchAccessToken; + getToken(): Promise; + private mapAccessToken; +} + +interface Event { + event: Type; +} +type EventKeys = T extends Event ? U : never; +type EventPayload = Extract; +type EventListenerFn, ReturnType = MaybePromise> = (payload: EventPayload) => ReturnType; +type EventListenerPredicateFn> = EventListenerFn>; +type ContextListener, Context> = (_: EventPayload & Context) => MaybePromise; +type GenericEventManager = EventManager; +declare class EventManager { + protected endpoints: Endpoints; + constructor(endpoints: Endpoints); + private appendListener; + filteredEvent>(eventName: EventName, predicate: EventListenerPredicateFn, listener: EventListenerFn): void; + emit>(eventName: EventName, payload: EventPayload): Promise; + event>(eventName: EventName, listener: EventListenerFn): void; + protected withContext, Context>(): ContextListener; +} + +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + +interface HttpReceiverOptions extends Partial { + endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; + port?: number | string | undefined; + webhooksSecretToken?: string | undefined; +} +type SecureServerOptions = { + [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; +}; +declare const secureServerOptionKeys: (keyof ServerOptions)[]; +declare class HttpReceiver implements Receiver { + private eventEmitter?; + private interactiveAuth?; + private server?; + private logger; + constructor(options: HttpReceiverOptions); + canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; + private getServerCreator; + private hasEndpoint; + private hasSecureOptions; + init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; + start(port?: number | string): Promise; + stop(): Promise; + private writeTemporaryRedirect; + private writeResponse; +} + +interface BaseResponse { + data?: Data | undefined; + statusCode: number; + trackingId?: string | undefined; +} +interface BuildEndpointOptions { + method: HttpMethod; + baseUrlOverride?: string | undefined; + urlPathBuilder: (params: PathSchema) => string; + requestMimeType?: RequestMimeType; +} +interface WebEndpointOptions { + auth: Auth; + baseUrl?: string | undefined; + doubleEncodeUrl?: boolean | undefined; + timeout?: number | undefined; +} +type EndpointArguments = (PathSchema extends NoParams ? object : AllPropsOptional extends "t" ? { + path?: PathSchema; +} : { + path: PathSchema; +}) & (BodySchema extends NoParams ? object : AllPropsOptional extends "t" ? { + body?: BodySchema; +} : { + body: BodySchema; +}) & (QuerySchema extends NoParams ? object : AllPropsOptional extends "t" ? { + query?: QuerySchema; +} : { + query: QuerySchema; +}); +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +type NoParams = "_NO_PARAMS_"; +type RequestMimeType = "application/json" | "multipart/form-data"; +declare class WebEndpoints { + constructor(options: WebEndpointOptions); + protected buildEndpoint({ method, baseUrlOverride, urlPathBuilder, requestMimeType }: BuildEndpointOptions): (_: EndpointArguments) => Promise>; + private buildUserAgent; + private getHeaders; + private getRequestBody; + private isOk; + private isZoomResponseError; + private makeRequest; +} + +type CommonClientOptions = GetAuthOptions & ExtractInstallerOptions & { + disableReceiver?: boolean | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; +}; +interface ClientReceiverOptions { + receiver: R; +} +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); +type ExtractInstallerOptions = A extends InteractiveAuth ? [ + ReturnType +] extends [true] ? WideInstallerOptions : object : object; +type ExtractAuthTokenType = A extends Auth ? T : never; +type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); +type WideInstallerOptions = { + installerOptions: InstallerOptions; +}; +declare abstract class ProductClient, ReceiverType extends Receiver> { + private readonly auth; + readonly endpoints: EndpointsType; + readonly webEventConsumer?: EventProcessorType | undefined; + private readonly receiver?; + constructor(options: ClientConstructorOptions); + protected abstract initAuth(options: OptionsType): AuthType; + protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; + private initDefaultReceiver; + start(): Promise>; +} + +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; +} +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; + +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} +interface InstallerOptions { + directInstall?: boolean | undefined; + installPath?: string | undefined; + redirectUri: string; + redirectUriPath?: string | undefined; + stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; +} +/** + * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication + * is initiated server-side, but requires manual authorization from a user, by redirecting the user to Zoom. + * + * In addition to all required fields from {@link AuthOptions}, this class requires a `redirectUri`, as this + * value is appended to the authorization URL when the user is redirected to Zoom and subsequently redirected + * back to an endpoint on this server. + * + * @see {@link https://developers.zoom.us/docs/integrations/oauth/ | OAuth - Zoom Developers} + */ +declare abstract class InteractiveAuth extends Auth { + installerOptions?: ReturnType; + getAuthorizationUrl(): Promise; + getFullRedirectUri(): string; + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { + directInstall: boolean; + installPath: string; + redirectUri: string; + redirectUriPath: string; + stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; + }; +} + +/** + * Credentials for access token & refresh token, which are used to access Zoom's APIs. + * + * As access token is short-lived (usually a single hour), its expiration time is checked + * first. If it's possible to use the access token, it's used; however, if it has expired + * or is close to expiring, the refresh token should be used to generate a new access token + * before the API call is made. Refresh tokens are generally valid for 90 days. + * + * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} + * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. + * It's likely that this error will be rare, but it _can_ be thrown. + */ +interface OAuthToken { + accessToken: string; + expirationTimeIso: string; + refreshToken: string; + scopes: string[]; +} +declare class OAuth extends InteractiveAuth { + private assertResponseAccessToken; + private fetchAccessToken; + getToken(): Promise; + initRedirectCode(code: string): Promise; + private mapOAuthToken; + private refreshAccessToken; +} + +interface RivetError extends Error { + readonly errorCode: ErrorCode; +} + +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + +interface AwsLambdaReceiverOptions { + webhooksSecretToken: string; +} +declare class AwsLambdaReceiver implements Receiver { + private eventEmitter?; + private readonly webhooksSecretToken; + constructor({ webhooksSecretToken }: AwsLambdaReceiverOptions); + buildResponse(statusCode: StatusCode, body: object): LambdaFunctionURLResult; + canInstall(): false; + init({ eventEmitter }: ReceiverInitOptions): void; + start(): LambdaFunctionURLHandler; + stop(): Promise; +} + +type AccountManagementCreateEndCustomerAccountRequestBody = { + account_name: string; + employee_count: string; + website: string; + sub_reseller_crm_account_number?: string; + contacts: { + first_name: string; + last_name: string; + job_title: string; + company_email: string; + business_phone: string; + primary_role?: string; + }[]; + currency: string; + billing_address: { + line_1: string; + city: string; + postal_code: string; + state: string; + country: string; + }; +}; +type AccountManagementCreateEndCustomerAccountResponse = { + create_reference_id?: string; + crm_account_number?: string; + status?: string; + status_detail?: string; +}; +type AccountManagementAddContactsToExistingEndCustomerOrYourOwnAccountPathParams = { + accountKey: string; +}; +type AccountManagementAddContactsToExistingEndCustomerOrYourOwnAccountRequestBody = { + contacts?: { + first_name: string; + last_name: string; + job_title: string; + company_email: string; + business_phone: string; + primary_role?: string; + }[]; +}; +type AccountManagementAddContactsToExistingEndCustomerOrYourOwnAccountResponse = { + create_reference_id?: string; + crm_account_number?: string; + status?: string; + status_detail?: string; +}; +type AccountManagementGetsListOfAllAccountsAssociatedWithZoomPartnerSubResellerByAccountTypeQueryParams = { + relationship_type?: string; + account_name?: string; + crm_account_number?: string; + zoom_account_number?: string; + create_reference_id?: string; + sub_reseller_crm_account_number?: string; + sibling_crm_account_number?: string; + page_size?: number; + page_number?: number; +}; +type AccountManagementGetsListOfAllAccountsAssociatedWithZoomPartnerSubResellerByAccountTypeResponse = { + page_count?: number; + account_list?: { + create_reference_id?: string; + crm_account_number?: string; + account_name?: string; + zoom_account_number?: string; + account_type?: string; + country?: string; + currency?: string; + website?: string; + }[]; +}; +type AccountManagementGetAccountDetailsForZoomPartnerSubResellerEndCustomerPathParams = { + accountKey: string; +}; +type AccountManagementGetAccountDetailsForZoomPartnerSubResellerEndCustomerResponse = { + crm_account_number?: string; + create_reference_id?: string; + status?: string; + zoom_account_number?: string; + account_name?: string; + account_type?: string; + created_on_date?: string; + employee_count?: string; + website?: string; + currency?: string; + contacts?: { + contact_crm_number?: string; + first_name: string; + last_name: string; + job_title: string; + company_email: string; + business_phone: string; + primary_role?: string; + }[]; + billing_address?: { + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; +}; +type BillingGetsAllBillingDocumentsForDistributorOrResellerQueryParams = { + document_type?: string; + payment_status?: string; + document_date_start?: string; + document_date_end?: string; + due_date?: string; + document_number?: string; + currency?: string; + end_customer_name?: string; + invoice_owner_crm_account_number?: string; + page_size?: number; + next_page_token?: string; + sort?: string; +}; +type BillingGetsAllBillingDocumentsForDistributorOrResellerResponse = { + document_count?: number; + billing_documents?: { + document_number?: string; + document_date?: string; + document_type?: string; + customer_name?: string[]; + sub_reseller_name?: string; + invoice_owner_name?: string; + due_date?: string; + payment_status?: string; + balance?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + po_number?: string; + billing_description?: string; + posted_date?: string; + reason_detail?: string; + reference_billing_document_id?: string; + }[]; + next_page_token?: string; +}; +type BillingGetsPDFDocumentForBillingDocumentIDPathParams = { + documentNumber: string; +}; +type BillingGetDetailedInformationAboutSpecificInvoiceForDistributorOrResellerPathParams = { + invoiceNumber: string; +}; +type BillingGetDetailedInformationAboutSpecificInvoiceForDistributorOrResellerQueryParams = { + zoom_account_number?: string; + crm_account_number?: string; +}; +type BillingGetDetailedInformationAboutSpecificInvoiceForDistributorOrResellerResponse = { + invoice_number?: string; + invoice_date?: string; + customer_name?: string[]; + invoice_owner_account?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sub_reseller?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sold_to_contact?: { + crm_contact_number?: string; + first_name?: string; + last_name?: string; + email?: string; + }; + bill_to_contact?: { + crm_contact_number?: string; + first_name?: string; + last_name?: string; + email?: string; + }; + due_date?: string; + payment_status?: string; + balance?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + po_numbers?: string; + billing_description?: string; + posted_date?: string; + reason_detail?: string; + reference_billing_document_id?: string; + total_tax_amount?: { + amount?: number; + currency?: string; + }; + target_date?: object; + invoice_items?: { + end_customer_account?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + charge_name?: string; + charge_type?: string; + description?: string; + offer_name?: string; + offer_price_list_name?: string; + start_date?: string; + end_date?: string; + subscription_number?: string; + partner_sku_code?: string; + po_number?: string; + quantity?: number; + tax_amount?: { + amount?: number; + currency?: string; + }; + total_amount?: { + amount?: number; + currency?: string; + }; + }[]; +}; +type DealRegistrationRetrievesAllValidZoomCampaignsWhichDealRegistrationCanBeAssociatedWithQueryParams = { + end_customer_crm_account_number: string; + sub_reseller_crm_account_number?: string; + campaign_member_crm_contact_number: string; + product_groups: string; +}; +type DealRegistrationRetrievesAllValidZoomCampaignsWhichDealRegistrationCanBeAssociatedWithResponse = { + campaigns?: { + campaign_number: number; + campaign_name?: string; + campaign_description?: string; + campaign_start_date?: string; + campaign_end_date?: string; + }[]; +}; +type DealRegistrationCreatesNewDealRegistrationForPartnerRequestBody = { + opportunity_type: string; + sub_reseller_crm_account_number?: string; + sales_rep_contact_crm_number: string; + partner_contacts?: { + contact_crm_number?: string; + }[]; + end_customer_crm_account_number: string; + end_customer_contact_crm_number: string; + end_customer_contacts?: { + contact_crm_number?: string; + }[]; + end_customer_department?: string[]; + met_decision_maker: boolean; + decision_maker_crm_number?: string; + budget_identified?: boolean; + is_public_sector?: boolean; + buy_gov_skus?: boolean; + end_customer_industry: string; + opportunity_name: string; + opportunity_desc?: string; + estimated_close_date: string; + estimated_mrr: number; + currency: string; + requires_professional_services: boolean; + professional_services_description?: string; + phone_carrier?: string; + product_groups: { + name: string; + quantity: number; + }[]; + campaign_number?: string; + sales_activities: { + type: string; + date: string; + }[]; + rfp_details?: { + is_rfp: boolean; + rfp_link?: string; + rfp_issue_date?: string; + rfp_due_date?: string; + }; + submitter_contact_crm_number?: string; + migration_type?: string; + additional_comments?: string; +}; +type DealRegistrationCreatesNewDealRegistrationForPartnerResponse = { + create_reference_id?: string; + deal_reg_number?: string; + status?: string; + errors?: { + error_code: string; + error_description: string; + }[]; +}; +type DealRegistrationGetsAllValidDealRegistrationsForPartnerQueryParams = { + deal_reg_number?: string; + create_reference_id?: string; + end_customer_name?: string; + end_customer_crm_account_number?: string; + end_customer_zoom_account_number?: string; + sub_reseller_name?: string; + sub_reseller_crm_account_number?: string; + invoice_owner_crm_account_number?: string; + status: string; + page_size?: number; + page_number?: string; +}; +type DealRegistrationGetsAllValidDealRegistrationsForPartnerResponse = { + page_count?: number; + deal_registrations?: { + deal_reg_number?: string; + create_reference_id?: string; + deal_name?: string; + invoice_owner_crm_account_number?: string; + opportunity_stage?: string; + submitted_date?: string; + expected_closed_date?: string; + estimated_mrr?: number; + currency?: string; + program_name?: string; + original_expiry_date?: string; + extended_expiry_date?: string; + partner_sales_rep?: string; + zoom_account_executive?: string; + zoom_cam?: string; + status?: string; + end_customer?: { + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + department?: string; + industry?: string; + account_local_name?: string; + employee_count?: string; + website?: string; + }; + opportunity?: { + opportunity_id?: string; + opportunity_name?: string; + opportunity_type?: string; + expected_close_date?: string; + partner_role?: string; + channel_sales_motion?: string; + }; + }[]; +}; +type DealRegistrationGetsDetailsForDealRegistrationByDealRegistrationNumberPathParams = { + dealRegKey: string; +}; +type DealRegistrationGetsDetailsForDealRegistrationByDealRegistrationNumberResponse = { + deal_reg_number?: string; + create_reference_id?: string; + deal_name?: string; + deal_description?: string; + currency?: string; + program_name?: string; + opportunity_stage?: string; + submitted_date?: string; + approved_date?: string; + denied_date?: string; + expected_close_date?: string; + partner_role?: string; + original_expiry_date?: string; + extended_expiry_date?: string; + sales_representative?: { + self_sales_representative?: boolean; + sales_rep_contact?: { + contact_crm_number: string; + first_name?: string; + last_name?: string; + email?: string; + title?: string; + phone?: string; + }; + }; + submitter?: { + contact_crm_number: string; + first_name?: string; + last_name?: string; + email?: string; + title?: string; + phone?: string; + }; + related_partner?: string; + partner_contacts?: { + contact_crm_number: string; + first_name?: string; + last_name?: string; + email?: string; + title?: string; + phone?: string; + }[]; + is_existing_customer?: boolean; + sub_reseller?: { + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + invoice_owner?: { + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + end_customer?: { + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + department?: string; + industry?: string; + account_local_name?: string; + employee_count?: string; + website?: string; + }; + end_customer_primary_contact?: { + contact_crm_number: string; + first_name?: string; + last_name?: string; + email?: string; + title?: string; + phone?: string; + end_customer_contact_domain_reason?: string; + }; + end_customer_other_contacts?: { + contact_crm_number: string; + first_name?: string; + last_name?: string; + email?: string; + title?: string; + phone?: string; + end_customer_contact_domain_reason?: string; + }[]; + end_customer_website_details?: { + empty_website?: boolean; + empty_website_reason?: string; + public_website_reason?: string; + }; + is_public_sector?: boolean; + budget_identified?: boolean; + buy_gov_skus?: boolean; + met_decision_maker?: boolean; + decision_maker_email?: string; + decision_maker_name?: string; + estimated_mrr?: number; + requires_professional_services?: boolean; + professional_services_description?: string; + phone_carrier?: string; + product_groups?: { + name: string; + quantity: number; + }[]; + campaign?: string; + sales_activities?: { + sales_activity_number?: string; + type: string; + date: string; + }[]; + rfp_details?: { + is_rfp?: boolean; + rfp_link?: string; + issue_date?: string; + due_date?: string; + }; + zoom_cams?: { + zoom_territory_cam?: string; + zoom_named_cam?: string; + zoom_distribution_cam?: string; + }; + additional_comments?: string; + opportunity?: { + opportunity_number?: string; + opportunity_name?: string; + opportunity_type?: string; + partner_role?: string; + channel_sales_motion?: string; + expected_close_date?: string; + }; + status?: string; + status_detail?: { + return_reason?: string; + revoke_reason?: string; + denied_reason?: string; + other_comments?: string; + }; +}; +type DealRegistrationUpdatesExistingDealRegistrationPathParams = { + dealRegKey: string; +}; +type DealRegistrationUpdatesExistingDealRegistrationRequestBody = { + sales_rep_contact_crm_number?: string; + add_partner_contacts?: { + contact_crm_number?: string; + }[]; + add_end_customer_contacts?: { + contact_crm_number?: string; + }[]; + end_customer_department?: string[]; + met_decision_maker?: boolean; + decision_maker_crm_number?: string; + budget_identified?: boolean; + is_public_sector?: boolean; + buy_gov_skus?: boolean; + end_customer_industry?: string; + opportunity_name?: string; + opportunity_desc?: string; + estimated_close_date?: string; + estimated_mrr?: number; + currency?: string; + requires_professional_services?: boolean; + professional_services_description?: string; + phone_carrier?: string; + add_product_groups?: { + name: string; + quantity: number; + }[]; + remove_product_groups?: { + name: string; + }[]; + campaign_number?: string; + sales_activities?: { + sales_activity_number?: string; + type: string; + date: string; + }[]; + rfp_details?: { + is_rfp: boolean; + rfp_link?: string; + issue_date?: string; + due_date?: string; + }; + submitter_contact_crm_number?: string; + additional_comments?: string; +}; +type OrderCreatesSubscriptionOrderForZoomPartnerRequestBody = { + header: { + order_type?: string; + order_description?: string; + deal_reg_number?: string; + order_date: string; + po_number?: string; + additional_attributes?: { + name?: string; + value_type?: string; + value?: string; + }[]; + }; + create_subscriptions?: { + end_customer_account_number?: string; + end_customer_crm_account_number: string; + sold_to_crm_contact_number: string; + end_customer_language?: string; + initial_term: { + term_type: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + renewal_term?: { + term_type: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + sub_reseller?: { + crm_account_number?: string; + account_name?: string; + }; + service_start_date?: string; + paid_period_start_date?: string; + free_months_reason_code?: string; + currency: string; + auto_renew?: boolean; + add_offers?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date?: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + add_add_ons?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date?: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + }[]; + amend_subscriptions?: { + subscription_number?: string; + zoom_account_number?: string; + add_offers?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date?: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + add_add_ons?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date?: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + upgrade_offers?: { + new_offer_price_list_id?: string; + new_partner_sku_code?: string; + old_offer_price_list_id?: string; + old_partner_sku_code?: string; + quantity?: number; + start_date: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + remove_offers?: { + offer_price_list_id: string; + partner_sku_code?: string; + end_date: string; + remove_reason?: string; + }[]; + update_offers?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + cancel_subscription?: { + cancel_by?: string; + cancel_on?: string; + cancel_reason: string; + }; + renew_subscription?: { + renewal_term?: { + term_type: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + }; + update_subscription?: { + auto_renew?: boolean; + sold_to_crm_contact_number?: string; + end_customer_language?: string; + }; + }[]; +}; +type OrderCreatesSubscriptionOrderForZoomPartnerResponse = { + status?: string; + order_reference_id?: string; + order_number?: string; + order_date?: string; + subscriptions?: { + subscription_number?: string; + zoom_account_number?: string; + subscription_status?: string; + subscription_owner_id?: string; + invoice_owner_id?: string; + invoice_owner_crm_account_number?: string; + }[]; + errors?: { + error_code: string; + error_description: string; + }[]; +}; +type OrderPreviewDeltaOrderMetricsAndSubscriptionsInOrderRequestBody = { + header: { + order_type?: string; + order_description?: string; + deal_reg_number?: string; + order_date: string; + po_number?: string; + additional_attributes?: { + name?: string; + value?: string; + }[]; + }; + create_subscriptions?: { + end_customer_account_number?: string; + end_customer_crm_account_number: string; + sold_to_crm_contact_number: string; + end_customer_language?: string; + initial_term: { + term_type: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + renewal_term?: { + term_type: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + sub_reseller?: { + crm_account_number?: string; + account_name?: string; + }; + service_start_date?: string; + paid_period_start_date?: string; + free_months_reason_code?: string; + currency: string; + auto_renew?: boolean; + add_offers?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date?: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + add_add_ons?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date?: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + }[]; + amend_subscriptions?: { + subscription_number?: string; + zoom_account_number?: string; + add_offers?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date?: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + add_add_ons?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date?: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + upgrade_offers?: { + new_offer_price_list_id?: string; + new_partner_sku_code?: string; + old_offer_price_list_id?: string; + old_partner_sku_code?: string; + quantity?: number; + start_date: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + remove_offers?: { + offer_price_list_id: string; + partner_sku_code?: string; + end_date: string; + remove_reason?: string; + }[]; + update_offers?: { + offer_price_list_id: string; + partner_sku_code?: string; + quantity?: number; + start_date: string; + offer_attributes?: { + name?: string; + value?: string; + }[]; + }[]; + cancel_subscription?: { + cancel_by?: string; + cancel_on?: string; + cancel_reason: string; + }; + renew_subscription?: { + renewal_term?: { + term_type: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + }; + update_subscription?: { + auto_renew?: boolean; + sold_to_crm_contact_number?: string; + end_customer_language?: string; + }; + }[]; +}; +type OrderPreviewDeltaOrderMetricsAndSubscriptionsInOrderResponse = { + status?: string; + order_reference_id?: string; + order_date?: string; + order_metrics?: { + tcv?: number; + tcb?: number; + mrr?: number; + total_discount_pct?: number; + }; + subscription_preview?: { + subscription_number?: object; + tcv?: number; + tcb?: number; + mrr?: number; + total_discount_pct?: number; + }[]; + subscription_item_metrics?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + offer_price_list_name?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + mrr?: { + amount?: number; + currency?: string; + }; + }[]; + errors?: string[]; +}; +type OrderGetsAllOrdersForZoomPartnerQueryParams = { + invoice_owner_crm_account_number?: string; + page_size?: number; + page_num?: string; + date_filter_option?: string; + from?: string; + to?: string; + order_type?: string; + order_reference_id?: string; + order_number?: string; + po_number?: string; + deal_reg_number?: string; + end_customer_name?: string; + end_customer_crm_account_number?: string; + end_customer_zoom_account_number?: string; + sub_reseller_name?: string; + sub_reseller_crm_account_number?: string; + status?: string; + subscription_number?: string; + sort?: string; +}; +type OrderGetsAllOrdersForZoomPartnerResponse = { + order_list?: { + order_reference_id?: string; + order_number?: string; + status?: string; + order_type?: string; + invoice_owner_crm_account_number?: string; + end_customer_account_name?: string; + end_customer_account_number?: string; + end_customer_crm_account_number?: string; + sub_reseller_name?: string; + sub_reseller_crm_account_number?: string; + creation_date?: string; + effective_date?: string; + net_amount?: { + amount?: number; + currency?: string; + }; + updated_date?: string; + trade_screening?: boolean; + deal_reg_number?: string; + po_number?: string; + }[]; +}; +type OrderGetsOrderDetailsByOrderReferenceIDPathParams = { + orderReferenceId: string; +}; +type OrderGetsOrderDetailsByOrderReferenceIDResponse = { + header?: { + order_reference_id?: string; + order_type?: string; + order_description?: string; + status?: string; + order_number?: string; + deal_reg_number?: string; + order_date: string; + po_number?: string; + trade_screening?: boolean; + order_metrics?: { + tcv?: number; + tcb?: number; + mrr?: number; + total_discount_pct?: number; + }; + additional_attributes?: { + name?: string; + value?: string; + }[]; + }; + create_subscription?: { + subscription_number?: string; + end_customer_account?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + invoice_owner_account?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sub_reseller?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sold_to_contact?: { + crm_contact_number?: string; + first_name?: string; + last_name?: string; + email?: string; + }; + bill_to_contact?: { + crm_contact_number?: string; + first_name?: string; + last_name?: string; + email?: string; + }; + initial_term?: { + term_type: string; + period_type?: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + renewal_term?: { + term_type: string; + period_type?: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + agreement_dates?: { + contract_effective_date?: string; + service_activation_date?: string; + customer_acceptance_date?: string; + }; + sold_to_crm_contact_number?: string; + end_customer_language?: string; + payment_term?: string; + service_start_date?: string; + paid_period_start_date?: string; + free_months_included?: boolean; + free_months_reason_code?: string; + auto_renew?: boolean; + currency?: string; + deal_reg_number?: string; + po_number?: string; + subscription_metrics?: { + subscription_number?: string; + tcv?: number; + tcb?: number; + mrr?: number; + total_discount_pct?: number; + }; + offers?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + partner_sku_code?: string; + offer_price_list_name?: string; + quantity?: number; + start_date?: string; + end_date?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + usage_based_charge?: boolean; + }[]; + add_ons?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + partner_sku_code?: string; + offer_price_list_name?: string; + quantity?: number; + start_date?: string; + end_date?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + usage_based_charge?: boolean; + }[]; + }[]; + amend_subscriptions?: { + subscription_number?: string; + end_customer_account?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + invoice_owner_account?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sub_reseller?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sold_to_contact?: { + crm_account_number?: string; + first_name?: string; + last_name?: string; + email?: string; + }; + bill_to_contact?: { + crm_account_number?: string; + first_name?: string; + last_name?: string; + email?: string; + }; + currency?: string; + deal_reg_number?: string; + po_number?: string; + subscription_metrics?: { + subscription_number?: string; + tcv?: number; + tcb?: number; + mrr?: number; + total_discount_pct?: number; + }; + add_offers?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + partner_sku_code?: string; + offer_price_list_name?: string; + quantity?: number; + start_date?: string; + end_date?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + usage_based_charge?: boolean; + }[]; + add_add_ons?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + partner_sku_code?: string; + offer_price_list_name?: string; + quantity?: number; + start_date?: string; + end_date?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + usage_based_charge?: boolean; + }[]; + upgrade_offers?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + partner_sku_code?: string; + offer_price_list_name?: string; + old_offer_price_list_id?: string; + old_partner_sku_code?: string; + quantity?: number; + start_date?: string; + end_date?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + usage_based_charge?: boolean; + }[]; + remove_offers?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + partner_sku_code?: string; + offer_price_list_name?: string; + quantity?: number; + start_date?: string; + end_date?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + usage_based_charge?: boolean; + }[]; + update_offers?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + partner_sku_code?: string; + offer_price_list_name?: string; + quantity?: number; + start_date?: string; + end_date?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + usage_based_charge?: boolean; + }[]; + cancel_subscription?: { + cancel_by?: string; + cancel_on?: string; + cancel_reason?: string; + }; + renew_subscription?: { + renewal_term?: { + term_type: string; + period_type?: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + }; + update_subscription?: { + auto_renew?: boolean; + sold_to_crm_contact_number?: string; + end_customer_language?: string; + }; + }[]; + errors?: { + error_code?: string; + error_description?: string; + }[]; +}; +type ProductCatalogGetsZoomProductCatalogForZoomPartnerRequestBody = { + filter_options: { + filter_by: string; + filter_value: string; + operand?: string; + }[]; + eligibility_criteria?: { + only_trial_eligible?: boolean; + only_base_plans?: boolean; + upgrade_offer_id?: string; + }; +}; +type ProductCatalogGetsZoomProductCatalogForZoomPartnerResponse = { + offers: { + offer_id: string; + offer_name: string; + offer_desc?: string; + offer_type?: string; + z_product_category?: string; + sku?: string; + status?: string; + start_date?: string; + end_date?: string; + offer_products?: { + product_name?: string; + product_id?: string; + product_family_id?: string; + product_family_name?: string; + product_group_id?: string; + product_group_name?: string; + product_type?: string; + sku?: string; + product_features?: { + feature_id?: string; + name?: string; + value_type?: string; + value?: string; + uom?: string; + }[]; + price_list?: { + price_list_id?: string; + price_list_name?: string; + prices?: { + price_list_charge_id?: string; + partner_sku_code?: string; + charge_type?: string; + charge_model?: string; + name?: string; + uom?: string; + amount?: number; + currency?: string; + region?: string; + country?: string; + min_unit_quantity?: number; + status?: string; + start_date?: string; + end_date?: string; + price_tiers?: { + partner_sku_code?: string; + lower?: number; + upper?: number; + price?: number; + apply_rule?: string; + }[]; + }[]; + start_date?: string; + end_date?: string; + billing_period?: string; + status?: string; + eccn_value?: string; + }[]; + }[]; + offer_attributes?: { + name?: string; + uom?: string; + value_type?: string; + value?: string; + }[]; + pricebook?: { + price_list_id?: string; + price_list_name?: string; + prices?: { + price_list_charge_id?: string; + partner_sku_code?: string; + charge_type?: string; + charge_model?: string; + name?: string; + uom?: string; + amount?: number; + currency?: string; + region?: string; + country?: string; + min_unit_quantity?: number; + status?: string; + start_date?: string; + end_date?: string; + price_tiers?: { + partner_sku_code?: string; + lower?: number; + upper?: number; + price?: number; + apply_rule?: string; + }[]; + }[]; + start_date?: string; + end_date?: string; + billing_period?: string; + status?: string; + eccn_value?: string; + pricebook_attributes?: { + name?: string; + value_type?: string; + value?: string; + }[]; + }[]; + }[]; +}; +type ProductCatalogGetsDetailsForZoomProductOrOfferPathParams = { + offerId: number; +}; +type ProductCatalogGetsDetailsForZoomProductOrOfferResponse = { + offer_id: string; + offer_name: string; + offer_desc?: string; + offer_type?: string; + z_product_category?: string; + sku?: string; + status?: string; + start_date?: string; + end_date?: string; + offer_products?: { + product_name?: string; + product_id?: string; + product_family_id?: string; + product_family_name?: string; + product_group_id?: string; + product_group_name?: string; + product_type?: string; + sku?: string; + product_features?: { + feature_id?: string; + name?: string; + value_type?: string; + value?: string; + uom?: string; + }[]; + price_list?: { + price_list_id?: string; + price_list_name?: string; + prices?: { + price_list_charge_id?: string; + partner_sku_code?: string; + charge_type?: string; + charge_model?: string; + name?: string; + uom?: string; + amount?: number; + currency?: string; + region?: string; + country?: string; + min_unit_quantity?: number; + status?: string; + start_date?: string; + end_date?: string; + price_tiers?: { + partner_sku_code?: string; + lower?: number; + upper?: number; + price?: number; + apply_rule?: string; + }[]; + }[]; + start_date?: string; + end_date?: string; + billing_period?: string; + status?: string; + eccn_value?: string; + }[]; + }[]; + offer_attributes?: { + name?: string; + uom?: string; + value_type?: string; + value?: string; + }[]; + pricebook?: { + price_list_id?: string; + price_list_name?: string; + prices?: { + price_list_charge_id?: string; + partner_sku_code?: string; + charge_type?: string; + charge_model?: string; + name?: string; + uom?: string; + amount?: number; + currency?: string; + country?: string; + min_unit_quantity?: number; + status?: string; + start_date?: string; + end_date?: string; + price_tiers?: { + partner_sku_code?: string; + lower?: number; + upper?: number; + price?: number; + apply_rule?: string; + }[]; + }[]; + start_date?: string; + end_date?: string; + billing_period?: string; + status?: string; + eccn_value?: string; + pricebook_attributes?: { + name?: string; + value_type?: string; + value?: string; + }[]; + }[]; +}; +type ProductCatalogGetsPricebookInDownloadableFileQueryParams = { + currency?: string; + file_type?: string; +}; +type SubscriptionGetsSubscriptionsForZoomPartnerQueryParams = { + page_size?: number; + sort?: string; + status?: string; + start?: string; + end?: string; + duration?: string; + end_customer_name?: string; + end_customer_crm_account_number?: string; + end_customer_zoom_account_number?: string; + sub_reseller_name?: string; + sub_reseller_crm_account_number?: string; + subscription_number?: string; + invoice_owner_crm_account_number?: string; + next_page_token?: string; +}; +type SubscriptionGetsSubscriptionsForZoomPartnerResponse = { + next_page_token?: string; + subscription_list?: { + subscription_number?: string; + subscription_status?: string; + subscription_owner?: { + crm_account_number?: string; + account_name?: string; + zoom_account_number?: string; + }; + invoice_owner?: { + crm_account_number?: string; + account_name?: string; + zoom_account_number?: string; + }; + start_date?: string; + end_date?: string; + order_number?: string; + sub_reseller_name?: string; + sold_to_email?: string; + mrr?: { + gross_amount?: number; + net_amount?: number; + currency?: string; + }; + auto_renew?: boolean; + trade_screening?: boolean; + }[]; +}; +type SubscriptionGetsSubscriptionDetailsForGivenSubscriptionNumberPathParams = { + subscriptionNumber: string; +}; +type SubscriptionGetsSubscriptionDetailsForGivenSubscriptionNumberResponse = { + subscription_number?: string; + status?: string; + payment_term?: string; + service_start_date?: string; + paid_period_start_date?: string; + free_months_included?: boolean; + free_months_reason_code?: string; + deal_reg_number?: string; + po_number?: string; + currency?: string; + end_customer_account?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + invoice_owner_account?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sub_reseller?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + address_type: string; + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + agreement_dates?: { + contract_effective_date?: string; + service_activation_date?: string; + customer_acceptance_date?: string; + }; + initial_term?: { + term_type: string; + period_type?: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + renewal_term?: { + term_type: string; + period_type?: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + current_term?: { + term_type: string; + period_type?: string; + term_period?: number; + start_date?: string; + end_date?: string; + }; + auto_renew?: boolean; + start_date?: string; + end_date?: string; + invoice_separately?: boolean; + contracted_mrr?: number; + mrr?: { + amount?: number; + currency?: string; + }; + bill_to?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sold_to?: { + zoom_account_number?: string; + crm_account_number?: string; + account_name?: string; + address?: { + line_1: string; + line_2?: string; + line_3?: string; + city: string; + postal_code?: string; + state: string; + country: string; + }; + }; + sold_to_contact?: { + crm_contact_number?: string; + first_name?: string; + last_name?: string; + email?: string; + }; + bill_to_contact?: { + crm_contact_number?: string; + first_name?: string; + last_name?: string; + email?: string; + }; + subscription_lines?: { + offer_id?: string; + offer_name?: string; + sku?: string; + offer_price_list_id?: string; + offer_price_list_name?: string; + quantity?: number; + start_date?: string; + end_date?: string; + status?: string; + charges?: { + charge_model?: string; + charge_type?: string; + sale_price?: { + amount?: number; + currency?: string; + }; + net_price?: { + amount?: number; + currency?: string; + }; + net_amount?: { + amount?: number; + currency?: string; + }; + discounts?: { + discount_type: string; + percent_value?: number; + amount_value?: number; + apply_to: string; + discount_level: string; + }[]; + }[]; + offer_attributes?: { + name?: string; + uom?: string; + value_type?: string; + value?: string; + }[]; + }[]; + created_date?: string; + updated_date?: string; +}; +type SubscriptionGetsSubscriptionChangesVersionsForGivenSubscriptionNumberPathParams = { + subscriptionNumber: string; +}; +type SubscriptionGetsSubscriptionChangesVersionsForGivenSubscriptionNumberResponse = { + subscription_number?: string; + status?: string; + start_date?: string; + end_date?: string; + subscription_versions?: { + sequence?: number; + version?: number; + latest_version?: boolean; + action?: string[]; + start_date?: string; + end_date?: string; + mrr?: number; + currency?: string; + }[]; +}; +declare class CommerceEndpoints extends WebEndpoints { + readonly accountManagement: { + createEndCustomerAccount: (_: object & { + body: AccountManagementCreateEndCustomerAccountRequestBody; + }) => Promise>; + addContactsToExistingEndCustomerOrYourOwnAccount: (_: { + path: AccountManagementAddContactsToExistingEndCustomerOrYourOwnAccountPathParams; + } & { + body?: AccountManagementAddContactsToExistingEndCustomerOrYourOwnAccountRequestBody; + } & object) => Promise>; + getsListOfAllAccountsAssociatedWithZoomPartnerSubResellerByAccountType: (_: object & { + query?: AccountManagementGetsListOfAllAccountsAssociatedWithZoomPartnerSubResellerByAccountTypeQueryParams; + }) => Promise>; + getAccountDetailsForZoomPartnerSubResellerEndCustomer: (_: { + path: AccountManagementGetAccountDetailsForZoomPartnerSubResellerEndCustomerPathParams; + } & object) => Promise>; + }; + readonly billing: { + getsAllBillingDocumentsForDistributorOrReseller: (_: object & { + query?: BillingGetsAllBillingDocumentsForDistributorOrResellerQueryParams; + }) => Promise>; + getsPDFDocumentForBillingDocumentID: (_: { + path: BillingGetsPDFDocumentForBillingDocumentIDPathParams; + } & object) => Promise>; + getDetailedInformationAboutSpecificInvoiceForDistributorOrReseller: (_: { + path: BillingGetDetailedInformationAboutSpecificInvoiceForDistributorOrResellerPathParams; + } & object & { + query?: BillingGetDetailedInformationAboutSpecificInvoiceForDistributorOrResellerQueryParams; + }) => Promise>; + }; + readonly dealRegistration: { + retrievesAllValidZoomCampaignsWhichDealRegistrationCanBeAssociatedWith: (_: object & { + query: DealRegistrationRetrievesAllValidZoomCampaignsWhichDealRegistrationCanBeAssociatedWithQueryParams; + }) => Promise>; + createsNewDealRegistrationForPartner: (_: object & { + body: DealRegistrationCreatesNewDealRegistrationForPartnerRequestBody; + }) => Promise>; + getsAllValidDealRegistrationsForPartner: (_: object & { + query: DealRegistrationGetsAllValidDealRegistrationsForPartnerQueryParams; + }) => Promise>; + getsDetailsForDealRegistrationByDealRegistrationNumber: (_: { + path: DealRegistrationGetsDetailsForDealRegistrationByDealRegistrationNumberPathParams; + } & object) => Promise>; + updatesExistingDealRegistration: (_: { + path: DealRegistrationUpdatesExistingDealRegistrationPathParams; + } & { + body?: DealRegistrationUpdatesExistingDealRegistrationRequestBody; + } & object) => Promise>; + }; + readonly order: { + createsSubscriptionOrderForZoomPartner: (_: object & { + body: OrderCreatesSubscriptionOrderForZoomPartnerRequestBody; + }) => Promise>; + previewDeltaOrderMetricsAndSubscriptionsInOrder: (_: object & { + body: OrderPreviewDeltaOrderMetricsAndSubscriptionsInOrderRequestBody; + }) => Promise>; + getsAllOrdersForZoomPartner: (_: object & { + query?: OrderGetsAllOrdersForZoomPartnerQueryParams; + }) => Promise>; + getsOrderDetailsByOrderReferenceID: (_: { + path: OrderGetsOrderDetailsByOrderReferenceIDPathParams; + } & object) => Promise>; + }; + readonly productCatalog: { + getsZoomProductCatalogForZoomPartner: (_: object & { + body: ProductCatalogGetsZoomProductCatalogForZoomPartnerRequestBody; + }) => Promise>; + getsDetailsForZoomProductOrOffer: (_: { + path: ProductCatalogGetsDetailsForZoomProductOrOfferPathParams; + } & object) => Promise>; + getsPricebookInDownloadableFile: (_: object & { + query?: ProductCatalogGetsPricebookInDownloadableFileQueryParams; + }) => Promise>; + }; + readonly subscription: { + getsSubscriptionsForZoomPartner: (_: object & { + query?: SubscriptionGetsSubscriptionsForZoomPartnerQueryParams; + }) => Promise>; + getsSubscriptionDetailsForGivenSubscriptionNumber: (_: { + path: SubscriptionGetsSubscriptionDetailsForGivenSubscriptionNumberPathParams; + } & object) => Promise>; + getsSubscriptionChangesVersionsForGivenSubscriptionNumber: (_: { + path: SubscriptionGetsSubscriptionChangesVersionsForGivenSubscriptionNumberPathParams; + } & object) => Promise>; + }; +} + +type CommerceOAuthOptions = CommonClientOptions; +declare class CommerceOAuthClient = CommerceOAuthOptions> extends ProductClient { + protected initAuth({ clientId, clientSecret, tokenStore, ...restOptions }: OptionsType): OAuth; + protected initEndpoints(auth: OAuth, options: OptionsType): CommerceEndpoints; + protected initEventProcessor(): never; +} + +export { type AccountManagementAddContactsToExistingEndCustomerOrYourOwnAccountPathParams, type AccountManagementAddContactsToExistingEndCustomerOrYourOwnAccountRequestBody, type AccountManagementAddContactsToExistingEndCustomerOrYourOwnAccountResponse, type AccountManagementCreateEndCustomerAccountRequestBody, type AccountManagementCreateEndCustomerAccountResponse, type AccountManagementGetAccountDetailsForZoomPartnerSubResellerEndCustomerPathParams, type AccountManagementGetAccountDetailsForZoomPartnerSubResellerEndCustomerResponse, type AccountManagementGetsListOfAllAccountsAssociatedWithZoomPartnerSubResellerByAccountTypeQueryParams, type AccountManagementGetsListOfAllAccountsAssociatedWithZoomPartnerSubResellerByAccountTypeResponse, ApiResponseError, AwsLambdaReceiver, AwsReceiverRequestError, type BillingGetDetailedInformationAboutSpecificInvoiceForDistributorOrResellerPathParams, type BillingGetDetailedInformationAboutSpecificInvoiceForDistributorOrResellerQueryParams, type BillingGetDetailedInformationAboutSpecificInvoiceForDistributorOrResellerResponse, type BillingGetsAllBillingDocumentsForDistributorOrResellerQueryParams, type BillingGetsAllBillingDocumentsForDistributorOrResellerResponse, type BillingGetsPDFDocumentForBillingDocumentIDPathParams, ClientCredentialsRawResponseError, type ClientCredentialsToken, CommerceEndpoints, CommerceOAuthClient, type CommerceOAuthOptions, CommonHttpRequestError, ConsoleLogger, type DealRegistrationCreatesNewDealRegistrationForPartnerRequestBody, type DealRegistrationCreatesNewDealRegistrationForPartnerResponse, type DealRegistrationGetsAllValidDealRegistrationsForPartnerQueryParams, type DealRegistrationGetsAllValidDealRegistrationsForPartnerResponse, type DealRegistrationGetsDetailsForDealRegistrationByDealRegistrationNumberPathParams, type DealRegistrationGetsDetailsForDealRegistrationByDealRegistrationNumberResponse, type DealRegistrationRetrievesAllValidZoomCampaignsWhichDealRegistrationCanBeAssociatedWithQueryParams, type DealRegistrationRetrievesAllValidZoomCampaignsWhichDealRegistrationCanBeAssociatedWithResponse, type DealRegistrationUpdatesExistingDealRegistrationPathParams, type DealRegistrationUpdatesExistingDealRegistrationRequestBody, HTTPReceiverConstructionError, HTTPReceiverPortNotNumberError, HTTPReceiverRequestError, HttpReceiver, type HttpReceiverOptions, type JwtToken, LogLevel, type Logger, OAuthInstallerNotInitializedError, OAuthStateVerificationFailedError, type OAuthToken, OAuthTokenDoesNotExistError, OAuthTokenFetchFailedError, OAuthTokenRawResponseError, OAuthTokenRefreshFailedError, type OrderCreatesSubscriptionOrderForZoomPartnerRequestBody, type OrderCreatesSubscriptionOrderForZoomPartnerResponse, type OrderGetsAllOrdersForZoomPartnerQueryParams, type OrderGetsAllOrdersForZoomPartnerResponse, type OrderGetsOrderDetailsByOrderReferenceIDPathParams, type OrderGetsOrderDetailsByOrderReferenceIDResponse, type OrderPreviewDeltaOrderMetricsAndSubscriptionsInOrderRequestBody, type OrderPreviewDeltaOrderMetricsAndSubscriptionsInOrderResponse, type ProductCatalogGetsDetailsForZoomProductOrOfferPathParams, type ProductCatalogGetsDetailsForZoomProductOrOfferResponse, type ProductCatalogGetsPricebookInDownloadableFileQueryParams, type ProductCatalogGetsZoomProductCatalogForZoomPartnerRequestBody, type ProductCatalogGetsZoomProductCatalogForZoomPartnerResponse, ProductClientConstructionError, type Receiver, ReceiverInconsistentStateError, type ReceiverInitOptions, ReceiverOAuthFlowError, type S2SAuthToken, S2SRawResponseError, type StateStore, StatusCode, type SubscriptionGetsSubscriptionChangesVersionsForGivenSubscriptionNumberPathParams, type SubscriptionGetsSubscriptionChangesVersionsForGivenSubscriptionNumberResponse, type SubscriptionGetsSubscriptionDetailsForGivenSubscriptionNumberPathParams, type SubscriptionGetsSubscriptionDetailsForGivenSubscriptionNumberResponse, type SubscriptionGetsSubscriptionsForZoomPartnerQueryParams, type SubscriptionGetsSubscriptionsForZoomPartnerResponse, type TokenStore, isCoreError, isStateStore }; diff --git a/commerce/commerce.mjs b/commerce/commerce.mjs new file mode 100644 index 0000000..21af344 --- /dev/null +++ b/commerce/commerce.mjs @@ -0,0 +1,1028 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { createServer as createServer$1 } from 'node:http'; +import { createServer } from 'node:https'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import { Buffer as Buffer$1 } from 'node:buffer'; +import { SignJWT, jwtVerify } from 'jose'; +import FormData from 'form-data'; +import os from 'node:os'; +import { basename } from 'node:path'; + +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +const isStateStore = (obj) => typeof obj.generateState === "function" && typeof obj.verifyState === "function"; + +const createRivetErrors = (errors) => ({ + createError: (errorCode) => class extends Error { + errorCode = errors[errorCode]; + constructor(message, options) { + const errorMessage = (message ?? + (options?.cause instanceof Error ? options.cause.message : errorCode)); + super(errorMessage, options); + this.name = errorCode; + Object.setPrototypeOf(this, new.target.prototype); + } + }, + isError: (obj, key) => key ? + Object.keys(errors).some((code) => code === key) && + typeof obj.errorCode === "string" && + obj.errorCode === errors[key] + : typeof obj.errorCode === "string" +}); + +const coreErrors = { + ApiResponseError: "zoom_rivet_api_response_error", + AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error", + ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error", + S2SRawResponseError: "zoom_rivet_s2s_raw_response_error", + CommonHttpRequestError: "zoom_rivet_common_http_request_error", + ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error", + ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error", + HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error", + HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error", + HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error", + OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error", + OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error", + OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error", + OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error", + OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error", + OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error", + ProductClientConstructionError: "zoom_rivet_product_client_construction_error" +}; +const { createError: createCoreError, isError: isCoreError } = createRivetErrors(coreErrors); +const ApiResponseError = createCoreError("ApiResponseError"); +const AwsReceiverRequestError = createCoreError("AwsReceiverRequestError"); +const ClientCredentialsRawResponseError = createCoreError("ClientCredentialsRawResponseError"); +const S2SRawResponseError = createCoreError("S2SRawResponseError"); +const CommonHttpRequestError = createCoreError("CommonHttpRequestError"); +const ReceiverInconsistentStateError = createCoreError("ReceiverInconsistentStateError"); +const ReceiverOAuthFlowError = createCoreError("ReceiverOAuthFlowError"); +const HTTPReceiverConstructionError = createCoreError("HTTPReceiverConstructionError"); +const HTTPReceiverPortNotNumberError = createCoreError("HTTPReceiverPortNotNumberError"); +const HTTPReceiverRequestError = createCoreError("HTTPReceiverRequestError"); +const OAuthInstallerNotInitializedError = createCoreError("OAuthInstallerNotInitializedError"); +const OAuthTokenDoesNotExistError = createCoreError("OAuthTokenDoesNotExistError"); +const OAuthTokenFetchFailedError = createCoreError("OAuthTokenFetchFailedError"); +const OAuthTokenRawResponseError = createCoreError("OAuthTokenRawResponseError"); +const OAuthTokenRefreshFailedError = createCoreError("OAuthTokenRefreshFailedError"); +const OAuthStateVerificationFailedError = createCoreError("OAuthStateVerificationFailedError"); +const ProductClientConstructionError = createCoreError("ProductClientConstructionError"); + +var LogLevel; +(function (LogLevel) { + LogLevel["ERROR"] = "error"; + LogLevel["WARN"] = "warn"; + LogLevel["INFO"] = "info"; + LogLevel["DEBUG"] = "debug"; +})(LogLevel || (LogLevel = {})); +class ConsoleLogger { + level; + name; + static labels = (() => { + const entries = Object.entries(LogLevel); + const map = entries.map(([key, value]) => [value, `[${key}] `]); + return new Map(map); + })(); + static severity = { + [LogLevel.ERROR]: 400, + [LogLevel.WARN]: 300, + [LogLevel.INFO]: 200, + [LogLevel.DEBUG]: 100 + }; + constructor() { + this.level = LogLevel.INFO; + this.name = ""; + } + getLevel() { + return this.level; + } + setLevel(level) { + this.level = level; + } + setName(name) { + this.name = name; + } + debug(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.DEBUG, this.level)) { + console.debug(ConsoleLogger.labels.get(LogLevel.DEBUG), this.name, ...msg); + } + } + info(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.INFO, this.level)) { + console.info(ConsoleLogger.labels.get(LogLevel.INFO), this.name, ...msg); + } + } + warn(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.WARN, this.level)) { + console.warn(ConsoleLogger.labels.get(LogLevel.WARN), this.name, ...msg); + } + } + error(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.ERROR, this.level)) { + console.error(ConsoleLogger.labels.get(LogLevel.ERROR), this.name, ...msg); + } + } + static isMoreOrEqualSevere(a, b) { + return ConsoleLogger.severity[a] >= ConsoleLogger.severity[b]; + } +} + +/** @internal */ +const hashUrlValidationEvent = ({ payload: { plainToken } }, webhooksSecretToken) => ({ + encryptedToken: createHmac("sha256", webhooksSecretToken).update(plainToken).digest("hex"), + plainToken +}); +const isHashedUrlValidation = (obj) => typeof obj.encryptedToken === "string" && + typeof obj.plainToken === "string"; +const isRawUrlValidationEvent = (obj) => obj.event === "endpoint.url_validation" && typeof obj.payload.plainToken === "string"; +const isSkeletonEvent = (obj) => typeof obj.event === "string"; +class CommonHttpRequest { + headers; + payload; + webhooksSecretToken; + constructor(headers, payload, webhooksSecretToken) { + this.headers = headers; + this.payload = payload; + this.webhooksSecretToken = webhooksSecretToken; + } + static buildFromAwsEvent({ body, headers, isBase64Encoded }, webhooksSecretToken) { + try { + const rawBody = body ?? ""; + const decodedBody = isBase64Encoded ? Buffer.from(rawBody, "base64").toString("ascii") : rawBody; + const payload = JSON.parse(decodedBody); + return new CommonHttpRequest(headers, payload, webhooksSecretToken); + } + catch (err) { + throw err instanceof SyntaxError ? + new CommonHttpRequestError("Failed to parse payload string to JSON.", err) + : err; + } + } + static async buildFromIncomingMessage(incomingMessage, webhooksSecretToken) { + const bufferAsString = () => { + return new Promise((resolve, reject) => { + const body = []; + incomingMessage.on("data", (chunk) => body.push(chunk)); + incomingMessage.on("error", (err) => { + reject(err); + }); + incomingMessage.on("end", () => { + resolve(Buffer.concat(body).toString()); + }); + }); + }; + try { + const payload = JSON.parse(await bufferAsString()); + return new CommonHttpRequest(incomingMessage.headers, payload, webhooksSecretToken); + } + catch (err) { + if (err instanceof SyntaxError) { + throw new CommonHttpRequestError("Failed to parse payload string to JSON.", err); + } + throw err; + } + } + isEventVerified() { + const { signature, requestTimestamp } = this.parseHeaders(); + const messageToVerify = `v0:${requestTimestamp.toString()}:${JSON.stringify(this.payload)}`; + const hashToVerify = createHmac("sha256", this.webhooksSecretToken).update(messageToVerify).digest("hex"); + const signatureToVerify = `v0=${hashToVerify}`; + return signatureToVerify === signature; + } + parseHeaders() { + const findHeader = (header) => { + const foundHeader = Object.keys(this.headers).find((key) => key.toLowerCase() === header.toLowerCase()); + return foundHeader && this.headers[foundHeader]; + }; + const headerSignature = findHeader("x-zm-signature"); + const headerRequestTimestamp = findHeader("x-zm-request-timestamp"); + if (!headerSignature && !headerRequestTimestamp) { + throw new CommonHttpRequestError("Request payload must have signature and request timestamp from Zoom."); + } + return { + signature: headerSignature, + requestTimestamp: Number(headerRequestTimestamp) + }; + } + processEvent() { + if (!isSkeletonEvent(this.payload)) { + throw new CommonHttpRequestError("Request payload structure does not match expected from Zoom."); + } + if (!this.isEventVerified()) { + throw new CommonHttpRequestError("Failed to verify event originated from Zoom."); + } + if (isRawUrlValidationEvent(this.payload)) { + return hashUrlValidationEvent(this.payload, this.webhooksSecretToken); + } + return this.payload; + } +} + +var StatusCode; +(function (StatusCode) { + StatusCode[StatusCode["OK"] = 200] = "OK"; + StatusCode[StatusCode["TEMPORARY_REDIRECT"] = 302] = "TEMPORARY_REDIRECT"; + StatusCode[StatusCode["BAD_REQUEST"] = 400] = "BAD_REQUEST"; + StatusCode[StatusCode["NOT_FOUND"] = 404] = "NOT_FOUND"; + StatusCode[StatusCode["METHOD_NOT_ALLOWED"] = 405] = "METHOD_NOT_ALLOWED"; + StatusCode[StatusCode["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR"; +})(StatusCode || (StatusCode = {})); + +class AwsLambdaReceiver { + eventEmitter; + webhooksSecretToken; + constructor({ webhooksSecretToken }) { + this.webhooksSecretToken = webhooksSecretToken; + } + buildResponse(statusCode, body) { + return { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + statusCode + }; + } + canInstall() { + return false; + } + init({ eventEmitter }) { + this.eventEmitter = eventEmitter; + } + start() { + return async (event, context) => { + console.debug("Processing Lambda event ", JSON.stringify(event), " with context ", JSON.stringify(context)); + try { + const request = CommonHttpRequest.buildFromAwsEvent(event, this.webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + return this.buildResponse(StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + return this.buildResponse(StatusCode.OK, { message: "Zoom event processed successfully." }); + } + } + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + return this.buildResponse(StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + return this.buildResponse(StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } + } + }; + } + async stop() { + return Promise.resolve(); + } +} + +const prependSlashes = (strs) => { + const rawStrs = Array.isArray(strs) ? strs : [strs]; + const mappedStrs = rawStrs.map((rawStr) => (rawStr.startsWith("/") ? rawStr : `/${rawStr}`)); + return (Array.isArray(strs) ? mappedStrs : mappedStrs[0]); +}; + +class TokenMemoryStore { + currentToken; + getLatestToken() { + return this.currentToken; + } + storeToken(token) { + this.currentToken = token; + } +} + +/** @internal */ +const EXPIRATION_DELTA_SECONDS = 60; +/** @internal */ +const OAUTH_BASE_URL = "https://zoom.us"; +/** @internal */ +const OAUTH_TOKEN_PATH = "/oauth/token"; +/** + * {@link Auth} is the base implementation of authentication for Zoom's APIs. + * + * It only requires a `clientId` and `tokenStore`, as these options are shared across + * all authentication implementations, namely OAuth and server-to-server auth (client + * credentials, JWT, and server-to-server OAuth.) + */ +class Auth { + clientId; + clientSecret; + tokenStore; + logger; + constructor({ clientId, clientSecret, tokenStore, logger }) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.tokenStore = tokenStore ?? new TokenMemoryStore(); + this.logger = logger; + } + getBasicAuthorization() { + const clientCredentials = `${this.clientId}:${this.clientSecret}`; + return Buffer$1.from(clientCredentials).toString("base64"); + } + isAlmostExpired(isoTime) { + const currentDate = dayjs(); + return dayjs(isoTime).diff(currentDate, "seconds") <= EXPIRATION_DELTA_SECONDS; + } + async makeOAuthTokenRequest(grantType, payload) { + return await axios({ + method: "POST", + url: new URL(OAUTH_TOKEN_PATH, OAUTH_BASE_URL).toString(), + headers: { + Authorization: `Basic ${this.getBasicAuthorization()}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + data: new URLSearchParams({ grant_type: grantType, ...payload }), + validateStatus: (status) => status >= 200 && status <= 299 + }); + } +} + +const DEFAULT_EXPIRATION_SECONDS = 300; // 5 minutes +/** @internal */ +const ISSUER_URN = "urn:zoom:rivet-sdk"; +class JwtStateStore { + encodedSecret; + expirationSeconds; + constructor({ expirationSeconds, stateSecret }) { + this.encodedSecret = new TextEncoder().encode(stateSecret); + this.expirationSeconds = expirationSeconds ?? DEFAULT_EXPIRATION_SECONDS; + } + async generateState() { + const issuedTime = dayjs(); + const expirationTime = issuedTime.add(this.expirationSeconds, "seconds"); + return await new SignJWT({ random: randomBytes(8).toString("hex") }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setExpirationTime(expirationTime.toDate()) + .setIssuedAt(issuedTime.toDate()) + .setIssuer(ISSUER_URN) + .sign(this.encodedSecret); + } + async verifyState(state) { + try { + await jwtVerify(state, this.encodedSecret, { + algorithms: ["HS256"], + issuer: ISSUER_URN, + typ: "JWT" + }); + } + catch (err) { + throw new OAuthStateVerificationFailedError(`Failed to verify OAuth state: ${err.name}.`, { + cause: err + }); + } + } +} + +const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; +const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds +const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; +const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && + typeof obj.installerOptions.stateStore !== "undefined"; +/** + * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication + * is initiated server-side, but requires manual authorization from a user, by redirecting the user to Zoom. + * + * In addition to all required fields from {@link AuthOptions}, this class requires a `redirectUri`, as this + * value is appended to the authorization URL when the user is redirected to Zoom and subsequently redirected + * back to an endpoint on this server. + * + * @see {@link https://developers.zoom.us/docs/integrations/oauth/ | OAuth - Zoom Developers} + */ +class InteractiveAuth extends Auth { + installerOptions; + async getAuthorizationUrl() { + if (!this.installerOptions?.stateStore) { + throw new OAuthInstallerNotInitializedError("Cannot generate authorization URL, state store not initialized."); + } + const authUrl = new URL(OAUTH_AUTHORIZE_PATH, OAUTH_BASE_URL); + const generatedState = await Promise.resolve(this.installerOptions.stateStore.generateState()); + const { searchParams } = authUrl; + searchParams.set("client_id", this.clientId); + searchParams.set("redirect_uri", this.getFullRedirectUri()); + searchParams.set("response_type", "code"); + searchParams.set("state", generatedState); + return { + fullUrl: authUrl.toString(), + generatedState + }; + } + getFullRedirectUri() { + if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { + throw new OAuthInstallerNotInitializedError("Cannot generate full redirect URI, redirect URI or redirect URI path not initialized."); + } + return new URL(this.installerOptions.redirectUriPath, this.installerOptions.redirectUri).toString(); + } + // Don't return a type; we want it to be as narrow as possible (used for ReturnType). + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { + const updatedOptions = { + directInstall: Boolean(directInstall), + installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, + redirectUri, + redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE + }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } + this.installerOptions = updatedOptions; + return updatedOptions; + } +} + +const mergeDefaultOptions = (options, defaultOptions) => ({ ...defaultOptions, ...options }); + +const withDefaultTemplate = (cardContent, buttonContent) => ` + + + Zoom Rivet + + + + + + + + + +`; +/** + * Get the default HTML template that is shown to the developer/user when they visit the + * `installPath` endpoint, if Rivet currently has OAuth enabled. + * + * If `directInstall` is set to `true`, this function is not called; instead, the developer + * is directly redirected to Zoom's OAuth page. + */ +const defaultInstallTemplate = (authUrl) => withDefaultTemplate(`

Click the button below to navigate to Zoom to authorize your application for use with Rivet.

`, { href: authUrl, text: "Authorize with Zoom" }); +/** + * Get the default HTML template that is shown to the developer/user when they successfully + * authorize Rivet with a Zoom application. This is shown once they have already been redirected + * to Zoom, and the authorization attempt with Rivet was successful. + */ +const defaultCallbackSuccessTemplate = () => withDefaultTemplate(`

Your application has been successfully authorized with Rivet!

+

You may now close this page, or click the button below to redirect to Zoom's Marketplace.

`, { href: "https://marketplace.zoom.us", text: "Go to Marketplace" }); +/** + * Get the default HTML template that is shown to the developer when a known error occurs, meaning + * that the error is a core Rivet error. + */ +const defaultCallbackKnownErrorTemplate = (errName, errMessage) => withDefaultTemplate(`

An error occurred authorizing Rivet with Zoom.

+

[${errName}]: ${errMessage}

`); +/** + * Get the default HTML template that is shown to the developer when an unknown error occurs, + * meaning that the error is not known to be a core Rivet error and was thrown and not wrapped elsewhere. + */ +const defaultCallbackUnknownErrorTemplate = () => withDefaultTemplate(`

An unknown error occurred authorizing Rivet with Zoom. Please see stacktrace for details.

+

Please see stacktrace for further details.

`); + +const secureServerOptionKeys = [ + "ALPNProtocols", + "clientCertEngine", + "enableTrace", + "handshakeTimeout", + "rejectUnauthorized", + "requestCert", + "sessionTimeout", + "SNICallback", + "ticketKeys", + "pskCallback", + "pskIdentityHint", + "ca", + "cert", + "sigalgs", + "ciphers", + "clientCertEngine", + "crl", + "dhparam", + "ecdhCurve", + "honorCipherOrder", + "key", + "privateKeyEngine", + "privateKeyIdentifier", + "maxVersion", + "minVersion", + "passphrase", + "pfx", + "secureOptions", + "secureProtocol", + "sessionIdContext" +]; +class HttpReceiver { + /** @internal */ + static DEFAULT_ENDPOINT = "/zoom/events"; + eventEmitter; + interactiveAuth; + /** @internal */ + options; + server; + logger; + constructor(options) { + this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); + this.options.endpoints = prependSlashes(this.options.endpoints); + this.logger = + options.logger ?? + (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(options.logLevel ?? LogLevel.ERROR); + return defaultLogger; + })(); + } + canInstall() { + return true; + } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } + getServerCreator() { + return this.hasSecureOptions() ? createServer : createServer$1; + } + hasEndpoint(pathname) { + const { endpoints } = this.options; + return Array.isArray(endpoints) ? endpoints.includes(pathname) : endpoints === pathname; + } + hasSecureOptions() { + return Object.keys(this.options).some((option) => secureServerOptionKeys.includes(option)); + } + init({ eventEmitter, interactiveAuth }) { + this.eventEmitter = eventEmitter; + this.interactiveAuth = interactiveAuth; + } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } + start(port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { + const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; + this.logger.error(errorMessage); + throw new HTTPReceiverPortNotNumberError(errorMessage); + } + const listenPort = port ?? this.options.port; + return new Promise((resolve, reject) => { + this.server = this.getServerCreator()(this.options, (req, res) => void (async () => { + // `req.headers.host` should be used with care, as clients can manipulate this value. + // However, for this use case, the value is completely discarded and only `pathname` + // is used, which is why there's no further validation occurring. + const { pathname, searchParams } = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`); + const { interactiveAuth } = this; + this.logger.debug([pathname, searchParams]); + // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath + if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { + const { installerOptions } = interactiveAuth; + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); + await (installerOptions.directInstall ? + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); + return; + } + // The user has navigated to the redirect page; init the code + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); + try { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { + const errorMessage = "OAuth callback did not include code and/or state in request."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); + return; + } + catch (err) { + const htmlTemplate = isCoreError(err) ? + defaultCallbackKnownErrorTemplate(err.name, err.message) + : defaultCallbackUnknownErrorTemplate(); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); + return; + } + } + } + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; + } + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; + } + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } + } + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } + } + } + })()); + this.server.on("close", () => (this.server = undefined)); + this.server.on("error", (err) => { + this.logger.error(err.message); + reject(err); + }); + this.server.listen(listenPort, () => { + if (!this.server) { + throw new ReceiverInconsistentStateError(); + } + const { port: listeningPort } = this.server.address(); + this.logger.info(`Listening on port ${listeningPort.toString()}`); + resolve(this.server); + }); + }); + } + stop() { + if (!this.server) { + throw new ReceiverInconsistentStateError(); + } + return new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) { + this.logger.error(err.message); + reject(err); + } + }); + this.server = undefined; + resolve(); + }); + } + writeTemporaryRedirect(res, location, setCookie) { + return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } + res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); + res.end(() => { + resolve(); + }); + }); + } + writeResponse(res, statusCode, bodyContent, setCookie) { + return new Promise((resolve) => { + const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; + bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } + res.writeHead(statusCode, { "Content-Type": mimeType }); + res.end(bodyContent, () => { + resolve(); + }); + }); + } +} + +const version = "0.3.0"; + +class WebEndpoints { + /** @internal */ + static DEFAULT_BASE_URL = "https://api.zoom.us/v2"; + /** @internal */ + static DEFAULT_MIME_TYPE = "application/json"; + /** @internal */ + static DEFAULT_TIMEOUT = 0; + /** @internal */ + static GENERIC_ERROR_MESSAGE = "Request was unsuccessful with no further context"; + /** @internal */ + static TRACKING_ID_HEADER = "x-zm-trackingid"; + /** @internal */ + options; + constructor(options) { + this.options = mergeDefaultOptions(options, { + baseUrl: WebEndpoints.DEFAULT_BASE_URL, + hasCustomBaseUrl: typeof options.baseUrl !== "undefined", + timeout: WebEndpoints.DEFAULT_TIMEOUT + }); + } + buildEndpoint({ method, baseUrlOverride, urlPathBuilder, requestMimeType }) { + // @ts-expect-error: Some arguments may not be present, but we pass them to makeRequest() anyway. + // prettier-ignore + // Next AST node is ignored by Prettier, even though it exceed maximum line length, because TypeScript + // won't allow ts-expect-error directive on multiple lines (https://github.com/Microsoft/TypeScript/issues/19573). + return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); + } + buildUserAgent() { + return (`rivet/${version} ` + + `${basename(process.title)}/${process.version.replace("v", "")} ` + + `${os.platform()}/${os.release()}`); + } + getHeaders(bearerToken, contentType) { + return { + Accept: "application/json", + Authorization: `Bearer ${bearerToken}`, + "Content-Type": contentType, + "User-Agent": this.buildUserAgent() + }; + } + getRequestBody(args, mimeType) { + if (mimeType === "multipart/form-data") { + const formData = new FormData(); + Object.entries(args).forEach(([key, value]) => { + formData.append(key, value); + }); + return formData; + } + return args; + } + isOk(response) { + return response.status >= 200 && response.status <= 299; + } + isZoomResponseError(obj) { + return (typeof obj.code !== "undefined" && + typeof obj.message !== "undefined"); + } + async makeRequest(method, baseUrlOverride, url, requestContentType, bodyArgs, queryArgs) { + const { auth, baseUrl, doubleEncodeUrl, hasCustomBaseUrl, timeout } = this.options; + const bearerToken = await Promise.resolve(auth.getToken()); + const urlToSend = doubleEncodeUrl ? encodeURIComponent(encodeURIComponent(url)) : url; + const response = await axios({ + url: urlToSend, + method, + baseURL: hasCustomBaseUrl ? baseUrl : (baseUrlOverride ?? baseUrl), + headers: this.getHeaders(bearerToken, requestContentType), + params: queryArgs, + data: bodyArgs && this.getRequestBody(bodyArgs, requestContentType), + timeout: timeout, + beforeRedirect: (options) => { + options.headers = { + ...this.getHeaders(bearerToken, requestContentType), + ...options.headers + }; + }, + validateStatus: () => true // All responses are valid, not just 2xx + }); + if (!this.isOk(response)) { + const { status: statusCode } = response; + if (this.isZoomResponseError(response.data)) { + const { code: errorCode, message: errorMessage } = response.data; + throw new ApiResponseError(`[${statusCode.toString()}/${errorCode.toString()}]: "${errorMessage}"`); + } + throw new ApiResponseError(`[${statusCode.toString()}]: ${WebEndpoints.GENERIC_ERROR_MESSAGE}`); + } + return { + data: response.data, + statusCode: response.status, + trackingId: response.headers[WebEndpoints.TRACKING_ID_HEADER] + }; + } +} + +class CommerceEndpoints extends WebEndpoints { + accountManagement = { + createEndCustomerAccount: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/account` }), + addContactsToExistingEndCustomerOrYourOwnAccount: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ accountKey }) => `/commerce/account/${accountKey}/contacts` }), + getsListOfAllAccountsAssociatedWithZoomPartnerSubResellerByAccountType: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/accounts` }), + getAccountDetailsForZoomPartnerSubResellerEndCustomer: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ accountKey }) => `/commerce/accounts/${accountKey}` }) + }; + billing = { + getsAllBillingDocumentsForDistributorOrReseller: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/billing_documents` }), + getsPDFDocumentForBillingDocumentID: this.buildEndpoint({ + method: "GET", + urlPathBuilder: ({ documentNumber }) => `/commerce/billing_documents/${documentNumber}/document` + }), + getDetailedInformationAboutSpecificInvoiceForDistributorOrReseller: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ invoiceNumber }) => `/commerce/invoices/${invoiceNumber}` }) + }; + dealRegistration = { + retrievesAllValidZoomCampaignsWhichDealRegistrationCanBeAssociatedWith: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/campaigns` }), + createsNewDealRegistrationForPartner: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/deal_registration` }), + getsAllValidDealRegistrationsForPartner: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/deal_registrations` }), + getsDetailsForDealRegistrationByDealRegistrationNumber: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ dealRegKey }) => `/commerce/deal_registrations/${dealRegKey}` }), + updatesExistingDealRegistration: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ dealRegKey }) => `/commerce/deal_registrations/${dealRegKey}` }) + }; + order = { + createsSubscriptionOrderForZoomPartner: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/order` }), + previewDeltaOrderMetricsAndSubscriptionsInOrder: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/order/preview` }), + getsAllOrdersForZoomPartner: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/orders` }), + getsOrderDetailsByOrderReferenceID: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ orderReferenceId }) => `/commerce/orders/${orderReferenceId}` }) + }; + productCatalog = { + getsZoomProductCatalogForZoomPartner: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/commerce/catalog` }), + getsDetailsForZoomProductOrOffer: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ offerId }) => `/commerce/catalog/${offerId}` }), + getsPricebookInDownloadableFile: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/pricebooks` }) + }; + subscription = { + getsSubscriptionsForZoomPartner: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/commerce/subscriptions` }), + getsSubscriptionDetailsForGivenSubscriptionNumber: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ subscriptionNumber }) => `/commerce/subscriptions/${subscriptionNumber}` }), + getsSubscriptionChangesVersionsForGivenSubscriptionNumber: this.buildEndpoint({ + method: "GET", + urlPathBuilder: ({ subscriptionNumber }) => `/commerce/subscriptions/${subscriptionNumber}/versions` + }) + }; +} + +class OAuth extends InteractiveAuth { + assertResponseAccessToken(data) { + if (typeof data.access_token !== "string" || + typeof data.refresh_token !== "string" || + typeof data.expires_in !== "number" || + typeof data.scope !== "string") { + throw new OAuthTokenRawResponseError(`Failed to match raw response (${JSON.stringify(data)}) to expected shape.`); + } + } + async fetchAccessToken(code) { + try { + const response = await this.makeOAuthTokenRequest("authorization_code", { + code, + redirect_uri: this.getFullRedirectUri() + }); + this.assertResponseAccessToken(response.data); + return this.mapOAuthToken(response.data); + } + catch (err) { + throw new OAuthTokenFetchFailedError("Failed to fetch OAuth token.", { cause: err }); + } + } + async getToken() { + const { tokenStore } = this; + const currentToken = await Promise.resolve(tokenStore.getLatestToken()); + // If we have no OAuth token, app most likely has not been previously authorized. + if (!currentToken) { + throw new OAuthTokenDoesNotExistError("Failed to find OAuth token. Authorize this app first."); + } + // If the OAuth token hasn't already expired (and isn't within the delta), return it. + if (!this.isAlmostExpired(currentToken.expirationTimeIso)) { + return currentToken.accessToken; + } + // Since the token has expired, refresh, store, and return it. + const refreshedToken = await this.refreshAccessToken(currentToken.refreshToken); + await Promise.resolve(tokenStore.storeToken(refreshedToken)); + return refreshedToken.accessToken; + } + async initRedirectCode(code) { + const { tokenStore } = this; + const accessToken = await this.fetchAccessToken(code); + await Promise.resolve(tokenStore.storeToken(accessToken)); + } + mapOAuthToken({ access_token, expires_in, refresh_token, scope }) { + return { + accessToken: access_token, + expirationTimeIso: dayjs().add(expires_in, "seconds").toISOString(), + refreshToken: refresh_token, + scopes: scope.includes(" ") ? scope.split(" ") : [scope] + }; + } + async refreshAccessToken(refreshToken) { + try { + const response = await this.makeOAuthTokenRequest("refresh_token", { + refresh_token: refreshToken + }); + this.assertResponseAccessToken(response.data); + return this.mapOAuthToken(response.data); + } + catch (err) { + throw new OAuthTokenRefreshFailedError("Failed to refresh OAuth token.", { cause: err }); + } + } +} + +// Utility functions for determining if client options include custom receiver, or, if not, +// a webhooks secret token, as one of those is required! +const hasExplicitReceiver = (obj) => typeof obj.receiver !== "undefined"; +const hasWebhooksSecretToken = (obj) => typeof obj.webhooksSecretToken !== "undefined"; +const isReceiverDisabled = (options) => typeof options.disableReceiver !== "undefined" && options.disableReceiver; +const DEFAULT_HTTP_RECEIVER_PORT = 8080; +const DEFAULT_LOGLEVEL = LogLevel.ERROR; +class ProductClient { + auth; + endpoints; + webEventConsumer; + receiver; + constructor(options) { + this.auth = this.initAuth(options); + this.endpoints = this.initEndpoints(this.auth, options); + this.webEventConsumer = this.initEventProcessor(this.endpoints, options); + // Only create an instance of `this.receiver` if the developer did not explicitly disable it. + if (!isReceiverDisabled(options)) { + // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); + } + this.receiver = (hasExplicitReceiver(options) ? + options.receiver + : this.initDefaultReceiver(options)); + this.receiver.init({ + eventEmitter: this.webEventConsumer, + interactiveAuth: this.auth instanceof InteractiveAuth ? this.auth : undefined + }); + } + } + initDefaultReceiver({ port, webhooksSecretToken, logLevel }) { + return new HttpReceiver({ + port: port ?? DEFAULT_HTTP_RECEIVER_PORT, + webhooksSecretToken, + logLevel: logLevel ?? DEFAULT_LOGLEVEL + }); + } + async start() { + if (!this.receiver) { + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); + } + // Method call is wrapped in `await` and `Promise.resolve()`, as the call + // may or may not return a promise. This is not required when implementing `Receiver`. + return (await Promise.resolve(this.receiver.start())); + } +} + +class CommerceOAuthClient extends ProductClient { + initAuth({ clientId, clientSecret, tokenStore, ...restOptions }) { + const oAuth = new OAuth({ clientId, clientSecret, tokenStore }); + if (hasInstallerOptions(restOptions)) { + oAuth.setInstallerOptions(restOptions.installerOptions); + } + return oAuth; + } + initEndpoints(auth, options) { + return new CommerceEndpoints({ auth, ...options }); + } + initEventProcessor() { + return undefined; + } +} + +export { ApiResponseError, AwsLambdaReceiver, AwsReceiverRequestError, ClientCredentialsRawResponseError, CommerceEndpoints, CommerceOAuthClient, CommonHttpRequestError, ConsoleLogger, HTTPReceiverConstructionError, HTTPReceiverPortNotNumberError, HTTPReceiverRequestError, HttpReceiver, LogLevel, OAuthInstallerNotInitializedError, OAuthStateVerificationFailedError, OAuthTokenDoesNotExistError, OAuthTokenFetchFailedError, OAuthTokenRawResponseError, OAuthTokenRefreshFailedError, ProductClientConstructionError, ReceiverInconsistentStateError, ReceiverOAuthFlowError, S2SRawResponseError, StatusCode, isCoreError, isStateStore }; diff --git a/index.cjs b/index.cjs index e017a85..6700b2a 100644 --- a/index.cjs +++ b/index.cjs @@ -1,8 +1,10 @@ const Chatbot = require("./chatbot/chatbot.cjs"); +const Commerce = require("./commerce/commerce.cjs"); const TeamChat = require("./teamchat/teamchat.cjs"); const Users = require("./users/users.cjs"); +const Marketplace = require("./marketplace/marketplace.cjs"); const Phone = require("./phone/phone.cjs"); const Accounts = require("./accounts/accounts.cjs"); const Meetings = require("./meetings/meetings.cjs"); const VideoSdk = require("./videosdk/videosdk.cjs"); -module.exports = { Chatbot, TeamChat, Users, Phone, Accounts, Meetings, VideoSdk }; \ No newline at end of file +module.exports = { Chatbot, Commerce, TeamChat, Users, Marketplace, Phone, Accounts, Meetings, VideoSdk }; \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index ee512b9..30bbbd8 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,13 +1,17 @@ import Chatbot from "./chatbot/chatbot.d.ts"; +import Commerce from "./commerce/commerce.d.ts"; import TeamChat from "./teamchat/teamchat.d.ts"; import Users from "./users/users.d.ts"; +import Marketplace from "./marketplace/marketplace.d.ts"; import Phone from "./phone/phone.d.ts"; import Accounts from "./accounts/accounts.d.ts"; import Meetings from "./meetings/meetings.d.ts"; import VideoSdk from "./videosdk/videosdk.d.ts"; export import Chatbot = Chatbot; +export import Commerce = Commerce; export import TeamChat = TeamChat; export import Users = Users; +export import Marketplace = Marketplace; export import Phone = Phone; export import Accounts = Accounts; export import Meetings = Meetings; diff --git a/index.mjs b/index.mjs index 0031bae..463a937 100644 --- a/index.mjs +++ b/index.mjs @@ -1,8 +1,10 @@ import Chatbot from "./chatbot/chatbot.mjs"; +import Commerce from "./commerce/commerce.mjs"; import TeamChat from "./teamchat/teamchat.mjs"; import Users from "./users/users.mjs"; +import Marketplace from "./marketplace/marketplace.mjs"; import Phone from "./phone/phone.mjs"; import Accounts from "./accounts/accounts.mjs"; import Meetings from "./meetings/meetings.mjs"; import VideoSdk from "./videosdk/videosdk.mjs"; -export { Chatbot, TeamChat, Users, Phone, Accounts, Meetings, VideoSdk }; \ No newline at end of file +export { Chatbot, Commerce, TeamChat, Users, Marketplace, Phone, Accounts, Meetings, VideoSdk }; \ No newline at end of file diff --git a/marketplace/marketplace.cjs b/marketplace/marketplace.cjs new file mode 100644 index 0000000..5214268 --- /dev/null +++ b/marketplace/marketplace.cjs @@ -0,0 +1,1152 @@ +'use strict'; + +var node_crypto = require('node:crypto'); +var node_http = require('node:http'); +var node_https = require('node:https'); +var axios = require('axios'); +var dayjs = require('dayjs'); +var node_buffer = require('node:buffer'); +var jose = require('jose'); +var FormData = require('form-data'); +var os = require('node:os'); +var node_path = require('node:path'); + +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +const isStateStore = (obj) => typeof obj.generateState === "function" && typeof obj.verifyState === "function"; + +const createRivetErrors = (errors) => ({ + createError: (errorCode) => class extends Error { + errorCode = errors[errorCode]; + constructor(message, options) { + const errorMessage = (message ?? + (options?.cause instanceof Error ? options.cause.message : errorCode)); + super(errorMessage, options); + this.name = errorCode; + Object.setPrototypeOf(this, new.target.prototype); + } + }, + isError: (obj, key) => key ? + Object.keys(errors).some((code) => code === key) && + typeof obj.errorCode === "string" && + obj.errorCode === errors[key] + : typeof obj.errorCode === "string" +}); + +const coreErrors = { + ApiResponseError: "zoom_rivet_api_response_error", + AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error", + ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error", + S2SRawResponseError: "zoom_rivet_s2s_raw_response_error", + CommonHttpRequestError: "zoom_rivet_common_http_request_error", + ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error", + ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error", + HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error", + HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error", + HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error", + OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error", + OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error", + OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error", + OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error", + OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error", + OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error", + ProductClientConstructionError: "zoom_rivet_product_client_construction_error" +}; +const { createError: createCoreError, isError: isCoreError } = createRivetErrors(coreErrors); +const ApiResponseError = createCoreError("ApiResponseError"); +const AwsReceiverRequestError = createCoreError("AwsReceiverRequestError"); +const ClientCredentialsRawResponseError = createCoreError("ClientCredentialsRawResponseError"); +const S2SRawResponseError = createCoreError("S2SRawResponseError"); +const CommonHttpRequestError = createCoreError("CommonHttpRequestError"); +const ReceiverInconsistentStateError = createCoreError("ReceiverInconsistentStateError"); +const ReceiverOAuthFlowError = createCoreError("ReceiverOAuthFlowError"); +const HTTPReceiverConstructionError = createCoreError("HTTPReceiverConstructionError"); +const HTTPReceiverPortNotNumberError = createCoreError("HTTPReceiverPortNotNumberError"); +const HTTPReceiverRequestError = createCoreError("HTTPReceiverRequestError"); +const OAuthInstallerNotInitializedError = createCoreError("OAuthInstallerNotInitializedError"); +const OAuthTokenDoesNotExistError = createCoreError("OAuthTokenDoesNotExistError"); +const OAuthTokenFetchFailedError = createCoreError("OAuthTokenFetchFailedError"); +const OAuthTokenRawResponseError = createCoreError("OAuthTokenRawResponseError"); +const OAuthTokenRefreshFailedError = createCoreError("OAuthTokenRefreshFailedError"); +const OAuthStateVerificationFailedError = createCoreError("OAuthStateVerificationFailedError"); +const ProductClientConstructionError = createCoreError("ProductClientConstructionError"); + +exports.LogLevel = void 0; +(function (LogLevel) { + LogLevel["ERROR"] = "error"; + LogLevel["WARN"] = "warn"; + LogLevel["INFO"] = "info"; + LogLevel["DEBUG"] = "debug"; +})(exports.LogLevel || (exports.LogLevel = {})); +class ConsoleLogger { + level; + name; + static labels = (() => { + const entries = Object.entries(exports.LogLevel); + const map = entries.map(([key, value]) => [value, `[${key}] `]); + return new Map(map); + })(); + static severity = { + [exports.LogLevel.ERROR]: 400, + [exports.LogLevel.WARN]: 300, + [exports.LogLevel.INFO]: 200, + [exports.LogLevel.DEBUG]: 100 + }; + constructor() { + this.level = exports.LogLevel.INFO; + this.name = ""; + } + getLevel() { + return this.level; + } + setLevel(level) { + this.level = level; + } + setName(name) { + this.name = name; + } + debug(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(exports.LogLevel.DEBUG, this.level)) { + console.debug(ConsoleLogger.labels.get(exports.LogLevel.DEBUG), this.name, ...msg); + } + } + info(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(exports.LogLevel.INFO, this.level)) { + console.info(ConsoleLogger.labels.get(exports.LogLevel.INFO), this.name, ...msg); + } + } + warn(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(exports.LogLevel.WARN, this.level)) { + console.warn(ConsoleLogger.labels.get(exports.LogLevel.WARN), this.name, ...msg); + } + } + error(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(exports.LogLevel.ERROR, this.level)) { + console.error(ConsoleLogger.labels.get(exports.LogLevel.ERROR), this.name, ...msg); + } + } + static isMoreOrEqualSevere(a, b) { + return ConsoleLogger.severity[a] >= ConsoleLogger.severity[b]; + } +} + +class EventManager { + endpoints; + /** @internal */ + listeners; + constructor(endpoints) { + this.endpoints = endpoints; + this.listeners = {}; + } + appendListener(eventName, predicate, listener) { + if (this.listeners[eventName]) { + this.listeners[eventName].push({ predicate, listener }); + } + else { + this.listeners[eventName] = [{ predicate, listener }]; + } + } + filteredEvent(eventName, predicate, listener) { + if (typeof predicate !== "function" || typeof listener !== "function") { + throw new Error("Event predicate and listener must be of type function."); + } + this.appendListener(eventName, predicate, listener); + } + async emit(eventName, payload) { + if (!this.listeners[eventName]) + return; + await Promise.all(this.listeners[eventName].map(async ({ predicate, listener }) => { + if (typeof predicate !== "undefined" && !predicate(payload)) + return; + await Promise.resolve(listener(payload)); + })); + } + event(eventName, listener) { + if (typeof listener !== "function") { + throw new Error("Event listener must be of type function."); + } + this.appendListener(eventName, undefined, listener); + } + withContext() { + throw new Error("Method not implemented. Only to be used for type."); + } +} + +/** @internal */ +const hashUrlValidationEvent = ({ payload: { plainToken } }, webhooksSecretToken) => ({ + encryptedToken: node_crypto.createHmac("sha256", webhooksSecretToken).update(plainToken).digest("hex"), + plainToken +}); +const isHashedUrlValidation = (obj) => typeof obj.encryptedToken === "string" && + typeof obj.plainToken === "string"; +const isRawUrlValidationEvent = (obj) => obj.event === "endpoint.url_validation" && typeof obj.payload.plainToken === "string"; +const isSkeletonEvent = (obj) => typeof obj.event === "string"; +class CommonHttpRequest { + headers; + payload; + webhooksSecretToken; + constructor(headers, payload, webhooksSecretToken) { + this.headers = headers; + this.payload = payload; + this.webhooksSecretToken = webhooksSecretToken; + } + static buildFromAwsEvent({ body, headers, isBase64Encoded }, webhooksSecretToken) { + try { + const rawBody = body ?? ""; + const decodedBody = isBase64Encoded ? Buffer.from(rawBody, "base64").toString("ascii") : rawBody; + const payload = JSON.parse(decodedBody); + return new CommonHttpRequest(headers, payload, webhooksSecretToken); + } + catch (err) { + throw err instanceof SyntaxError ? + new CommonHttpRequestError("Failed to parse payload string to JSON.", err) + : err; + } + } + static async buildFromIncomingMessage(incomingMessage, webhooksSecretToken) { + const bufferAsString = () => { + return new Promise((resolve, reject) => { + const body = []; + incomingMessage.on("data", (chunk) => body.push(chunk)); + incomingMessage.on("error", (err) => { + reject(err); + }); + incomingMessage.on("end", () => { + resolve(Buffer.concat(body).toString()); + }); + }); + }; + try { + const payload = JSON.parse(await bufferAsString()); + return new CommonHttpRequest(incomingMessage.headers, payload, webhooksSecretToken); + } + catch (err) { + if (err instanceof SyntaxError) { + throw new CommonHttpRequestError("Failed to parse payload string to JSON.", err); + } + throw err; + } + } + isEventVerified() { + const { signature, requestTimestamp } = this.parseHeaders(); + const messageToVerify = `v0:${requestTimestamp.toString()}:${JSON.stringify(this.payload)}`; + const hashToVerify = node_crypto.createHmac("sha256", this.webhooksSecretToken).update(messageToVerify).digest("hex"); + const signatureToVerify = `v0=${hashToVerify}`; + return signatureToVerify === signature; + } + parseHeaders() { + const findHeader = (header) => { + const foundHeader = Object.keys(this.headers).find((key) => key.toLowerCase() === header.toLowerCase()); + return foundHeader && this.headers[foundHeader]; + }; + const headerSignature = findHeader("x-zm-signature"); + const headerRequestTimestamp = findHeader("x-zm-request-timestamp"); + if (!headerSignature && !headerRequestTimestamp) { + throw new CommonHttpRequestError("Request payload must have signature and request timestamp from Zoom."); + } + return { + signature: headerSignature, + requestTimestamp: Number(headerRequestTimestamp) + }; + } + processEvent() { + if (!isSkeletonEvent(this.payload)) { + throw new CommonHttpRequestError("Request payload structure does not match expected from Zoom."); + } + if (!this.isEventVerified()) { + throw new CommonHttpRequestError("Failed to verify event originated from Zoom."); + } + if (isRawUrlValidationEvent(this.payload)) { + return hashUrlValidationEvent(this.payload, this.webhooksSecretToken); + } + return this.payload; + } +} + +exports.StatusCode = void 0; +(function (StatusCode) { + StatusCode[StatusCode["OK"] = 200] = "OK"; + StatusCode[StatusCode["TEMPORARY_REDIRECT"] = 302] = "TEMPORARY_REDIRECT"; + StatusCode[StatusCode["BAD_REQUEST"] = 400] = "BAD_REQUEST"; + StatusCode[StatusCode["NOT_FOUND"] = 404] = "NOT_FOUND"; + StatusCode[StatusCode["METHOD_NOT_ALLOWED"] = 405] = "METHOD_NOT_ALLOWED"; + StatusCode[StatusCode["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR"; +})(exports.StatusCode || (exports.StatusCode = {})); + +class AwsLambdaReceiver { + eventEmitter; + webhooksSecretToken; + constructor({ webhooksSecretToken }) { + this.webhooksSecretToken = webhooksSecretToken; + } + buildResponse(statusCode, body) { + return { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + statusCode + }; + } + canInstall() { + return false; + } + init({ eventEmitter }) { + this.eventEmitter = eventEmitter; + } + start() { + return async (event, context) => { + console.debug("Processing Lambda event ", JSON.stringify(event), " with context ", JSON.stringify(context)); + try { + const request = CommonHttpRequest.buildFromAwsEvent(event, this.webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + return this.buildResponse(exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + return this.buildResponse(exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } + } + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + return this.buildResponse(exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + return this.buildResponse(exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } + } + }; + } + async stop() { + return Promise.resolve(); + } +} + +const prependSlashes = (strs) => { + const rawStrs = Array.isArray(strs) ? strs : [strs]; + const mappedStrs = rawStrs.map((rawStr) => (rawStr.startsWith("/") ? rawStr : `/${rawStr}`)); + return (Array.isArray(strs) ? mappedStrs : mappedStrs[0]); +}; + +class TokenMemoryStore { + currentToken; + getLatestToken() { + return this.currentToken; + } + storeToken(token) { + this.currentToken = token; + } +} + +/** @internal */ +const EXPIRATION_DELTA_SECONDS = 60; +/** @internal */ +const OAUTH_BASE_URL = "https://zoom.us"; +/** @internal */ +const OAUTH_TOKEN_PATH = "/oauth/token"; +/** + * {@link Auth} is the base implementation of authentication for Zoom's APIs. + * + * It only requires a `clientId` and `tokenStore`, as these options are shared across + * all authentication implementations, namely OAuth and server-to-server auth (client + * credentials, JWT, and server-to-server OAuth.) + */ +class Auth { + clientId; + clientSecret; + tokenStore; + logger; + constructor({ clientId, clientSecret, tokenStore, logger }) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.tokenStore = tokenStore ?? new TokenMemoryStore(); + this.logger = logger; + } + getBasicAuthorization() { + const clientCredentials = `${this.clientId}:${this.clientSecret}`; + return node_buffer.Buffer.from(clientCredentials).toString("base64"); + } + isAlmostExpired(isoTime) { + const currentDate = dayjs(); + return dayjs(isoTime).diff(currentDate, "seconds") <= EXPIRATION_DELTA_SECONDS; + } + async makeOAuthTokenRequest(grantType, payload) { + return await axios({ + method: "POST", + url: new URL(OAUTH_TOKEN_PATH, OAUTH_BASE_URL).toString(), + headers: { + Authorization: `Basic ${this.getBasicAuthorization()}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + data: new URLSearchParams({ grant_type: grantType, ...payload }), + validateStatus: (status) => status >= 200 && status <= 299 + }); + } +} + +const DEFAULT_EXPIRATION_SECONDS = 300; // 5 minutes +/** @internal */ +const ISSUER_URN = "urn:zoom:rivet-sdk"; +class JwtStateStore { + encodedSecret; + expirationSeconds; + constructor({ expirationSeconds, stateSecret }) { + this.encodedSecret = new TextEncoder().encode(stateSecret); + this.expirationSeconds = expirationSeconds ?? DEFAULT_EXPIRATION_SECONDS; + } + async generateState() { + const issuedTime = dayjs(); + const expirationTime = issuedTime.add(this.expirationSeconds, "seconds"); + return await new jose.SignJWT({ random: node_crypto.randomBytes(8).toString("hex") }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setExpirationTime(expirationTime.toDate()) + .setIssuedAt(issuedTime.toDate()) + .setIssuer(ISSUER_URN) + .sign(this.encodedSecret); + } + async verifyState(state) { + try { + await jose.jwtVerify(state, this.encodedSecret, { + algorithms: ["HS256"], + issuer: ISSUER_URN, + typ: "JWT" + }); + } + catch (err) { + throw new OAuthStateVerificationFailedError(`Failed to verify OAuth state: ${err.name}.`, { + cause: err + }); + } + } +} + +const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; +const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds +const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; +const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && + typeof obj.installerOptions.stateStore !== "undefined"; +/** + * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication + * is initiated server-side, but requires manual authorization from a user, by redirecting the user to Zoom. + * + * In addition to all required fields from {@link AuthOptions}, this class requires a `redirectUri`, as this + * value is appended to the authorization URL when the user is redirected to Zoom and subsequently redirected + * back to an endpoint on this server. + * + * @see {@link https://developers.zoom.us/docs/integrations/oauth/ | OAuth - Zoom Developers} + */ +class InteractiveAuth extends Auth { + installerOptions; + async getAuthorizationUrl() { + if (!this.installerOptions?.stateStore) { + throw new OAuthInstallerNotInitializedError("Cannot generate authorization URL, state store not initialized."); + } + const authUrl = new URL(OAUTH_AUTHORIZE_PATH, OAUTH_BASE_URL); + const generatedState = await Promise.resolve(this.installerOptions.stateStore.generateState()); + const { searchParams } = authUrl; + searchParams.set("client_id", this.clientId); + searchParams.set("redirect_uri", this.getFullRedirectUri()); + searchParams.set("response_type", "code"); + searchParams.set("state", generatedState); + return { + fullUrl: authUrl.toString(), + generatedState + }; + } + getFullRedirectUri() { + if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { + throw new OAuthInstallerNotInitializedError("Cannot generate full redirect URI, redirect URI or redirect URI path not initialized."); + } + return new URL(this.installerOptions.redirectUriPath, this.installerOptions.redirectUri).toString(); + } + // Don't return a type; we want it to be as narrow as possible (used for ReturnType). + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { + const updatedOptions = { + directInstall: Boolean(directInstall), + installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, + redirectUri, + redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE + }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } + this.installerOptions = updatedOptions; + return updatedOptions; + } +} + +const mergeDefaultOptions = (options, defaultOptions) => ({ ...defaultOptions, ...options }); + +const withDefaultTemplate = (cardContent, buttonContent) => ` + + + Zoom Rivet + + + + + + +
+
+
+

+ Zoom Rivet +

+
+
+ ${cardContent} +
+ ${buttonContent ? + `` + : ""} +
+
+ + +`; +/** + * Get the default HTML template that is shown to the developer/user when they visit the + * `installPath` endpoint, if Rivet currently has OAuth enabled. + * + * If `directInstall` is set to `true`, this function is not called; instead, the developer + * is directly redirected to Zoom's OAuth page. + */ +const defaultInstallTemplate = (authUrl) => withDefaultTemplate(`

Click the button below to navigate to Zoom to authorize your application for use with Rivet.

`, { href: authUrl, text: "Authorize with Zoom" }); +/** + * Get the default HTML template that is shown to the developer/user when they successfully + * authorize Rivet with a Zoom application. This is shown once they have already been redirected + * to Zoom, and the authorization attempt with Rivet was successful. + */ +const defaultCallbackSuccessTemplate = () => withDefaultTemplate(`

Your application has been successfully authorized with Rivet!

+

You may now close this page, or click the button below to redirect to Zoom's Marketplace.

`, { href: "https://marketplace.zoom.us", text: "Go to Marketplace" }); +/** + * Get the default HTML template that is shown to the developer when a known error occurs, meaning + * that the error is a core Rivet error. + */ +const defaultCallbackKnownErrorTemplate = (errName, errMessage) => withDefaultTemplate(`

An error occurred authorizing Rivet with Zoom.

+

[${errName}]: ${errMessage}

`); +/** + * Get the default HTML template that is shown to the developer when an unknown error occurs, + * meaning that the error is not known to be a core Rivet error and was thrown and not wrapped elsewhere. + */ +const defaultCallbackUnknownErrorTemplate = () => withDefaultTemplate(`

An unknown error occurred authorizing Rivet with Zoom. Please see stacktrace for details.

+

Please see stacktrace for further details.

`); + +const secureServerOptionKeys = [ + "ALPNProtocols", + "clientCertEngine", + "enableTrace", + "handshakeTimeout", + "rejectUnauthorized", + "requestCert", + "sessionTimeout", + "SNICallback", + "ticketKeys", + "pskCallback", + "pskIdentityHint", + "ca", + "cert", + "sigalgs", + "ciphers", + "clientCertEngine", + "crl", + "dhparam", + "ecdhCurve", + "honorCipherOrder", + "key", + "privateKeyEngine", + "privateKeyIdentifier", + "maxVersion", + "minVersion", + "passphrase", + "pfx", + "secureOptions", + "secureProtocol", + "sessionIdContext" +]; +class HttpReceiver { + /** @internal */ + static DEFAULT_ENDPOINT = "/zoom/events"; + eventEmitter; + interactiveAuth; + /** @internal */ + options; + server; + logger; + constructor(options) { + this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); + this.options.endpoints = prependSlashes(this.options.endpoints); + this.logger = + options.logger ?? + (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(options.logLevel ?? exports.LogLevel.ERROR); + return defaultLogger; + })(); + } + canInstall() { + return true; + } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } + getServerCreator() { + return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; + } + hasEndpoint(pathname) { + const { endpoints } = this.options; + return Array.isArray(endpoints) ? endpoints.includes(pathname) : endpoints === pathname; + } + hasSecureOptions() { + return Object.keys(this.options).some((option) => secureServerOptionKeys.includes(option)); + } + init({ eventEmitter, interactiveAuth }) { + this.eventEmitter = eventEmitter; + this.interactiveAuth = interactiveAuth; + } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } + start(port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { + const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; + this.logger.error(errorMessage); + throw new HTTPReceiverPortNotNumberError(errorMessage); + } + const listenPort = port ?? this.options.port; + return new Promise((resolve, reject) => { + this.server = this.getServerCreator()(this.options, (req, res) => void (async () => { + // `req.headers.host` should be used with care, as clients can manipulate this value. + // However, for this use case, the value is completely discarded and only `pathname` + // is used, which is why there's no further validation occurring. + const { pathname, searchParams } = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`); + const { interactiveAuth } = this; + this.logger.debug([pathname, searchParams]); + // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath + if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { + const { installerOptions } = interactiveAuth; + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); + await (installerOptions.directInstall ? + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); + return; + } + // The user has navigated to the redirect page; init the code + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); + try { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { + const errorMessage = "OAuth callback did not include code and/or state in request."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); + return; + } + catch (err) { + const htmlTemplate = isCoreError(err) ? + defaultCallbackKnownErrorTemplate(err.name, err.message) + : defaultCallbackUnknownErrorTemplate(); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); + return; + } + } + } + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; + } + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; + } + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } + } + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } + } + } + })()); + this.server.on("close", () => (this.server = undefined)); + this.server.on("error", (err) => { + this.logger.error(err.message); + reject(err); + }); + this.server.listen(listenPort, () => { + if (!this.server) { + throw new ReceiverInconsistentStateError(); + } + const { port: listeningPort } = this.server.address(); + this.logger.info(`Listening on port ${listeningPort.toString()}`); + resolve(this.server); + }); + }); + } + stop() { + if (!this.server) { + throw new ReceiverInconsistentStateError(); + } + return new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) { + this.logger.error(err.message); + reject(err); + } + }); + this.server = undefined; + resolve(); + }); + } + writeTemporaryRedirect(res, location, setCookie) { + return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } + res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); + res.end(() => { + resolve(); + }); + }); + } + writeResponse(res, statusCode, bodyContent, setCookie) { + return new Promise((resolve) => { + const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; + bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } + res.writeHead(statusCode, { "Content-Type": mimeType }); + res.end(bodyContent, () => { + resolve(); + }); + }); + } +} + +const version = "0.3.0"; + +class WebEndpoints { + /** @internal */ + static DEFAULT_BASE_URL = "https://api.zoom.us/v2"; + /** @internal */ + static DEFAULT_MIME_TYPE = "application/json"; + /** @internal */ + static DEFAULT_TIMEOUT = 0; + /** @internal */ + static GENERIC_ERROR_MESSAGE = "Request was unsuccessful with no further context"; + /** @internal */ + static TRACKING_ID_HEADER = "x-zm-trackingid"; + /** @internal */ + options; + constructor(options) { + this.options = mergeDefaultOptions(options, { + baseUrl: WebEndpoints.DEFAULT_BASE_URL, + hasCustomBaseUrl: typeof options.baseUrl !== "undefined", + timeout: WebEndpoints.DEFAULT_TIMEOUT + }); + } + buildEndpoint({ method, baseUrlOverride, urlPathBuilder, requestMimeType }) { + // @ts-expect-error: Some arguments may not be present, but we pass them to makeRequest() anyway. + // prettier-ignore + // Next AST node is ignored by Prettier, even though it exceed maximum line length, because TypeScript + // won't allow ts-expect-error directive on multiple lines (https://github.com/Microsoft/TypeScript/issues/19573). + return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); + } + buildUserAgent() { + return (`rivet/${version} ` + + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + + `${os.platform()}/${os.release()}`); + } + getHeaders(bearerToken, contentType) { + return { + Accept: "application/json", + Authorization: `Bearer ${bearerToken}`, + "Content-Type": contentType, + "User-Agent": this.buildUserAgent() + }; + } + getRequestBody(args, mimeType) { + if (mimeType === "multipart/form-data") { + const formData = new FormData(); + Object.entries(args).forEach(([key, value]) => { + formData.append(key, value); + }); + return formData; + } + return args; + } + isOk(response) { + return response.status >= 200 && response.status <= 299; + } + isZoomResponseError(obj) { + return (typeof obj.code !== "undefined" && + typeof obj.message !== "undefined"); + } + async makeRequest(method, baseUrlOverride, url, requestContentType, bodyArgs, queryArgs) { + const { auth, baseUrl, doubleEncodeUrl, hasCustomBaseUrl, timeout } = this.options; + const bearerToken = await Promise.resolve(auth.getToken()); + const urlToSend = doubleEncodeUrl ? encodeURIComponent(encodeURIComponent(url)) : url; + const response = await axios({ + url: urlToSend, + method, + baseURL: hasCustomBaseUrl ? baseUrl : (baseUrlOverride ?? baseUrl), + headers: this.getHeaders(bearerToken, requestContentType), + params: queryArgs, + data: bodyArgs && this.getRequestBody(bodyArgs, requestContentType), + timeout: timeout, + beforeRedirect: (options) => { + options.headers = { + ...this.getHeaders(bearerToken, requestContentType), + ...options.headers + }; + }, + validateStatus: () => true // All responses are valid, not just 2xx + }); + if (!this.isOk(response)) { + const { status: statusCode } = response; + if (this.isZoomResponseError(response.data)) { + const { code: errorCode, message: errorMessage } = response.data; + throw new ApiResponseError(`[${statusCode.toString()}/${errorCode.toString()}]: "${errorMessage}"`); + } + throw new ApiResponseError(`[${statusCode.toString()}]: ${WebEndpoints.GENERIC_ERROR_MESSAGE}`); + } + return { + data: response.data, + statusCode: response.status, + trackingId: response.headers[WebEndpoints.TRACKING_ID_HEADER] + }; + } +} + +class MarketplaceEndpoints extends WebEndpoints { + app = { + sendAppNotifications: this.buildEndpoint({ + method: "POST", + urlPathBuilder: () => `/app/notifications` + }), + getUserOrAccountEventSubscription: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/marketplace/app/event_subscription` }), + createEventSubscription: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/marketplace/app/event_subscription` }), + unsubscribeAppEventSubscription: this.buildEndpoint({ method: "DELETE", urlPathBuilder: () => `/marketplace/app/event_subscription` }), + deleteEventSubscription: this.buildEndpoint({ + method: "DELETE", + urlPathBuilder: ({ eventSubscriptionId }) => `/marketplace/app/event_subscription/${eventSubscriptionId}` + }), + subscribeEventSubscription: this.buildEndpoint({ + method: "PATCH", + urlPathBuilder: ({ eventSubscriptionId }) => `/marketplace/app/event_subscription/${eventSubscriptionId}` + }), + listApps: this.buildEndpoint({ + method: "GET", + urlPathBuilder: () => `/marketplace/apps` + }), + createApps: this.buildEndpoint({ + method: "POST", + urlPathBuilder: () => `/marketplace/apps` + }), + getInformationAboutApp: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}` }), + getAppsUserRequests: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/requests` }), + addAppAllowRequestsForUsers: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/requests` }), + updateAppsRequestStatus: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/requests` }), + getWebhookLogs: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/webhook_logs` }), + getAppUserEntitlements: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/marketplace/monetization/entitlements` }), + getUsersAppRequests: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/marketplace/users/${userId}/apps` }), + getUsersEntitlements: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/marketplace/users/${userId}/entitlements` }) + }; + apps = { + generateAppDeeplink: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/zoomapp/deeplink` }) + }; + manifest = { + validateAppManifest: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/marketplace/apps/manifest/validate` }), + exportAppManifestFromExistingApp: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/manifest` }), + updateAppByManifest: this.buildEndpoint({ method: "PUT", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/manifest` }) + }; +} + +class MarketplaceEventProcessor extends EventManager { +} + +class OAuth extends InteractiveAuth { + assertResponseAccessToken(data) { + if (typeof data.access_token !== "string" || + typeof data.refresh_token !== "string" || + typeof data.expires_in !== "number" || + typeof data.scope !== "string") { + throw new OAuthTokenRawResponseError(`Failed to match raw response (${JSON.stringify(data)}) to expected shape.`); + } + } + async fetchAccessToken(code) { + try { + const response = await this.makeOAuthTokenRequest("authorization_code", { + code, + redirect_uri: this.getFullRedirectUri() + }); + this.assertResponseAccessToken(response.data); + return this.mapOAuthToken(response.data); + } + catch (err) { + throw new OAuthTokenFetchFailedError("Failed to fetch OAuth token.", { cause: err }); + } + } + async getToken() { + const { tokenStore } = this; + const currentToken = await Promise.resolve(tokenStore.getLatestToken()); + // If we have no OAuth token, app most likely has not been previously authorized. + if (!currentToken) { + throw new OAuthTokenDoesNotExistError("Failed to find OAuth token. Authorize this app first."); + } + // If the OAuth token hasn't already expired (and isn't within the delta), return it. + if (!this.isAlmostExpired(currentToken.expirationTimeIso)) { + return currentToken.accessToken; + } + // Since the token has expired, refresh, store, and return it. + const refreshedToken = await this.refreshAccessToken(currentToken.refreshToken); + await Promise.resolve(tokenStore.storeToken(refreshedToken)); + return refreshedToken.accessToken; + } + async initRedirectCode(code) { + const { tokenStore } = this; + const accessToken = await this.fetchAccessToken(code); + await Promise.resolve(tokenStore.storeToken(accessToken)); + } + mapOAuthToken({ access_token, expires_in, refresh_token, scope }) { + return { + accessToken: access_token, + expirationTimeIso: dayjs().add(expires_in, "seconds").toISOString(), + refreshToken: refresh_token, + scopes: scope.includes(" ") ? scope.split(" ") : [scope] + }; + } + async refreshAccessToken(refreshToken) { + try { + const response = await this.makeOAuthTokenRequest("refresh_token", { + refresh_token: refreshToken + }); + this.assertResponseAccessToken(response.data); + return this.mapOAuthToken(response.data); + } + catch (err) { + throw new OAuthTokenRefreshFailedError("Failed to refresh OAuth token.", { cause: err }); + } + } +} + +// Utility functions for determining if client options include custom receiver, or, if not, +// a webhooks secret token, as one of those is required! +const hasExplicitReceiver = (obj) => typeof obj.receiver !== "undefined"; +const hasWebhooksSecretToken = (obj) => typeof obj.webhooksSecretToken !== "undefined"; +const isReceiverDisabled = (options) => typeof options.disableReceiver !== "undefined" && options.disableReceiver; +const DEFAULT_HTTP_RECEIVER_PORT = 8080; +const DEFAULT_LOGLEVEL = exports.LogLevel.ERROR; +class ProductClient { + auth; + endpoints; + webEventConsumer; + receiver; + constructor(options) { + this.auth = this.initAuth(options); + this.endpoints = this.initEndpoints(this.auth, options); + this.webEventConsumer = this.initEventProcessor(this.endpoints, options); + // Only create an instance of `this.receiver` if the developer did not explicitly disable it. + if (!isReceiverDisabled(options)) { + // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); + } + this.receiver = (hasExplicitReceiver(options) ? + options.receiver + : this.initDefaultReceiver(options)); + this.receiver.init({ + eventEmitter: this.webEventConsumer, + interactiveAuth: this.auth instanceof InteractiveAuth ? this.auth : undefined + }); + } + } + initDefaultReceiver({ port, webhooksSecretToken, logLevel }) { + return new HttpReceiver({ + port: port ?? DEFAULT_HTTP_RECEIVER_PORT, + webhooksSecretToken, + logLevel: logLevel ?? DEFAULT_LOGLEVEL + }); + } + async start() { + if (!this.receiver) { + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); + } + // Method call is wrapped in `await` and `Promise.resolve()`, as the call + // may or may not return a promise. This is not required when implementing `Receiver`. + return (await Promise.resolve(this.receiver.start())); + } +} + +class MarketplaceOAuthClient extends ProductClient { + initAuth({ clientId, clientSecret, tokenStore, ...restOptions }) { + const oAuth = new OAuth({ clientId, clientSecret, tokenStore }); + if (hasInstallerOptions(restOptions)) { + oAuth.setInstallerOptions(restOptions.installerOptions); + } + return oAuth; + } + initEndpoints(auth, options) { + return new MarketplaceEndpoints({ auth, ...options }); + } + initEventProcessor(endpoints) { + return new MarketplaceEventProcessor(endpoints); + } +} + +class S2SAuth extends Auth { + accountId; + constructor({ accountId, ...restOptions }) { + super(restOptions); + this.accountId = accountId; + } + assertRawToken(obj) { + if (typeof obj.access_token !== "string" || + typeof obj.expires_in !== "number" || + typeof obj.scope !== "string") { + throw new S2SRawResponseError(`Failed to match raw response ${JSON.stringify(obj)} to expected shape.`); + } + } + async fetchAccessToken() { + const response = await this.makeOAuthTokenRequest("account_credentials", { + account_id: this.accountId + }); + this.assertRawToken(response.data); + return this.mapAccessToken(response.data); + } + async getToken() { + const { tokenStore } = this; + const currentToken = await Promise.resolve(tokenStore.getLatestToken()); + if (currentToken && !this.isAlmostExpired(currentToken.expirationTimeIso)) { + return currentToken.accessToken; + } + const token = await this.fetchAccessToken(); + await Promise.resolve(tokenStore.storeToken(token)); + return token.accessToken; + } + mapAccessToken({ access_token, expires_in, scope }) { + return { + accessToken: access_token, + expirationTimeIso: dayjs().add(expires_in, "seconds").toISOString(), + scopes: scope.includes(" ") ? scope.split(" ") : [scope] + }; + } +} + +class MarketplaceS2SAuthClient extends ProductClient { + initAuth({ clientId, clientSecret, tokenStore, accountId }) { + return new S2SAuth({ clientId, clientSecret, tokenStore, accountId }); + } + initEndpoints(auth, options) { + return new MarketplaceEndpoints({ auth, ...options }); + } + initEventProcessor(endpoints) { + return new MarketplaceEventProcessor(endpoints); + } +} + +exports.ApiResponseError = ApiResponseError; +exports.AwsLambdaReceiver = AwsLambdaReceiver; +exports.AwsReceiverRequestError = AwsReceiverRequestError; +exports.ClientCredentialsRawResponseError = ClientCredentialsRawResponseError; +exports.CommonHttpRequestError = CommonHttpRequestError; +exports.ConsoleLogger = ConsoleLogger; +exports.HTTPReceiverConstructionError = HTTPReceiverConstructionError; +exports.HTTPReceiverPortNotNumberError = HTTPReceiverPortNotNumberError; +exports.HTTPReceiverRequestError = HTTPReceiverRequestError; +exports.HttpReceiver = HttpReceiver; +exports.MarketplaceEndpoints = MarketplaceEndpoints; +exports.MarketplaceEventProcessor = MarketplaceEventProcessor; +exports.MarketplaceOAuthClient = MarketplaceOAuthClient; +exports.MarketplaceS2SAuthClient = MarketplaceS2SAuthClient; +exports.OAuthInstallerNotInitializedError = OAuthInstallerNotInitializedError; +exports.OAuthStateVerificationFailedError = OAuthStateVerificationFailedError; +exports.OAuthTokenDoesNotExistError = OAuthTokenDoesNotExistError; +exports.OAuthTokenFetchFailedError = OAuthTokenFetchFailedError; +exports.OAuthTokenRawResponseError = OAuthTokenRawResponseError; +exports.OAuthTokenRefreshFailedError = OAuthTokenRefreshFailedError; +exports.ProductClientConstructionError = ProductClientConstructionError; +exports.ReceiverInconsistentStateError = ReceiverInconsistentStateError; +exports.ReceiverOAuthFlowError = ReceiverOAuthFlowError; +exports.S2SRawResponseError = S2SRawResponseError; +exports.isCoreError = isCoreError; +exports.isStateStore = isStateStore; diff --git a/marketplace/marketplace.d.ts b/marketplace/marketplace.d.ts new file mode 100644 index 0000000..e7ab651 --- /dev/null +++ b/marketplace/marketplace.d.ts @@ -0,0 +1,875 @@ +import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; +import { Server } from 'node:http'; +import { ServerOptions } from 'node:https'; + +declare enum LogLevel { + ERROR = "error", + WARN = "warn", + INFO = "info", + DEBUG = "debug" +} +interface Logger { + /** + * Output debug message + * @param msg any data to be logged + */ + debug(...msg: unknown[]): void; + /** + * Output info message + * @param msg any data to be logged + */ + info(...msg: unknown[]): void; + /** + * Output warn message + * @param msg any data to be logged + */ + warn(...msg: unknown[]): void; + /** + * Output error message + * @param msg any data to be logged + */ + error(...msg: unknown[]): void; + /** + * Disables all logging below the given level + * @param level as a string, 'error' | 'warn' | 'info' | 'debug' + */ + setLevel(level: LogLevel): void; + /** + * Return the current LogLevel. + */ + getLevel(): LogLevel; + /** + * Name the instance so that it can be filtered when many loggers are sending output + * to the same destination. + * @param name as a string + */ + setName(name: string): void; +} +declare class ConsoleLogger implements Logger { + private level; + private name; + private static labels; + private static severity; + constructor(); + getLevel(): LogLevel; + setLevel(level: LogLevel): void; + setName(name: string): void; + debug(...msg: unknown[]): void; + info(...msg: unknown[]): void; + warn(...msg: unknown[]): void; + error(...msg: unknown[]): void; + private static isMoreOrEqualSevere; +} + +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + +interface AuthOptions { + clientId: string; + clientSecret: string; + tokenStore?: TokenStore | undefined; + logger?: Logger; +} +type OAuthGrantType = "authorization_code" | "client_credentials" | "refresh_token" | "account_credentials"; +interface BaseOAuthRequest { + grant_type: OAuthGrantType; +} +interface OAuthAuthorizationCodeRequest extends BaseOAuthRequest { + code: string; + grant_type: "authorization_code"; + redirect_uri?: string; +} +interface OAuthRefreshTokenRequest extends BaseOAuthRequest { + grant_type: "refresh_token"; + refresh_token: string; +} +interface S2SAuthTokenRequest extends BaseOAuthRequest { + grant_type: "account_credentials"; + account_id: string; +} +type OAuthRequest = OAuthAuthorizationCodeRequest | OAuthRefreshTokenRequest | S2SAuthTokenRequest; +/** + * {@link Auth} is the base implementation of authentication for Zoom's APIs. + * + * It only requires a `clientId` and `tokenStore`, as these options are shared across + * all authentication implementations, namely OAuth and server-to-server auth (client + * credentials, JWT, and server-to-server OAuth.) + */ +declare abstract class Auth { + protected readonly clientId: string; + protected readonly clientSecret: string; + protected readonly tokenStore: TokenStore; + protected readonly logger: Logger | undefined; + constructor({ clientId, clientSecret, tokenStore, logger }: AuthOptions); + protected getBasicAuthorization(): string; + abstract getToken(): MaybePromise; + protected isAlmostExpired(isoTime: string): boolean; + protected makeOAuthTokenRequest(grantType: T, payload?: Omit, "grant_type">): Promise; +} + +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} + +interface S2SAuthToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} +interface S2SAuthOptions { + accountId: string; +} +declare class S2SAuth extends Auth { + private accountId; + constructor({ accountId, ...restOptions }: AuthOptions & S2SAuthOptions); + private assertRawToken; + private fetchAccessToken; + getToken(): Promise; + private mapAccessToken; +} + +interface Event { + event: Type; +} +type EventKeys = T extends Event ? U : never; +type EventPayload = Extract; +type EventListenerFn, ReturnType = MaybePromise> = (payload: EventPayload) => ReturnType; +type EventListenerPredicateFn> = EventListenerFn>; +type ContextListener, Context> = (_: EventPayload & Context) => MaybePromise; +type GenericEventManager = EventManager; +declare class EventManager { + protected endpoints: Endpoints; + constructor(endpoints: Endpoints); + private appendListener; + filteredEvent>(eventName: EventName, predicate: EventListenerPredicateFn, listener: EventListenerFn): void; + emit>(eventName: EventName, payload: EventPayload): Promise; + event>(eventName: EventName, listener: EventListenerFn): void; + protected withContext, Context>(): ContextListener; +} + +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + +interface HttpReceiverOptions extends Partial { + endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; + port?: number | string | undefined; + webhooksSecretToken?: string | undefined; +} +type SecureServerOptions = { + [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; +}; +declare const secureServerOptionKeys: (keyof ServerOptions)[]; +declare class HttpReceiver implements Receiver { + private eventEmitter?; + private interactiveAuth?; + private server?; + private logger; + constructor(options: HttpReceiverOptions); + canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; + private getServerCreator; + private hasEndpoint; + private hasSecureOptions; + init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; + start(port?: number | string): Promise; + stop(): Promise; + private writeTemporaryRedirect; + private writeResponse; +} + +interface BaseResponse { + data?: Data | undefined; + statusCode: number; + trackingId?: string | undefined; +} +interface BuildEndpointOptions { + method: HttpMethod; + baseUrlOverride?: string | undefined; + urlPathBuilder: (params: PathSchema) => string; + requestMimeType?: RequestMimeType; +} +interface WebEndpointOptions { + auth: Auth; + baseUrl?: string | undefined; + doubleEncodeUrl?: boolean | undefined; + timeout?: number | undefined; +} +type EndpointArguments = (PathSchema extends NoParams ? object : AllPropsOptional extends "t" ? { + path?: PathSchema; +} : { + path: PathSchema; +}) & (BodySchema extends NoParams ? object : AllPropsOptional extends "t" ? { + body?: BodySchema; +} : { + body: BodySchema; +}) & (QuerySchema extends NoParams ? object : AllPropsOptional extends "t" ? { + query?: QuerySchema; +} : { + query: QuerySchema; +}); +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +type NoParams = "_NO_PARAMS_"; +type RequestMimeType = "application/json" | "multipart/form-data"; +declare class WebEndpoints { + constructor(options: WebEndpointOptions); + protected buildEndpoint({ method, baseUrlOverride, urlPathBuilder, requestMimeType }: BuildEndpointOptions): (_: EndpointArguments) => Promise>; + private buildUserAgent; + private getHeaders; + private getRequestBody; + private isOk; + private isZoomResponseError; + private makeRequest; +} + +type CommonClientOptions = GetAuthOptions & ExtractInstallerOptions & { + disableReceiver?: boolean | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; +}; +interface ClientReceiverOptions { + receiver: R; +} +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); +type ExtractInstallerOptions = A extends InteractiveAuth ? [ + ReturnType +] extends [true] ? WideInstallerOptions : object : object; +type ExtractAuthTokenType = A extends Auth ? T : never; +type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); +type WideInstallerOptions = { + installerOptions: InstallerOptions; +}; +declare abstract class ProductClient, ReceiverType extends Receiver> { + private readonly auth; + readonly endpoints: EndpointsType; + readonly webEventConsumer?: EventProcessorType | undefined; + private readonly receiver?; + constructor(options: ClientConstructorOptions); + protected abstract initAuth(options: OptionsType): AuthType; + protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; + private initDefaultReceiver; + start(): Promise>; +} + +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; +} +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; + +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} +interface InstallerOptions { + directInstall?: boolean | undefined; + installPath?: string | undefined; + redirectUri: string; + redirectUriPath?: string | undefined; + stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; +} +/** + * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication + * is initiated server-side, but requires manual authorization from a user, by redirecting the user to Zoom. + * + * In addition to all required fields from {@link AuthOptions}, this class requires a `redirectUri`, as this + * value is appended to the authorization URL when the user is redirected to Zoom and subsequently redirected + * back to an endpoint on this server. + * + * @see {@link https://developers.zoom.us/docs/integrations/oauth/ | OAuth - Zoom Developers} + */ +declare abstract class InteractiveAuth extends Auth { + installerOptions?: ReturnType; + getAuthorizationUrl(): Promise; + getFullRedirectUri(): string; + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { + directInstall: boolean; + installPath: string; + redirectUri: string; + redirectUriPath: string; + stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; + }; +} + +/** + * Credentials for access token & refresh token, which are used to access Zoom's APIs. + * + * As access token is short-lived (usually a single hour), its expiration time is checked + * first. If it's possible to use the access token, it's used; however, if it has expired + * or is close to expiring, the refresh token should be used to generate a new access token + * before the API call is made. Refresh tokens are generally valid for 90 days. + * + * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} + * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. + * It's likely that this error will be rare, but it _can_ be thrown. + */ +interface OAuthToken { + accessToken: string; + expirationTimeIso: string; + refreshToken: string; + scopes: string[]; +} +declare class OAuth extends InteractiveAuth { + private assertResponseAccessToken; + private fetchAccessToken; + getToken(): Promise; + initRedirectCode(code: string): Promise; + private mapOAuthToken; + private refreshAccessToken; +} + +interface RivetError extends Error { + readonly errorCode: ErrorCode; +} + +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + +interface AwsLambdaReceiverOptions { + webhooksSecretToken: string; +} +declare class AwsLambdaReceiver implements Receiver { + private eventEmitter?; + private readonly webhooksSecretToken; + constructor({ webhooksSecretToken }: AwsLambdaReceiverOptions); + buildResponse(statusCode: StatusCode, body: object): LambdaFunctionURLResult; + canInstall(): false; + init({ eventEmitter }: ReceiverInitOptions): void; + start(): LambdaFunctionURLHandler; + stop(): Promise; +} + +type AppSendAppNotificationsRequestBody = { + notification_id?: string; + message?: { + text?: string; + }; + user_id?: string; +}; +type AppGetUserOrAccountEventSubscriptionQueryParams = { + page_size?: number; + next_page_token?: string; + user_id: string; + subscription_scope?: string; + account_id: string; +}; +type AppGetUserOrAccountEventSubscriptionResponse = { + next_page_token?: string; + page_size?: number; +} & { + event_subscriptions?: { + event_subscription_id?: string; + events?: string[]; + event_subscription_name?: string; + event_webhook_url?: string; + subscription_scope?: string; + created_source?: string; + subscriber_id?: string; + }[]; +}; +type AppCreateEventSubscriptionRequestBody = { + events: string[]; + event_subscription_name?: string; + event_webhook_url: string; + user_ids?: string[]; + subscription_scope: string; + account_id?: string; +}; +type AppCreateEventSubscriptionResponse = { + event_subscription_id?: string; +}; +type AppUnsubscribeAppEventSubscriptionQueryParams = { + event_subscription_id: string; + user_ids?: string; + account_id: string; +}; +type AppDeleteEventSubscriptionPathParams = { + eventSubscriptionId: string; +}; +type AppSubscribeEventSubscriptionPathParams = { + eventSubscriptionId: string; +}; +type AppSubscribeEventSubscriptionRequestBody = { + user_ids?: string[]; + account_id: string; +}; +type AppListAppsQueryParams = { + page_size?: number; + next_page_token?: string; + type?: string; +}; +type AppListAppsResponse = { + next_page_token?: string; + page_size?: number; +} & { + apps?: { + app_id?: string; + app_name?: string; + app_type?: string; + app_usage?: number; + app_status?: string; + request_id?: string; + request_total_number?: number; + request_pending_number?: number; + request_approved_number?: number; + request_declined_number?: number; + latest_request_date_time?: string; + reviewer_name?: string; + review_date_time?: string; + app_developer_type?: string; + app_description?: string; + app_icon?: string; + scopes?: { + scope_name?: string; + scope_description?: string; + }[]; + app_privacy_policy_url?: string; + app_directory_url?: string; + app_help_url?: string; + restricted_time?: string; + approval_info?: { + approved_type?: string; + approver_id?: string; + approved_time?: string; + app_approval_closed?: boolean; + }; + }[]; +}; +type AppCreateAppsRequestBody = { + app_type: string; + app_name: string; + scopes?: string[]; + contact_name: string; + contact_email: string; + company_name: string; + active?: boolean; + publish?: boolean; + manifest?: object; +}; +type AppCreateAppsResponse = { + created_at?: string; + app_id?: string; + app_name?: string; + app_type?: string; + scopes?: string[]; + production_credentials?: { + client_id?: string; + client_secret?: string; + }; + development_credentials?: { + client_id?: string; + client_secret?: string; + }; +}; +type AppGetInformationAboutAppPathParams = { + appId: string; +}; +type AppGetInformationAboutAppResponse = { + app_id?: string; + app_name?: string; + app_description?: string; + app_type?: string; + app_usage?: number; + app_status?: string; + app_links?: { + documentation_url?: string; + privacy_policy_url?: string; + support_url?: string; + terms_of_use_url?: string; + }; + app_permissions?: { + group?: string; + group_message?: string; + title?: string; + permissions?: { + name?: string; + }[]; + }[]; + app_requirements?: { + user_role?: string; + min_client_version?: string; + account_eligibility?: { + account_types?: string[]; + premium_events?: { + event_name?: string; + event?: string; + }[]; + }; + }; + app_scopes?: string[]; +}; +type AppGetAppsUserRequestsPathParams = { + appId: string; +}; +type AppGetAppsUserRequestsQueryParams = { + page_size?: number; + next_page_token?: string; + status?: string; +}; +type AppGetAppsUserRequestsResponse = { + next_page_token?: string; + page_size?: number; +} & { + requests?: { + request_user_id?: string; + request_user_name?: string; + request_user_email?: string; + request_user_department?: string; + request_date_time?: string; + reason?: string; + status?: string; + }[]; +}; +type AppAddAppAllowRequestsForUsersPathParams = { + appId: string; +}; +type AppAddAppAllowRequestsForUsersRequestBody = { + action: string; + user_ids?: string[]; + group_ids?: string[]; +}; +type AppAddAppAllowRequestsForUsersResponse = { + added_at?: string; + user_ids?: string[]; + group_ids?: string[]; +}; +type AppUpdateAppsRequestStatusPathParams = { + appId: string; +}; +type AppUpdateAppsRequestStatusRequestBody = { + action: string; + request_user_ids?: string[]; +}; +type AppGetWebhookLogsPathParams = { + appId: string; +}; +type AppGetWebhookLogsQueryParams = { + next_page_token?: string; + page_size?: number; + from?: string; + to?: string; + event?: string; + type?: number; +}; +type AppGetWebhookLogsResponse = { + next_page_token?: string; + page_size?: number; + webhook_logs?: { + event?: string; + status?: number; + failed_reason_type?: number; + user_id?: string; + endpoint?: string; + subscription_id?: string; + request_headers?: string; + request_body?: string; + response_headers?: string; + response_body?: string; + date_time?: string; + trace_id?: string; + }[]; +}; +type AppGetAppUserEntitlementsQueryParams = { + user_id?: string; +}; +type AppGetAppUserEntitlementsResponse = { + id?: string; + plan_name?: string; + plan_id?: string; +}[]; +type AppGetUsersAppRequestsPathParams = { + userId: string; +}; +type AppGetUsersAppRequestsQueryParams = { + page_size?: number; + next_page_token?: string; + type?: string; +}; +type AppGetUsersAppRequestsResponse = { + next_page_token?: string; + page_size?: number; +} & { + apps?: { + app_id?: string; + app_name?: string; + app_type?: string; + app_usage?: number; + app_status?: string; + request_id?: string; + request_date_time?: string; + request_status?: string; + }[]; +}; +type AppGetUsersEntitlementsPathParams = { + userId: string; +}; +type AppGetUsersEntitlementsResponse = { + entitlements?: { + entitlement_id?: number; + }[]; +}; +type AppsGenerateAppDeeplinkRequestBody = { + type?: number; + user_id?: string; + action?: string; +}; +type AppsGenerateAppDeeplinkResponse = { + deeplink?: string; +}; +type ManifestValidateAppManifestRequestBody = { + manifest: object; + app_id?: string; +}; +type ManifestValidateAppManifestResponse = { + ok?: boolean; + error?: string; + errors?: { + message: string; + setting: string; + }[]; +}; +type ManifestExportAppManifestFromExistingAppPathParams = { + appId: string; +}; +type ManifestExportAppManifestFromExistingAppResponse = { + manifest?: object; +}; +type ManifestUpdateAppByManifestPathParams = { + appId: string; +}; +type ManifestUpdateAppByManifestRequestBody = { + manifest: object; +}; +declare class MarketplaceEndpoints extends WebEndpoints { + readonly app: { + sendAppNotifications: (_: object & { + body?: AppSendAppNotificationsRequestBody; + }) => Promise>; + getUserOrAccountEventSubscription: (_: object & { + query: AppGetUserOrAccountEventSubscriptionQueryParams; + }) => Promise>; + createEventSubscription: (_: object & { + body: AppCreateEventSubscriptionRequestBody; + }) => Promise>; + unsubscribeAppEventSubscription: (_: object & { + query: AppUnsubscribeAppEventSubscriptionQueryParams; + }) => Promise>; + deleteEventSubscription: (_: { + path: AppDeleteEventSubscriptionPathParams; + } & object) => Promise>; + subscribeEventSubscription: (_: { + path: AppSubscribeEventSubscriptionPathParams; + } & { + body: AppSubscribeEventSubscriptionRequestBody; + } & object) => Promise>; + listApps: (_: object & { + query?: AppListAppsQueryParams; + }) => Promise>; + createApps: (_: object & { + body: AppCreateAppsRequestBody; + }) => Promise>; + getInformationAboutApp: (_: { + path: AppGetInformationAboutAppPathParams; + } & object) => Promise>; + getAppsUserRequests: (_: { + path: AppGetAppsUserRequestsPathParams; + } & object & { + query?: AppGetAppsUserRequestsQueryParams; + }) => Promise>; + addAppAllowRequestsForUsers: (_: { + path: AppAddAppAllowRequestsForUsersPathParams; + } & { + body: AppAddAppAllowRequestsForUsersRequestBody; + } & object) => Promise>; + updateAppsRequestStatus: (_: { + path: AppUpdateAppsRequestStatusPathParams; + } & { + body: AppUpdateAppsRequestStatusRequestBody; + } & object) => Promise>; + getWebhookLogs: (_: { + path: AppGetWebhookLogsPathParams; + } & object & { + query?: AppGetWebhookLogsQueryParams; + }) => Promise>; + getAppUserEntitlements: (_: object & { + query?: AppGetAppUserEntitlementsQueryParams; + }) => Promise>; + getUsersAppRequests: (_: { + path: AppGetUsersAppRequestsPathParams; + } & object & { + query?: AppGetUsersAppRequestsQueryParams; + }) => Promise>; + getUsersEntitlements: (_: { + path: AppGetUsersEntitlementsPathParams; + } & object) => Promise>; + }; + readonly apps: { + generateAppDeeplink: (_: object & { + body?: AppsGenerateAppDeeplinkRequestBody; + }) => Promise>; + }; + readonly manifest: { + validateAppManifest: (_: object & { + body: ManifestValidateAppManifestRequestBody; + }) => Promise>; + exportAppManifestFromExistingApp: (_: { + path: ManifestExportAppManifestFromExistingAppPathParams; + } & object) => Promise>; + updateAppByManifest: (_: { + path: ManifestUpdateAppByManifestPathParams; + } & { + body: ManifestUpdateAppByManifestRequestBody; + } & object) => Promise>; + }; +} + +type AppDeauthorizedEvent = Event<"app_deauthorized"> & { + event?: string; + payload?: { + user_id?: string; + account_id?: string; + client_id?: string; + deauthorization_time?: string; + signature?: string; + event_ts?: number; + }; +}; +type AppAuthorizationRequestCreatedEvent = Event<"app.authorization_request_created"> & { + event?: string; + event_ts?: number; + payload?: { + app_name?: string; + app_type?: string; + app_status?: string; + app_description?: string; + app_link?: { + developer_documentation?: string; + developer_privacy_policy?: string; + developer_support?: string; + developer_terms_of_use?: string; + }; + }; +}; +type MarketplaceEvents = AppDeauthorizedEvent | AppAuthorizationRequestCreatedEvent; +declare class MarketplaceEventProcessor extends EventManager { +} + +type MarketplaceOAuthOptions = CommonClientOptions; +declare class MarketplaceOAuthClient = MarketplaceOAuthOptions> extends ProductClient { + protected initAuth({ clientId, clientSecret, tokenStore, ...restOptions }: OptionsType): OAuth; + protected initEndpoints(auth: OAuth, options: OptionsType): MarketplaceEndpoints; + protected initEventProcessor(endpoints: MarketplaceEndpoints): MarketplaceEventProcessor; +} + +type MarketplaceS2SAuthOptions = CommonClientOptions; +declare class MarketplaceS2SAuthClient = MarketplaceS2SAuthOptions> extends ProductClient { + protected initAuth({ clientId, clientSecret, tokenStore, accountId }: OptionsType): S2SAuth; + protected initEndpoints(auth: S2SAuth, options: OptionsType): MarketplaceEndpoints; + protected initEventProcessor(endpoints: MarketplaceEndpoints): MarketplaceEventProcessor; +} + +export { ApiResponseError, type AppAddAppAllowRequestsForUsersPathParams, type AppAddAppAllowRequestsForUsersRequestBody, type AppAddAppAllowRequestsForUsersResponse, type AppAuthorizationRequestCreatedEvent, type AppCreateAppsRequestBody, type AppCreateAppsResponse, type AppCreateEventSubscriptionRequestBody, type AppCreateEventSubscriptionResponse, type AppDeauthorizedEvent, type AppDeleteEventSubscriptionPathParams, type AppGetAppUserEntitlementsQueryParams, type AppGetAppUserEntitlementsResponse, type AppGetAppsUserRequestsPathParams, type AppGetAppsUserRequestsQueryParams, type AppGetAppsUserRequestsResponse, type AppGetInformationAboutAppPathParams, type AppGetInformationAboutAppResponse, type AppGetUserOrAccountEventSubscriptionQueryParams, type AppGetUserOrAccountEventSubscriptionResponse, type AppGetUsersAppRequestsPathParams, type AppGetUsersAppRequestsQueryParams, type AppGetUsersAppRequestsResponse, type AppGetUsersEntitlementsPathParams, type AppGetUsersEntitlementsResponse, type AppGetWebhookLogsPathParams, type AppGetWebhookLogsQueryParams, type AppGetWebhookLogsResponse, type AppListAppsQueryParams, type AppListAppsResponse, type AppSendAppNotificationsRequestBody, type AppSubscribeEventSubscriptionPathParams, type AppSubscribeEventSubscriptionRequestBody, type AppUnsubscribeAppEventSubscriptionQueryParams, type AppUpdateAppsRequestStatusPathParams, type AppUpdateAppsRequestStatusRequestBody, type AppsGenerateAppDeeplinkRequestBody, type AppsGenerateAppDeeplinkResponse, AwsLambdaReceiver, AwsReceiverRequestError, ClientCredentialsRawResponseError, type ClientCredentialsToken, CommonHttpRequestError, ConsoleLogger, HTTPReceiverConstructionError, HTTPReceiverPortNotNumberError, HTTPReceiverRequestError, HttpReceiver, type HttpReceiverOptions, type JwtToken, LogLevel, type Logger, type ManifestExportAppManifestFromExistingAppPathParams, type ManifestExportAppManifestFromExistingAppResponse, type ManifestUpdateAppByManifestPathParams, type ManifestUpdateAppByManifestRequestBody, type ManifestValidateAppManifestRequestBody, type ManifestValidateAppManifestResponse, MarketplaceEndpoints, MarketplaceEventProcessor, MarketplaceOAuthClient, type MarketplaceOAuthOptions, MarketplaceS2SAuthClient, type MarketplaceS2SAuthOptions, OAuthInstallerNotInitializedError, OAuthStateVerificationFailedError, type OAuthToken, OAuthTokenDoesNotExistError, OAuthTokenFetchFailedError, OAuthTokenRawResponseError, OAuthTokenRefreshFailedError, ProductClientConstructionError, type Receiver, ReceiverInconsistentStateError, type ReceiverInitOptions, ReceiverOAuthFlowError, type S2SAuthToken, S2SRawResponseError, type StateStore, StatusCode, type TokenStore, isCoreError, isStateStore }; diff --git a/marketplace/marketplace.mjs b/marketplace/marketplace.mjs new file mode 100644 index 0000000..e3b4a06 --- /dev/null +++ b/marketplace/marketplace.mjs @@ -0,0 +1,1125 @@ +import { createHmac, randomBytes } from 'node:crypto'; +import { createServer as createServer$1 } from 'node:http'; +import { createServer } from 'node:https'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import { Buffer as Buffer$1 } from 'node:buffer'; +import { SignJWT, jwtVerify } from 'jose'; +import FormData from 'form-data'; +import os from 'node:os'; +import { basename } from 'node:path'; + +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +const isStateStore = (obj) => typeof obj.generateState === "function" && typeof obj.verifyState === "function"; + +const createRivetErrors = (errors) => ({ + createError: (errorCode) => class extends Error { + errorCode = errors[errorCode]; + constructor(message, options) { + const errorMessage = (message ?? + (options?.cause instanceof Error ? options.cause.message : errorCode)); + super(errorMessage, options); + this.name = errorCode; + Object.setPrototypeOf(this, new.target.prototype); + } + }, + isError: (obj, key) => key ? + Object.keys(errors).some((code) => code === key) && + typeof obj.errorCode === "string" && + obj.errorCode === errors[key] + : typeof obj.errorCode === "string" +}); + +const coreErrors = { + ApiResponseError: "zoom_rivet_api_response_error", + AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error", + ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error", + S2SRawResponseError: "zoom_rivet_s2s_raw_response_error", + CommonHttpRequestError: "zoom_rivet_common_http_request_error", + ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error", + ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error", + HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error", + HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error", + HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error", + OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error", + OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error", + OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error", + OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error", + OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error", + OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error", + ProductClientConstructionError: "zoom_rivet_product_client_construction_error" +}; +const { createError: createCoreError, isError: isCoreError } = createRivetErrors(coreErrors); +const ApiResponseError = createCoreError("ApiResponseError"); +const AwsReceiverRequestError = createCoreError("AwsReceiverRequestError"); +const ClientCredentialsRawResponseError = createCoreError("ClientCredentialsRawResponseError"); +const S2SRawResponseError = createCoreError("S2SRawResponseError"); +const CommonHttpRequestError = createCoreError("CommonHttpRequestError"); +const ReceiverInconsistentStateError = createCoreError("ReceiverInconsistentStateError"); +const ReceiverOAuthFlowError = createCoreError("ReceiverOAuthFlowError"); +const HTTPReceiverConstructionError = createCoreError("HTTPReceiverConstructionError"); +const HTTPReceiverPortNotNumberError = createCoreError("HTTPReceiverPortNotNumberError"); +const HTTPReceiverRequestError = createCoreError("HTTPReceiverRequestError"); +const OAuthInstallerNotInitializedError = createCoreError("OAuthInstallerNotInitializedError"); +const OAuthTokenDoesNotExistError = createCoreError("OAuthTokenDoesNotExistError"); +const OAuthTokenFetchFailedError = createCoreError("OAuthTokenFetchFailedError"); +const OAuthTokenRawResponseError = createCoreError("OAuthTokenRawResponseError"); +const OAuthTokenRefreshFailedError = createCoreError("OAuthTokenRefreshFailedError"); +const OAuthStateVerificationFailedError = createCoreError("OAuthStateVerificationFailedError"); +const ProductClientConstructionError = createCoreError("ProductClientConstructionError"); + +var LogLevel; +(function (LogLevel) { + LogLevel["ERROR"] = "error"; + LogLevel["WARN"] = "warn"; + LogLevel["INFO"] = "info"; + LogLevel["DEBUG"] = "debug"; +})(LogLevel || (LogLevel = {})); +class ConsoleLogger { + level; + name; + static labels = (() => { + const entries = Object.entries(LogLevel); + const map = entries.map(([key, value]) => [value, `[${key}] `]); + return new Map(map); + })(); + static severity = { + [LogLevel.ERROR]: 400, + [LogLevel.WARN]: 300, + [LogLevel.INFO]: 200, + [LogLevel.DEBUG]: 100 + }; + constructor() { + this.level = LogLevel.INFO; + this.name = ""; + } + getLevel() { + return this.level; + } + setLevel(level) { + this.level = level; + } + setName(name) { + this.name = name; + } + debug(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.DEBUG, this.level)) { + console.debug(ConsoleLogger.labels.get(LogLevel.DEBUG), this.name, ...msg); + } + } + info(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.INFO, this.level)) { + console.info(ConsoleLogger.labels.get(LogLevel.INFO), this.name, ...msg); + } + } + warn(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.WARN, this.level)) { + console.warn(ConsoleLogger.labels.get(LogLevel.WARN), this.name, ...msg); + } + } + error(...msg) { + if (ConsoleLogger.isMoreOrEqualSevere(LogLevel.ERROR, this.level)) { + console.error(ConsoleLogger.labels.get(LogLevel.ERROR), this.name, ...msg); + } + } + static isMoreOrEqualSevere(a, b) { + return ConsoleLogger.severity[a] >= ConsoleLogger.severity[b]; + } +} + +class EventManager { + endpoints; + /** @internal */ + listeners; + constructor(endpoints) { + this.endpoints = endpoints; + this.listeners = {}; + } + appendListener(eventName, predicate, listener) { + if (this.listeners[eventName]) { + this.listeners[eventName].push({ predicate, listener }); + } + else { + this.listeners[eventName] = [{ predicate, listener }]; + } + } + filteredEvent(eventName, predicate, listener) { + if (typeof predicate !== "function" || typeof listener !== "function") { + throw new Error("Event predicate and listener must be of type function."); + } + this.appendListener(eventName, predicate, listener); + } + async emit(eventName, payload) { + if (!this.listeners[eventName]) + return; + await Promise.all(this.listeners[eventName].map(async ({ predicate, listener }) => { + if (typeof predicate !== "undefined" && !predicate(payload)) + return; + await Promise.resolve(listener(payload)); + })); + } + event(eventName, listener) { + if (typeof listener !== "function") { + throw new Error("Event listener must be of type function."); + } + this.appendListener(eventName, undefined, listener); + } + withContext() { + throw new Error("Method not implemented. Only to be used for type."); + } +} + +/** @internal */ +const hashUrlValidationEvent = ({ payload: { plainToken } }, webhooksSecretToken) => ({ + encryptedToken: createHmac("sha256", webhooksSecretToken).update(plainToken).digest("hex"), + plainToken +}); +const isHashedUrlValidation = (obj) => typeof obj.encryptedToken === "string" && + typeof obj.plainToken === "string"; +const isRawUrlValidationEvent = (obj) => obj.event === "endpoint.url_validation" && typeof obj.payload.plainToken === "string"; +const isSkeletonEvent = (obj) => typeof obj.event === "string"; +class CommonHttpRequest { + headers; + payload; + webhooksSecretToken; + constructor(headers, payload, webhooksSecretToken) { + this.headers = headers; + this.payload = payload; + this.webhooksSecretToken = webhooksSecretToken; + } + static buildFromAwsEvent({ body, headers, isBase64Encoded }, webhooksSecretToken) { + try { + const rawBody = body ?? ""; + const decodedBody = isBase64Encoded ? Buffer.from(rawBody, "base64").toString("ascii") : rawBody; + const payload = JSON.parse(decodedBody); + return new CommonHttpRequest(headers, payload, webhooksSecretToken); + } + catch (err) { + throw err instanceof SyntaxError ? + new CommonHttpRequestError("Failed to parse payload string to JSON.", err) + : err; + } + } + static async buildFromIncomingMessage(incomingMessage, webhooksSecretToken) { + const bufferAsString = () => { + return new Promise((resolve, reject) => { + const body = []; + incomingMessage.on("data", (chunk) => body.push(chunk)); + incomingMessage.on("error", (err) => { + reject(err); + }); + incomingMessage.on("end", () => { + resolve(Buffer.concat(body).toString()); + }); + }); + }; + try { + const payload = JSON.parse(await bufferAsString()); + return new CommonHttpRequest(incomingMessage.headers, payload, webhooksSecretToken); + } + catch (err) { + if (err instanceof SyntaxError) { + throw new CommonHttpRequestError("Failed to parse payload string to JSON.", err); + } + throw err; + } + } + isEventVerified() { + const { signature, requestTimestamp } = this.parseHeaders(); + const messageToVerify = `v0:${requestTimestamp.toString()}:${JSON.stringify(this.payload)}`; + const hashToVerify = createHmac("sha256", this.webhooksSecretToken).update(messageToVerify).digest("hex"); + const signatureToVerify = `v0=${hashToVerify}`; + return signatureToVerify === signature; + } + parseHeaders() { + const findHeader = (header) => { + const foundHeader = Object.keys(this.headers).find((key) => key.toLowerCase() === header.toLowerCase()); + return foundHeader && this.headers[foundHeader]; + }; + const headerSignature = findHeader("x-zm-signature"); + const headerRequestTimestamp = findHeader("x-zm-request-timestamp"); + if (!headerSignature && !headerRequestTimestamp) { + throw new CommonHttpRequestError("Request payload must have signature and request timestamp from Zoom."); + } + return { + signature: headerSignature, + requestTimestamp: Number(headerRequestTimestamp) + }; + } + processEvent() { + if (!isSkeletonEvent(this.payload)) { + throw new CommonHttpRequestError("Request payload structure does not match expected from Zoom."); + } + if (!this.isEventVerified()) { + throw new CommonHttpRequestError("Failed to verify event originated from Zoom."); + } + if (isRawUrlValidationEvent(this.payload)) { + return hashUrlValidationEvent(this.payload, this.webhooksSecretToken); + } + return this.payload; + } +} + +var StatusCode; +(function (StatusCode) { + StatusCode[StatusCode["OK"] = 200] = "OK"; + StatusCode[StatusCode["TEMPORARY_REDIRECT"] = 302] = "TEMPORARY_REDIRECT"; + StatusCode[StatusCode["BAD_REQUEST"] = 400] = "BAD_REQUEST"; + StatusCode[StatusCode["NOT_FOUND"] = 404] = "NOT_FOUND"; + StatusCode[StatusCode["METHOD_NOT_ALLOWED"] = 405] = "METHOD_NOT_ALLOWED"; + StatusCode[StatusCode["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR"; +})(StatusCode || (StatusCode = {})); + +class AwsLambdaReceiver { + eventEmitter; + webhooksSecretToken; + constructor({ webhooksSecretToken }) { + this.webhooksSecretToken = webhooksSecretToken; + } + buildResponse(statusCode, body) { + return { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + statusCode + }; + } + canInstall() { + return false; + } + init({ eventEmitter }) { + this.eventEmitter = eventEmitter; + } + start() { + return async (event, context) => { + console.debug("Processing Lambda event ", JSON.stringify(event), " with context ", JSON.stringify(context)); + try { + const request = CommonHttpRequest.buildFromAwsEvent(event, this.webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + return this.buildResponse(StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + return this.buildResponse(StatusCode.OK, { message: "Zoom event processed successfully." }); + } + } + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + return this.buildResponse(StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + return this.buildResponse(StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } + } + }; + } + async stop() { + return Promise.resolve(); + } +} + +const prependSlashes = (strs) => { + const rawStrs = Array.isArray(strs) ? strs : [strs]; + const mappedStrs = rawStrs.map((rawStr) => (rawStr.startsWith("/") ? rawStr : `/${rawStr}`)); + return (Array.isArray(strs) ? mappedStrs : mappedStrs[0]); +}; + +class TokenMemoryStore { + currentToken; + getLatestToken() { + return this.currentToken; + } + storeToken(token) { + this.currentToken = token; + } +} + +/** @internal */ +const EXPIRATION_DELTA_SECONDS = 60; +/** @internal */ +const OAUTH_BASE_URL = "https://zoom.us"; +/** @internal */ +const OAUTH_TOKEN_PATH = "/oauth/token"; +/** + * {@link Auth} is the base implementation of authentication for Zoom's APIs. + * + * It only requires a `clientId` and `tokenStore`, as these options are shared across + * all authentication implementations, namely OAuth and server-to-server auth (client + * credentials, JWT, and server-to-server OAuth.) + */ +class Auth { + clientId; + clientSecret; + tokenStore; + logger; + constructor({ clientId, clientSecret, tokenStore, logger }) { + this.clientId = clientId; + this.clientSecret = clientSecret; + this.tokenStore = tokenStore ?? new TokenMemoryStore(); + this.logger = logger; + } + getBasicAuthorization() { + const clientCredentials = `${this.clientId}:${this.clientSecret}`; + return Buffer$1.from(clientCredentials).toString("base64"); + } + isAlmostExpired(isoTime) { + const currentDate = dayjs(); + return dayjs(isoTime).diff(currentDate, "seconds") <= EXPIRATION_DELTA_SECONDS; + } + async makeOAuthTokenRequest(grantType, payload) { + return await axios({ + method: "POST", + url: new URL(OAUTH_TOKEN_PATH, OAUTH_BASE_URL).toString(), + headers: { + Authorization: `Basic ${this.getBasicAuthorization()}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + data: new URLSearchParams({ grant_type: grantType, ...payload }), + validateStatus: (status) => status >= 200 && status <= 299 + }); + } +} + +const DEFAULT_EXPIRATION_SECONDS = 300; // 5 minutes +/** @internal */ +const ISSUER_URN = "urn:zoom:rivet-sdk"; +class JwtStateStore { + encodedSecret; + expirationSeconds; + constructor({ expirationSeconds, stateSecret }) { + this.encodedSecret = new TextEncoder().encode(stateSecret); + this.expirationSeconds = expirationSeconds ?? DEFAULT_EXPIRATION_SECONDS; + } + async generateState() { + const issuedTime = dayjs(); + const expirationTime = issuedTime.add(this.expirationSeconds, "seconds"); + return await new SignJWT({ random: randomBytes(8).toString("hex") }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setExpirationTime(expirationTime.toDate()) + .setIssuedAt(issuedTime.toDate()) + .setIssuer(ISSUER_URN) + .sign(this.encodedSecret); + } + async verifyState(state) { + try { + await jwtVerify(state, this.encodedSecret, { + algorithms: ["HS256"], + issuer: ISSUER_URN, + typ: "JWT" + }); + } + catch (err) { + throw new OAuthStateVerificationFailedError(`Failed to verify OAuth state: ${err.name}.`, { + cause: err + }); + } + } +} + +const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; +const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds +const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; +const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && + typeof obj.installerOptions.stateStore !== "undefined"; +/** + * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication + * is initiated server-side, but requires manual authorization from a user, by redirecting the user to Zoom. + * + * In addition to all required fields from {@link AuthOptions}, this class requires a `redirectUri`, as this + * value is appended to the authorization URL when the user is redirected to Zoom and subsequently redirected + * back to an endpoint on this server. + * + * @see {@link https://developers.zoom.us/docs/integrations/oauth/ | OAuth - Zoom Developers} + */ +class InteractiveAuth extends Auth { + installerOptions; + async getAuthorizationUrl() { + if (!this.installerOptions?.stateStore) { + throw new OAuthInstallerNotInitializedError("Cannot generate authorization URL, state store not initialized."); + } + const authUrl = new URL(OAUTH_AUTHORIZE_PATH, OAUTH_BASE_URL); + const generatedState = await Promise.resolve(this.installerOptions.stateStore.generateState()); + const { searchParams } = authUrl; + searchParams.set("client_id", this.clientId); + searchParams.set("redirect_uri", this.getFullRedirectUri()); + searchParams.set("response_type", "code"); + searchParams.set("state", generatedState); + return { + fullUrl: authUrl.toString(), + generatedState + }; + } + getFullRedirectUri() { + if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { + throw new OAuthInstallerNotInitializedError("Cannot generate full redirect URI, redirect URI or redirect URI path not initialized."); + } + return new URL(this.installerOptions.redirectUriPath, this.installerOptions.redirectUri).toString(); + } + // Don't return a type; we want it to be as narrow as possible (used for ReturnType). + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { + const updatedOptions = { + directInstall: Boolean(directInstall), + installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, + redirectUri, + redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE + }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } + this.installerOptions = updatedOptions; + return updatedOptions; + } +} + +const mergeDefaultOptions = (options, defaultOptions) => ({ ...defaultOptions, ...options }); + +const withDefaultTemplate = (cardContent, buttonContent) => ` + + + Zoom Rivet + + + + + + + + + +`; +/** + * Get the default HTML template that is shown to the developer/user when they visit the + * `installPath` endpoint, if Rivet currently has OAuth enabled. + * + * If `directInstall` is set to `true`, this function is not called; instead, the developer + * is directly redirected to Zoom's OAuth page. + */ +const defaultInstallTemplate = (authUrl) => withDefaultTemplate(`

Click the button below to navigate to Zoom to authorize your application for use with Rivet.

`, { href: authUrl, text: "Authorize with Zoom" }); +/** + * Get the default HTML template that is shown to the developer/user when they successfully + * authorize Rivet with a Zoom application. This is shown once they have already been redirected + * to Zoom, and the authorization attempt with Rivet was successful. + */ +const defaultCallbackSuccessTemplate = () => withDefaultTemplate(`

Your application has been successfully authorized with Rivet!

+

You may now close this page, or click the button below to redirect to Zoom's Marketplace.

`, { href: "https://marketplace.zoom.us", text: "Go to Marketplace" }); +/** + * Get the default HTML template that is shown to the developer when a known error occurs, meaning + * that the error is a core Rivet error. + */ +const defaultCallbackKnownErrorTemplate = (errName, errMessage) => withDefaultTemplate(`

An error occurred authorizing Rivet with Zoom.

+

[${errName}]: ${errMessage}

`); +/** + * Get the default HTML template that is shown to the developer when an unknown error occurs, + * meaning that the error is not known to be a core Rivet error and was thrown and not wrapped elsewhere. + */ +const defaultCallbackUnknownErrorTemplate = () => withDefaultTemplate(`

An unknown error occurred authorizing Rivet with Zoom. Please see stacktrace for details.

+

Please see stacktrace for further details.

`); + +const secureServerOptionKeys = [ + "ALPNProtocols", + "clientCertEngine", + "enableTrace", + "handshakeTimeout", + "rejectUnauthorized", + "requestCert", + "sessionTimeout", + "SNICallback", + "ticketKeys", + "pskCallback", + "pskIdentityHint", + "ca", + "cert", + "sigalgs", + "ciphers", + "clientCertEngine", + "crl", + "dhparam", + "ecdhCurve", + "honorCipherOrder", + "key", + "privateKeyEngine", + "privateKeyIdentifier", + "maxVersion", + "minVersion", + "passphrase", + "pfx", + "secureOptions", + "secureProtocol", + "sessionIdContext" +]; +class HttpReceiver { + /** @internal */ + static DEFAULT_ENDPOINT = "/zoom/events"; + eventEmitter; + interactiveAuth; + /** @internal */ + options; + server; + logger; + constructor(options) { + this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); + this.options.endpoints = prependSlashes(this.options.endpoints); + this.logger = + options.logger ?? + (() => { + const defaultLogger = new ConsoleLogger(); + defaultLogger.setLevel(options.logLevel ?? LogLevel.ERROR); + return defaultLogger; + })(); + } + canInstall() { + return true; + } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } + getServerCreator() { + return this.hasSecureOptions() ? createServer : createServer$1; + } + hasEndpoint(pathname) { + const { endpoints } = this.options; + return Array.isArray(endpoints) ? endpoints.includes(pathname) : endpoints === pathname; + } + hasSecureOptions() { + return Object.keys(this.options).some((option) => secureServerOptionKeys.includes(option)); + } + init({ eventEmitter, interactiveAuth }) { + this.eventEmitter = eventEmitter; + this.interactiveAuth = interactiveAuth; + } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } + start(port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { + const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; + this.logger.error(errorMessage); + throw new HTTPReceiverPortNotNumberError(errorMessage); + } + const listenPort = port ?? this.options.port; + return new Promise((resolve, reject) => { + this.server = this.getServerCreator()(this.options, (req, res) => void (async () => { + // `req.headers.host` should be used with care, as clients can manipulate this value. + // However, for this use case, the value is completely discarded and only `pathname` + // is used, which is why there's no further validation occurring. + const { pathname, searchParams } = new URL(req.url ?? "", `http://${req.headers.host ?? "localhost"}`); + const { interactiveAuth } = this; + this.logger.debug([pathname, searchParams]); + // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath + if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { + const { installerOptions } = interactiveAuth; + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); + await (installerOptions.directInstall ? + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); + return; + } + // The user has navigated to the redirect page; init the code + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); + try { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { + const errorMessage = "OAuth callback did not include code and/or state in request."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); + return; + } + catch (err) { + const htmlTemplate = isCoreError(err) ? + defaultCallbackKnownErrorTemplate(err.name, err.message) + : defaultCallbackUnknownErrorTemplate(); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); + return; + } + } + } + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; + } + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; + } + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } + } + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } + } + } + })()); + this.server.on("close", () => (this.server = undefined)); + this.server.on("error", (err) => { + this.logger.error(err.message); + reject(err); + }); + this.server.listen(listenPort, () => { + if (!this.server) { + throw new ReceiverInconsistentStateError(); + } + const { port: listeningPort } = this.server.address(); + this.logger.info(`Listening on port ${listeningPort.toString()}`); + resolve(this.server); + }); + }); + } + stop() { + if (!this.server) { + throw new ReceiverInconsistentStateError(); + } + return new Promise((resolve, reject) => { + this.server?.close((err) => { + if (err) { + this.logger.error(err.message); + reject(err); + } + }); + this.server = undefined; + resolve(); + }); + } + writeTemporaryRedirect(res, location, setCookie) { + return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } + res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); + res.end(() => { + resolve(); + }); + }); + } + writeResponse(res, statusCode, bodyContent, setCookie) { + return new Promise((resolve) => { + const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; + bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } + res.writeHead(statusCode, { "Content-Type": mimeType }); + res.end(bodyContent, () => { + resolve(); + }); + }); + } +} + +const version = "0.3.0"; + +class WebEndpoints { + /** @internal */ + static DEFAULT_BASE_URL = "https://api.zoom.us/v2"; + /** @internal */ + static DEFAULT_MIME_TYPE = "application/json"; + /** @internal */ + static DEFAULT_TIMEOUT = 0; + /** @internal */ + static GENERIC_ERROR_MESSAGE = "Request was unsuccessful with no further context"; + /** @internal */ + static TRACKING_ID_HEADER = "x-zm-trackingid"; + /** @internal */ + options; + constructor(options) { + this.options = mergeDefaultOptions(options, { + baseUrl: WebEndpoints.DEFAULT_BASE_URL, + hasCustomBaseUrl: typeof options.baseUrl !== "undefined", + timeout: WebEndpoints.DEFAULT_TIMEOUT + }); + } + buildEndpoint({ method, baseUrlOverride, urlPathBuilder, requestMimeType }) { + // @ts-expect-error: Some arguments may not be present, but we pass them to makeRequest() anyway. + // prettier-ignore + // Next AST node is ignored by Prettier, even though it exceed maximum line length, because TypeScript + // won't allow ts-expect-error directive on multiple lines (https://github.com/Microsoft/TypeScript/issues/19573). + return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); + } + buildUserAgent() { + return (`rivet/${version} ` + + `${basename(process.title)}/${process.version.replace("v", "")} ` + + `${os.platform()}/${os.release()}`); + } + getHeaders(bearerToken, contentType) { + return { + Accept: "application/json", + Authorization: `Bearer ${bearerToken}`, + "Content-Type": contentType, + "User-Agent": this.buildUserAgent() + }; + } + getRequestBody(args, mimeType) { + if (mimeType === "multipart/form-data") { + const formData = new FormData(); + Object.entries(args).forEach(([key, value]) => { + formData.append(key, value); + }); + return formData; + } + return args; + } + isOk(response) { + return response.status >= 200 && response.status <= 299; + } + isZoomResponseError(obj) { + return (typeof obj.code !== "undefined" && + typeof obj.message !== "undefined"); + } + async makeRequest(method, baseUrlOverride, url, requestContentType, bodyArgs, queryArgs) { + const { auth, baseUrl, doubleEncodeUrl, hasCustomBaseUrl, timeout } = this.options; + const bearerToken = await Promise.resolve(auth.getToken()); + const urlToSend = doubleEncodeUrl ? encodeURIComponent(encodeURIComponent(url)) : url; + const response = await axios({ + url: urlToSend, + method, + baseURL: hasCustomBaseUrl ? baseUrl : (baseUrlOverride ?? baseUrl), + headers: this.getHeaders(bearerToken, requestContentType), + params: queryArgs, + data: bodyArgs && this.getRequestBody(bodyArgs, requestContentType), + timeout: timeout, + beforeRedirect: (options) => { + options.headers = { + ...this.getHeaders(bearerToken, requestContentType), + ...options.headers + }; + }, + validateStatus: () => true // All responses are valid, not just 2xx + }); + if (!this.isOk(response)) { + const { status: statusCode } = response; + if (this.isZoomResponseError(response.data)) { + const { code: errorCode, message: errorMessage } = response.data; + throw new ApiResponseError(`[${statusCode.toString()}/${errorCode.toString()}]: "${errorMessage}"`); + } + throw new ApiResponseError(`[${statusCode.toString()}]: ${WebEndpoints.GENERIC_ERROR_MESSAGE}`); + } + return { + data: response.data, + statusCode: response.status, + trackingId: response.headers[WebEndpoints.TRACKING_ID_HEADER] + }; + } +} + +class MarketplaceEndpoints extends WebEndpoints { + app = { + sendAppNotifications: this.buildEndpoint({ + method: "POST", + urlPathBuilder: () => `/app/notifications` + }), + getUserOrAccountEventSubscription: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/marketplace/app/event_subscription` }), + createEventSubscription: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/marketplace/app/event_subscription` }), + unsubscribeAppEventSubscription: this.buildEndpoint({ method: "DELETE", urlPathBuilder: () => `/marketplace/app/event_subscription` }), + deleteEventSubscription: this.buildEndpoint({ + method: "DELETE", + urlPathBuilder: ({ eventSubscriptionId }) => `/marketplace/app/event_subscription/${eventSubscriptionId}` + }), + subscribeEventSubscription: this.buildEndpoint({ + method: "PATCH", + urlPathBuilder: ({ eventSubscriptionId }) => `/marketplace/app/event_subscription/${eventSubscriptionId}` + }), + listApps: this.buildEndpoint({ + method: "GET", + urlPathBuilder: () => `/marketplace/apps` + }), + createApps: this.buildEndpoint({ + method: "POST", + urlPathBuilder: () => `/marketplace/apps` + }), + getInformationAboutApp: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}` }), + getAppsUserRequests: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/requests` }), + addAppAllowRequestsForUsers: this.buildEndpoint({ method: "POST", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/requests` }), + updateAppsRequestStatus: this.buildEndpoint({ method: "PATCH", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/requests` }), + getWebhookLogs: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/webhook_logs` }), + getAppUserEntitlements: this.buildEndpoint({ method: "GET", urlPathBuilder: () => `/marketplace/monetization/entitlements` }), + getUsersAppRequests: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/marketplace/users/${userId}/apps` }), + getUsersEntitlements: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ userId }) => `/marketplace/users/${userId}/entitlements` }) + }; + apps = { + generateAppDeeplink: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/zoomapp/deeplink` }) + }; + manifest = { + validateAppManifest: this.buildEndpoint({ method: "POST", urlPathBuilder: () => `/marketplace/apps/manifest/validate` }), + exportAppManifestFromExistingApp: this.buildEndpoint({ method: "GET", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/manifest` }), + updateAppByManifest: this.buildEndpoint({ method: "PUT", urlPathBuilder: ({ appId }) => `/marketplace/apps/${appId}/manifest` }) + }; +} + +class MarketplaceEventProcessor extends EventManager { +} + +class OAuth extends InteractiveAuth { + assertResponseAccessToken(data) { + if (typeof data.access_token !== "string" || + typeof data.refresh_token !== "string" || + typeof data.expires_in !== "number" || + typeof data.scope !== "string") { + throw new OAuthTokenRawResponseError(`Failed to match raw response (${JSON.stringify(data)}) to expected shape.`); + } + } + async fetchAccessToken(code) { + try { + const response = await this.makeOAuthTokenRequest("authorization_code", { + code, + redirect_uri: this.getFullRedirectUri() + }); + this.assertResponseAccessToken(response.data); + return this.mapOAuthToken(response.data); + } + catch (err) { + throw new OAuthTokenFetchFailedError("Failed to fetch OAuth token.", { cause: err }); + } + } + async getToken() { + const { tokenStore } = this; + const currentToken = await Promise.resolve(tokenStore.getLatestToken()); + // If we have no OAuth token, app most likely has not been previously authorized. + if (!currentToken) { + throw new OAuthTokenDoesNotExistError("Failed to find OAuth token. Authorize this app first."); + } + // If the OAuth token hasn't already expired (and isn't within the delta), return it. + if (!this.isAlmostExpired(currentToken.expirationTimeIso)) { + return currentToken.accessToken; + } + // Since the token has expired, refresh, store, and return it. + const refreshedToken = await this.refreshAccessToken(currentToken.refreshToken); + await Promise.resolve(tokenStore.storeToken(refreshedToken)); + return refreshedToken.accessToken; + } + async initRedirectCode(code) { + const { tokenStore } = this; + const accessToken = await this.fetchAccessToken(code); + await Promise.resolve(tokenStore.storeToken(accessToken)); + } + mapOAuthToken({ access_token, expires_in, refresh_token, scope }) { + return { + accessToken: access_token, + expirationTimeIso: dayjs().add(expires_in, "seconds").toISOString(), + refreshToken: refresh_token, + scopes: scope.includes(" ") ? scope.split(" ") : [scope] + }; + } + async refreshAccessToken(refreshToken) { + try { + const response = await this.makeOAuthTokenRequest("refresh_token", { + refresh_token: refreshToken + }); + this.assertResponseAccessToken(response.data); + return this.mapOAuthToken(response.data); + } + catch (err) { + throw new OAuthTokenRefreshFailedError("Failed to refresh OAuth token.", { cause: err }); + } + } +} + +// Utility functions for determining if client options include custom receiver, or, if not, +// a webhooks secret token, as one of those is required! +const hasExplicitReceiver = (obj) => typeof obj.receiver !== "undefined"; +const hasWebhooksSecretToken = (obj) => typeof obj.webhooksSecretToken !== "undefined"; +const isReceiverDisabled = (options) => typeof options.disableReceiver !== "undefined" && options.disableReceiver; +const DEFAULT_HTTP_RECEIVER_PORT = 8080; +const DEFAULT_LOGLEVEL = LogLevel.ERROR; +class ProductClient { + auth; + endpoints; + webEventConsumer; + receiver; + constructor(options) { + this.auth = this.initAuth(options); + this.endpoints = this.initEndpoints(this.auth, options); + this.webEventConsumer = this.initEventProcessor(this.endpoints, options); + // Only create an instance of `this.receiver` if the developer did not explicitly disable it. + if (!isReceiverDisabled(options)) { + // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); + } + this.receiver = (hasExplicitReceiver(options) ? + options.receiver + : this.initDefaultReceiver(options)); + this.receiver.init({ + eventEmitter: this.webEventConsumer, + interactiveAuth: this.auth instanceof InteractiveAuth ? this.auth : undefined + }); + } + } + initDefaultReceiver({ port, webhooksSecretToken, logLevel }) { + return new HttpReceiver({ + port: port ?? DEFAULT_HTTP_RECEIVER_PORT, + webhooksSecretToken, + logLevel: logLevel ?? DEFAULT_LOGLEVEL + }); + } + async start() { + if (!this.receiver) { + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); + } + // Method call is wrapped in `await` and `Promise.resolve()`, as the call + // may or may not return a promise. This is not required when implementing `Receiver`. + return (await Promise.resolve(this.receiver.start())); + } +} + +class MarketplaceOAuthClient extends ProductClient { + initAuth({ clientId, clientSecret, tokenStore, ...restOptions }) { + const oAuth = new OAuth({ clientId, clientSecret, tokenStore }); + if (hasInstallerOptions(restOptions)) { + oAuth.setInstallerOptions(restOptions.installerOptions); + } + return oAuth; + } + initEndpoints(auth, options) { + return new MarketplaceEndpoints({ auth, ...options }); + } + initEventProcessor(endpoints) { + return new MarketplaceEventProcessor(endpoints); + } +} + +class S2SAuth extends Auth { + accountId; + constructor({ accountId, ...restOptions }) { + super(restOptions); + this.accountId = accountId; + } + assertRawToken(obj) { + if (typeof obj.access_token !== "string" || + typeof obj.expires_in !== "number" || + typeof obj.scope !== "string") { + throw new S2SRawResponseError(`Failed to match raw response ${JSON.stringify(obj)} to expected shape.`); + } + } + async fetchAccessToken() { + const response = await this.makeOAuthTokenRequest("account_credentials", { + account_id: this.accountId + }); + this.assertRawToken(response.data); + return this.mapAccessToken(response.data); + } + async getToken() { + const { tokenStore } = this; + const currentToken = await Promise.resolve(tokenStore.getLatestToken()); + if (currentToken && !this.isAlmostExpired(currentToken.expirationTimeIso)) { + return currentToken.accessToken; + } + const token = await this.fetchAccessToken(); + await Promise.resolve(tokenStore.storeToken(token)); + return token.accessToken; + } + mapAccessToken({ access_token, expires_in, scope }) { + return { + accessToken: access_token, + expirationTimeIso: dayjs().add(expires_in, "seconds").toISOString(), + scopes: scope.includes(" ") ? scope.split(" ") : [scope] + }; + } +} + +class MarketplaceS2SAuthClient extends ProductClient { + initAuth({ clientId, clientSecret, tokenStore, accountId }) { + return new S2SAuth({ clientId, clientSecret, tokenStore, accountId }); + } + initEndpoints(auth, options) { + return new MarketplaceEndpoints({ auth, ...options }); + } + initEventProcessor(endpoints) { + return new MarketplaceEventProcessor(endpoints); + } +} + +export { ApiResponseError, AwsLambdaReceiver, AwsReceiverRequestError, ClientCredentialsRawResponseError, CommonHttpRequestError, ConsoleLogger, HTTPReceiverConstructionError, HTTPReceiverPortNotNumberError, HTTPReceiverRequestError, HttpReceiver, LogLevel, MarketplaceEndpoints, MarketplaceEventProcessor, MarketplaceOAuthClient, MarketplaceS2SAuthClient, OAuthInstallerNotInitializedError, OAuthStateVerificationFailedError, OAuthTokenDoesNotExistError, OAuthTokenFetchFailedError, OAuthTokenRawResponseError, OAuthTokenRefreshFailedError, ProductClientConstructionError, ReceiverInconsistentStateError, ReceiverOAuthFlowError, S2SRawResponseError, StatusCode, isCoreError, isStateStore }; diff --git a/meetings/meetings.cjs b/meetings/meetings.cjs index 16978aa..f249849 100644 --- a/meetings/meetings.cjs +++ b/meetings/meetings.cjs @@ -426,6 +426,9 @@ class JwtStateStore { const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -452,7 +455,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -462,14 +468,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -581,9 +593,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -597,6 +606,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; } @@ -611,8 +633,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -629,69 +663,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, exports.StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -725,18 +774,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -745,86 +800,7 @@ class HttpReceiver { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -854,7 +830,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } @@ -1322,7 +1298,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -1343,7 +1321,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. diff --git a/meetings/meetings.d.ts b/meetings/meetings.d.ts index 2efb504..4d0c72b 100644 --- a/meetings/meetings.d.ts +++ b/meetings/meetings.d.ts @@ -1,96 +1,9 @@ -import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { Server } from 'node:http'; import { ServerOptions } from 'node:https'; import { ReadStream } from 'node:fs'; -type AllKeysOf = T extends any ? keyof T : never; -type AllPropsOptional = Exclude<{ - [P in keyof T]: undefined extends T[P] ? True : False; -}[keyof T], undefined> extends True ? True : False; -type Constructor = new (...args: any[]) => T; -type ExactlyOneOf = { - [K in keyof T]: T[K] & ProhibitKeys, keyof T[K]>>; -}[number]; -type MaybeArray = T | T[]; -type MaybePromise = T | Promise; -type ProhibitKeys = { - [P in K]?: never; -}; -type StringIndexed = Record; - -/** - * {@link StateStore} defines methods for generating and verifying OAuth state. - * - * This interface is implemented internally for the default state store; however, - * it can also be implemented and passed to an OAuth client as well. - */ -interface StateStore { - /** - * Generate a new state string, which is directly appended to the OAuth `state` parameter. - */ - generateState(): MaybePromise; - /** - * Verify that the state received during OAuth callback is valid and not forged. - * - * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. - * - * @param state The state parameter that was received during OAuth callback - */ - verifyState(state: string): MaybePromise; -} -/** - * Guard if an object implements the {@link StateStore} interface — most notably, - * `generateState()` and `verifyState(state: string)`. - */ -declare const isStateStore: (obj: unknown) => obj is StateStore; - -interface TokenStore { - getLatestToken(): MaybePromise; - storeToken(token: Token): MaybePromise; -} - -interface RivetError extends Error { - readonly errorCode: ErrorCode; -} - -declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ - readonly ApiResponseError: "zoom_rivet_api_response_error"; - readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; - readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; - readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; - readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; - readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; - readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; - readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; - readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; - readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; - readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; - readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; - readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; - readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; - readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; - readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; - readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; -}[K]>; -declare const ApiResponseError: Constructor; -declare const AwsReceiverRequestError: Constructor; -declare const ClientCredentialsRawResponseError: Constructor; -declare const S2SRawResponseError: Constructor; -declare const CommonHttpRequestError: Constructor; -declare const ReceiverInconsistentStateError: Constructor; -declare const ReceiverOAuthFlowError: Constructor; -declare const HTTPReceiverConstructionError: Constructor; -declare const HTTPReceiverPortNotNumberError: Constructor; -declare const HTTPReceiverRequestError: Constructor; -declare const OAuthInstallerNotInitializedError: Constructor; -declare const OAuthTokenDoesNotExistError: Constructor; -declare const OAuthTokenFetchFailedError: Constructor; -declare const OAuthTokenRawResponseError: Constructor; -declare const OAuthTokenRefreshFailedError: Constructor; -declare const OAuthStateVerificationFailedError: Constructor; -declare const ProductClientConstructionError: Constructor; - declare enum LogLevel { ERROR = "error", WARN = "warn", @@ -150,6 +63,26 @@ declare class ConsoleLogger implements Logger { private static isMoreOrEqualSevere; } +type AllKeysOf = T extends any ? keyof T : never; +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type ExactlyOneOf = { + [K in keyof T]: T[K] & ProhibitKeys, keyof T[K]>>; +}[number]; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type ProhibitKeys = { + [P in K]?: never; +}; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + interface AuthOptions { clientId: string; clientSecret: string; @@ -195,6 +128,17 @@ declare abstract class Auth { }>, "grant_type">): Promise; } +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} + interface S2SAuthToken { accessToken: string; expirationTimeIso: string; @@ -233,12 +177,31 @@ declare class EventManager { protected withContext, Context>(): ContextListener; } +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + interface HttpReceiverOptions extends Partial { endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; port?: number | string | undefined; - webhooksSecretToken: string; - logger?: Logger; - logLevel?: LogLevel; + webhooksSecretToken?: string | undefined; } type SecureServerOptions = { [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; @@ -251,10 +214,15 @@ declare class HttpReceiver implements Receiver { private logger; constructor(options: HttpReceiverOptions); canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; private getServerCreator; private hasEndpoint; private hasSecureOptions; init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; start(port?: number | string): Promise; stop(): Promise; private writeTemporaryRedirect; @@ -313,38 +281,68 @@ type CommonClientOptions = GetAuthOptions interface ClientReceiverOptions { receiver: R; } -type ClientConstructorOptions, R extends Receiver> = IsReceiverDisabled extends true ? O : O & (ClientReceiverOptions | HttpReceiverOptions); +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); type ExtractInstallerOptions = A extends InteractiveAuth ? [ ReturnType ] extends [true] ? WideInstallerOptions : object : object; type ExtractAuthTokenType = A extends Auth ? T : never; -type GenericClientOptions = CommonClientOptions; type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); -type IsReceiverDisabled> = [ - O["disableReceiver"] -] extends [true] ? true : false; type WideInstallerOptions = { installerOptions: InstallerOptions; }; declare abstract class ProductClient, ReceiverType extends Receiver> { private readonly auth; readonly endpoints: EndpointsType; - readonly webEventConsumer: EventProcessorType; + readonly webEventConsumer?: EventProcessorType | undefined; private readonly receiver?; constructor(options: ClientConstructorOptions); protected abstract initAuth(options: OptionsType): AuthType; protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; - protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; private initDefaultReceiver; - start(this: IsReceiverDisabled extends true ? never : this): Promise>; + start(): Promise>; +} + +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; } +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} interface InstallerOptions { directInstall?: boolean | undefined; installPath?: string | undefined; redirectUri: string; redirectUriPath?: string | undefined; stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; } /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -358,36 +356,87 @@ interface InstallerOptions { */ declare abstract class InteractiveAuth extends Auth { installerOptions?: ReturnType; - getAuthorizationUrl(): Promise; + getAuthorizationUrl(): Promise; getFullRedirectUri(): string; - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }: InstallerOptions): { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { directInstall: boolean; installPath: string; redirectUri: string; redirectUriPath: string; stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; }; } -declare enum StatusCode { - OK = 200, - TEMPORARY_REDIRECT = 302, - BAD_REQUEST = 400, - NOT_FOUND = 404, - METHOD_NOT_ALLOWED = 405, - INTERNAL_SERVER_ERROR = 500 +/** + * Credentials for access token & refresh token, which are used to access Zoom's APIs. + * + * As access token is short-lived (usually a single hour), its expiration time is checked + * first. If it's possible to use the access token, it's used; however, if it has expired + * or is close to expiring, the refresh token should be used to generate a new access token + * before the API call is made. Refresh tokens are generally valid for 90 days. + * + * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} + * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. + * It's likely that this error will be rare, but it _can_ be thrown. + */ +interface OAuthToken { + accessToken: string; + expirationTimeIso: string; + refreshToken: string; + scopes: string[]; } -interface ReceiverInitOptions { - eventEmitter: GenericEventManager; - interactiveAuth?: InteractiveAuth | undefined; +declare class OAuth extends InteractiveAuth { + private assertResponseAccessToken; + private fetchAccessToken; + getToken(): Promise; + initRedirectCode(code: string): Promise; + private mapOAuthToken; + private refreshAccessToken; } -interface Receiver { - canInstall(): true | false; - init(options: ReceiverInitOptions): void; - start(...args: any[]): MaybePromise; - stop(...args: any[]): MaybePromise; + +interface RivetError extends Error { + readonly errorCode: ErrorCode; } +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + interface AwsLambdaReceiverOptions { webhooksSecretToken: string; } @@ -8045,33 +8094,6 @@ type MeetingsEvents = WebinarSharingStartedEvent | MeetingParticipantJbhWaitingE declare class MeetingsEventProcessor extends EventManager { } -/** - * Credentials for access token & refresh token, which are used to access Zoom's APIs. - * - * As access token is short-lived (usually a single hour), its expiration time is checked - * first. If it's possible to use the access token, it's used; however, if it has expired - * or is close to expiring, the refresh token should be used to generate a new access token - * before the API call is made. Refresh tokens are generally valid for 90 days. - * - * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} - * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. - * It's likely that this error will be rare, but it _can_ be thrown. - */ -interface OAuthToken { - accessToken: string; - expirationTimeIso: string; - refreshToken: string; - scopes: string[]; -} -declare class OAuth extends InteractiveAuth { - private assertResponseAccessToken; - private fetchAccessToken; - getToken(): Promise; - initRedirectCode(code: string): Promise; - private mapOAuthToken; - private refreshAccessToken; -} - type MeetingsOptions = CommonClientOptions; declare class MeetingsOAuthClient = MeetingsOptions> extends ProductClient { protected initAuth({ clientId, clientSecret, tokenStore, ...restOptions }: OptionsType): OAuth; @@ -8086,4 +8108,4 @@ declare class MeetingsS2SAuthClient typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -450,7 +453,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -460,14 +466,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -579,9 +591,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -595,6 +604,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? createServer : createServer$1; } @@ -609,8 +631,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -627,69 +661,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -723,18 +772,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -743,86 +798,7 @@ class HttpReceiver { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -852,7 +828,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } @@ -1320,7 +1296,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -1341,7 +1319,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. diff --git a/package.json b/package.json index 406517f..c1ddef5 100644 --- a/package.json +++ b/package.json @@ -1,138 +1,100 @@ { - "type": "module", - "name": "@zoom/rivet", - "author": "Zoom Communications, Inc.", - "contributors": [ - { - "name": "James Coon", - "email": "james.coon@zoom.us", - "url": "https://www.npmjs.com/~jcoon97" - }, - { - "name": "Will Ezrine", - "email": "will.ezrine@zoom.us", - "url": "https://www.npmjs.com/~wezrine" - }, - { - "name": "Tommy Gaessler", - "email": "tommy.gaessler@zoom.us", - "url": "https://www.npmjs.com/~tommygaessler" - } - ], - "packageManager": "pnpm@9.9.0", - "version": "0.2.2", - "scripts": { - "test": "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - "prepare": "husky", - "lint": "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" + "type": "module", + "name": "@zoom/rivet", + "author": "Zoom Communications, Inc.", + "contributors": [ + { + "name": "James Coon", + "email": "james.coon@zoom.us", + "url": "https://www.npmjs.com/~jcoon97" }, - "devDependencies": { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - "dotenv": "^16.4.5", - "eslint": "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - "husky": "^9.1.6", - "lint-staged": "^15.2.10", - "nock": "^13.5.5", - "prettier": "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - "rollup": "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - "semver": "^7.6.3", - "supertest": "^7.0.0", - "ts-node": "^10.9.2", - "tslib": "^2.7.0", - "typescript": "^5.6.3", - "typescript-eslint": "^8.8.1", - "vitest": "2.1.3" + { + "name": "Will Ezrine", + "email": "will.ezrine@zoom.us", + "url": "https://www.npmjs.com/~wezrine" }, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] + { + "name": "Tommy Gaessler", + "email": "tommy.gaessler@zoom.us", + "url": "https://www.npmjs.com/~tommygaessler" + } + ], + "packageManager": "pnpm@9.9.0", + "version": "0.3.0", + "license": "SEE LICENSE IN LICENSE.md", + "repository": { + "type": "git", + "url": "git+https://github.com/zoom/rivet-javascript.git" + }, + "keywords": [ + "zoom", + "rivet", + "api", + "endpoint", + "webhook", + "event", + "sdk" + ], + "bugs": { + "url": "https://github.com/zoom/rivet-javascript/issues" + }, + "homepage": "https://developers.zoom.us/docs/rivet/javascript/", + "exports": { + ".": { + "import": "./index.mjs", + "require": "./index.cjs", + "types": "./index.d.ts" + }, + "./chatbot": { + "import": "./chatbot/chatbot.mjs", + "require": "./chatbot/chatbot.cjs", + "types": "./chatbot/chatbot.d.ts" + }, + "./commerce": { + "import": "./commerce/commerce.mjs", + "require": "./commerce/commerce.cjs", + "types": "./commerce/commerce.d.ts" + }, + "./teamchat": { + "import": "./teamchat/teamchat.mjs", + "require": "./teamchat/teamchat.cjs", + "types": "./teamchat/teamchat.d.ts" + }, + "./users": { + "import": "./users/users.mjs", + "require": "./users/users.cjs", + "types": "./users/users.d.ts" + }, + "./marketplace": { + "import": "./marketplace/marketplace.mjs", + "require": "./marketplace/marketplace.cjs", + "types": "./marketplace/marketplace.d.ts" }, - "license": "SEE LICENSE IN LICENSE.md", - "repository": { - "type": "git", - "url": "git+https://github.com/zoom/rivet-javascript.git" + "./phone": { + "import": "./phone/phone.mjs", + "require": "./phone/phone.cjs", + "types": "./phone/phone.d.ts" }, - "keywords": [ - "zoom", - "rivet", - "api", - "endpoint", - "webhook", - "event", - "sdk" - ], - "bugs": { - "url": "https://github.com/zoom/rivet-javascript/issues" + "./accounts": { + "import": "./accounts/accounts.mjs", + "require": "./accounts/accounts.cjs", + "types": "./accounts/accounts.d.ts" }, - "homepage": "https://developers.zoom.us/docs/rivet/javascript/", - "exports": { - ".": { - "import": "./index.mjs", - "require": "./index.cjs", - "types": "./index.d.ts" - }, - "./chatbot": { - "import": "./chatbot/chatbot.mjs", - "require": "./chatbot/chatbot.cjs", - "types": "./chatbot/chatbot.d.ts" - }, - "./teamchat": { - "import": "./teamchat/teamchat.mjs", - "require": "./teamchat/teamchat.cjs", - "types": "./teamchat/teamchat.d.ts" - }, - "./users": { - "import": "./users/users.mjs", - "require": "./users/users.cjs", - "types": "./users/users.d.ts" - }, - "./phone": { - "import": "./phone/phone.mjs", - "require": "./phone/phone.cjs", - "types": "./phone/phone.d.ts" - }, - "./accounts": { - "import": "./accounts/accounts.mjs", - "require": "./accounts/accounts.cjs", - "types": "./accounts/accounts.d.ts" - }, - "./meetings": { - "import": "./meetings/meetings.mjs", - "require": "./meetings/meetings.cjs", - "types": "./meetings/meetings.d.ts" - }, - "./videosdk": { - "import": "./videosdk/videosdk.mjs", - "require": "./videosdk/videosdk.cjs", - "types": "./videosdk/videosdk.d.ts" - } + "./meetings": { + "import": "./meetings/meetings.mjs", + "require": "./meetings/meetings.cjs", + "types": "./meetings/meetings.d.ts" }, - "dependencies": { - "axios": "^1.7.9", - "dayjs": "^1.11.13", - "form-data": "^4.0.1", - "jose": "^5.9.4" + "./videosdk": { + "import": "./videosdk/videosdk.mjs", + "require": "./videosdk/videosdk.cjs", + "types": "./videosdk/videosdk.d.ts" } + }, + "dependencies": { + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "form-data": "^4.0.1", + "jose": "^5.9.4" + } } \ No newline at end of file diff --git a/phone/phone.cjs b/phone/phone.cjs index 91ea27e..0689cdb 100644 --- a/phone/phone.cjs +++ b/phone/phone.cjs @@ -426,6 +426,9 @@ class JwtStateStore { const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -452,7 +455,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -462,14 +468,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -581,9 +593,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -597,6 +606,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; } @@ -611,8 +633,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -629,69 +663,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, exports.StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -725,18 +774,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -745,86 +800,7 @@ class HttpReceiver { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -854,7 +830,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } @@ -1749,7 +1725,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -1770,7 +1748,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. diff --git a/phone/phone.d.ts b/phone/phone.d.ts index 5441f91..a4398c0 100644 --- a/phone/phone.d.ts +++ b/phone/phone.d.ts @@ -1,88 +1,8 @@ -import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { Server } from 'node:http'; import { ServerOptions } from 'node:https'; -type AllPropsOptional = Exclude<{ - [P in keyof T]: undefined extends T[P] ? True : False; -}[keyof T], undefined> extends True ? True : False; -type Constructor = new (...args: any[]) => T; -type MaybeArray = T | T[]; -type MaybePromise = T | Promise; -type StringIndexed = Record; - -/** - * {@link StateStore} defines methods for generating and verifying OAuth state. - * - * This interface is implemented internally for the default state store; however, - * it can also be implemented and passed to an OAuth client as well. - */ -interface StateStore { - /** - * Generate a new state string, which is directly appended to the OAuth `state` parameter. - */ - generateState(): MaybePromise; - /** - * Verify that the state received during OAuth callback is valid and not forged. - * - * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. - * - * @param state The state parameter that was received during OAuth callback - */ - verifyState(state: string): MaybePromise; -} -/** - * Guard if an object implements the {@link StateStore} interface — most notably, - * `generateState()` and `verifyState(state: string)`. - */ -declare const isStateStore: (obj: unknown) => obj is StateStore; - -interface TokenStore { - getLatestToken(): MaybePromise; - storeToken(token: Token): MaybePromise; -} - -interface RivetError extends Error { - readonly errorCode: ErrorCode; -} - -declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ - readonly ApiResponseError: "zoom_rivet_api_response_error"; - readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; - readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; - readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; - readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; - readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; - readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; - readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; - readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; - readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; - readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; - readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; - readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; - readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; - readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; - readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; - readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; -}[K]>; -declare const ApiResponseError: Constructor; -declare const AwsReceiverRequestError: Constructor; -declare const ClientCredentialsRawResponseError: Constructor; -declare const S2SRawResponseError: Constructor; -declare const CommonHttpRequestError: Constructor; -declare const ReceiverInconsistentStateError: Constructor; -declare const ReceiverOAuthFlowError: Constructor; -declare const HTTPReceiverConstructionError: Constructor; -declare const HTTPReceiverPortNotNumberError: Constructor; -declare const HTTPReceiverRequestError: Constructor; -declare const OAuthInstallerNotInitializedError: Constructor; -declare const OAuthTokenDoesNotExistError: Constructor; -declare const OAuthTokenFetchFailedError: Constructor; -declare const OAuthTokenRawResponseError: Constructor; -declare const OAuthTokenRefreshFailedError: Constructor; -declare const OAuthStateVerificationFailedError: Constructor; -declare const ProductClientConstructionError: Constructor; - declare enum LogLevel { ERROR = "error", WARN = "warn", @@ -142,6 +62,19 @@ declare class ConsoleLogger implements Logger { private static isMoreOrEqualSevere; } +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + interface AuthOptions { clientId: string; clientSecret: string; @@ -187,6 +120,17 @@ declare abstract class Auth { }>, "grant_type">): Promise; } +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} + interface S2SAuthToken { accessToken: string; expirationTimeIso: string; @@ -225,12 +169,31 @@ declare class EventManager { protected withContext, Context>(): ContextListener; } +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + interface HttpReceiverOptions extends Partial { endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; port?: number | string | undefined; - webhooksSecretToken: string; - logger?: Logger; - logLevel?: LogLevel; + webhooksSecretToken?: string | undefined; } type SecureServerOptions = { [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; @@ -243,10 +206,15 @@ declare class HttpReceiver implements Receiver { private logger; constructor(options: HttpReceiverOptions); canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; private getServerCreator; private hasEndpoint; private hasSecureOptions; init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; start(port?: number | string): Promise; stop(): Promise; private writeTemporaryRedirect; @@ -305,38 +273,68 @@ type CommonClientOptions = GetAuthOptions interface ClientReceiverOptions { receiver: R; } -type ClientConstructorOptions, R extends Receiver> = IsReceiverDisabled extends true ? O : O & (ClientReceiverOptions | HttpReceiverOptions); +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); type ExtractInstallerOptions = A extends InteractiveAuth ? [ ReturnType ] extends [true] ? WideInstallerOptions : object : object; type ExtractAuthTokenType = A extends Auth ? T : never; -type GenericClientOptions = CommonClientOptions; type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); -type IsReceiverDisabled> = [ - O["disableReceiver"] -] extends [true] ? true : false; type WideInstallerOptions = { installerOptions: InstallerOptions; }; declare abstract class ProductClient, ReceiverType extends Receiver> { private readonly auth; readonly endpoints: EndpointsType; - readonly webEventConsumer: EventProcessorType; + readonly webEventConsumer?: EventProcessorType | undefined; private readonly receiver?; constructor(options: ClientConstructorOptions); protected abstract initAuth(options: OptionsType): AuthType; protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; - protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; private initDefaultReceiver; - start(this: IsReceiverDisabled extends true ? never : this): Promise>; + start(): Promise>; } +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; +} +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; + +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} interface InstallerOptions { directInstall?: boolean | undefined; installPath?: string | undefined; redirectUri: string; redirectUriPath?: string | undefined; stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; } /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -350,36 +348,87 @@ interface InstallerOptions { */ declare abstract class InteractiveAuth extends Auth { installerOptions?: ReturnType; - getAuthorizationUrl(): Promise; + getAuthorizationUrl(): Promise; getFullRedirectUri(): string; - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }: InstallerOptions): { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { directInstall: boolean; installPath: string; redirectUri: string; redirectUriPath: string; stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; }; } -declare enum StatusCode { - OK = 200, - TEMPORARY_REDIRECT = 302, - BAD_REQUEST = 400, - NOT_FOUND = 404, - METHOD_NOT_ALLOWED = 405, - INTERNAL_SERVER_ERROR = 500 +/** + * Credentials for access token & refresh token, which are used to access Zoom's APIs. + * + * As access token is short-lived (usually a single hour), its expiration time is checked + * first. If it's possible to use the access token, it's used; however, if it has expired + * or is close to expiring, the refresh token should be used to generate a new access token + * before the API call is made. Refresh tokens are generally valid for 90 days. + * + * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} + * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. + * It's likely that this error will be rare, but it _can_ be thrown. + */ +interface OAuthToken { + accessToken: string; + expirationTimeIso: string; + refreshToken: string; + scopes: string[]; } -interface ReceiverInitOptions { - eventEmitter: GenericEventManager; - interactiveAuth?: InteractiveAuth | undefined; +declare class OAuth extends InteractiveAuth { + private assertResponseAccessToken; + private fetchAccessToken; + getToken(): Promise; + initRedirectCode(code: string): Promise; + private mapOAuthToken; + private refreshAccessToken; } -interface Receiver { - canInstall(): true | false; - init(options: ReceiverInitOptions): void; - start(...args: any[]): MaybePromise; - stop(...args: any[]): MaybePromise; + +interface RivetError extends Error { + readonly errorCode: ErrorCode; } +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + interface AwsLambdaReceiverOptions { webhooksSecretToken: string; } @@ -12706,33 +12755,6 @@ type PhoneEvents = PhoneRecordingDeletedEvent | PhoneCallerCallLogCompletedEvent declare class PhoneEventProcessor extends EventManager { } -/** - * Credentials for access token & refresh token, which are used to access Zoom's APIs. - * - * As access token is short-lived (usually a single hour), its expiration time is checked - * first. If it's possible to use the access token, it's used; however, if it has expired - * or is close to expiring, the refresh token should be used to generate a new access token - * before the API call is made. Refresh tokens are generally valid for 90 days. - * - * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} - * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. - * It's likely that this error will be rare, but it _can_ be thrown. - */ -interface OAuthToken { - accessToken: string; - expirationTimeIso: string; - refreshToken: string; - scopes: string[]; -} -declare class OAuth extends InteractiveAuth { - private assertResponseAccessToken; - private fetchAccessToken; - getToken(): Promise; - initRedirectCode(code: string): Promise; - private mapOAuthToken; - private refreshAccessToken; -} - type PhoneOptions = CommonClientOptions; declare class PhoneOAuthClient = PhoneOptions> extends ProductClient { protected initAuth({ clientId, clientSecret, tokenStore, ...restOptions }: OptionsType): OAuth; @@ -12747,4 +12769,4 @@ declare class PhoneS2SAuthClient typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -450,7 +453,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -460,14 +466,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -579,9 +591,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -595,6 +604,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? createServer : createServer$1; } @@ -609,8 +631,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -627,69 +661,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -723,18 +772,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -743,86 +798,7 @@ class HttpReceiver { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -852,7 +828,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } @@ -1747,7 +1723,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -1768,7 +1746,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. diff --git a/teamchat/teamchat.cjs b/teamchat/teamchat.cjs index 4792e06..31a22ae 100644 --- a/teamchat/teamchat.cjs +++ b/teamchat/teamchat.cjs @@ -426,6 +426,9 @@ class JwtStateStore { const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -452,7 +455,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -462,14 +468,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -581,9 +593,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -597,6 +606,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; } @@ -611,8 +633,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -629,69 +663,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, exports.StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -725,18 +774,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -829,7 +884,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -850,7 +907,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. @@ -858,86 +915,7 @@ class ProductClient { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -967,7 +945,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } diff --git a/teamchat/teamchat.d.ts b/teamchat/teamchat.d.ts index f23fcc0..4d34725 100644 --- a/teamchat/teamchat.d.ts +++ b/teamchat/teamchat.d.ts @@ -1,96 +1,9 @@ -import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { Server } from 'node:http'; import { ServerOptions } from 'node:https'; import { ReadStream } from 'node:fs'; -type AllKeysOf = T extends any ? keyof T : never; -type AllPropsOptional = Exclude<{ - [P in keyof T]: undefined extends T[P] ? True : False; -}[keyof T], undefined> extends True ? True : False; -type Constructor = new (...args: any[]) => T; -type ExactlyOneOf = { - [K in keyof T]: T[K] & ProhibitKeys, keyof T[K]>>; -}[number]; -type MaybeArray = T | T[]; -type MaybePromise = T | Promise; -type ProhibitKeys = { - [P in K]?: never; -}; -type StringIndexed = Record; - -/** - * {@link StateStore} defines methods for generating and verifying OAuth state. - * - * This interface is implemented internally for the default state store; however, - * it can also be implemented and passed to an OAuth client as well. - */ -interface StateStore { - /** - * Generate a new state string, which is directly appended to the OAuth `state` parameter. - */ - generateState(): MaybePromise; - /** - * Verify that the state received during OAuth callback is valid and not forged. - * - * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. - * - * @param state The state parameter that was received during OAuth callback - */ - verifyState(state: string): MaybePromise; -} -/** - * Guard if an object implements the {@link StateStore} interface — most notably, - * `generateState()` and `verifyState(state: string)`. - */ -declare const isStateStore: (obj: unknown) => obj is StateStore; - -interface TokenStore { - getLatestToken(): MaybePromise; - storeToken(token: Token): MaybePromise; -} - -interface RivetError extends Error { - readonly errorCode: ErrorCode; -} - -declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ - readonly ApiResponseError: "zoom_rivet_api_response_error"; - readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; - readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; - readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; - readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; - readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; - readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; - readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; - readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; - readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; - readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; - readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; - readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; - readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; - readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; - readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; - readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; -}[K]>; -declare const ApiResponseError: Constructor; -declare const AwsReceiverRequestError: Constructor; -declare const ClientCredentialsRawResponseError: Constructor; -declare const S2SRawResponseError: Constructor; -declare const CommonHttpRequestError: Constructor; -declare const ReceiverInconsistentStateError: Constructor; -declare const ReceiverOAuthFlowError: Constructor; -declare const HTTPReceiverConstructionError: Constructor; -declare const HTTPReceiverPortNotNumberError: Constructor; -declare const HTTPReceiverRequestError: Constructor; -declare const OAuthInstallerNotInitializedError: Constructor; -declare const OAuthTokenDoesNotExistError: Constructor; -declare const OAuthTokenFetchFailedError: Constructor; -declare const OAuthTokenRawResponseError: Constructor; -declare const OAuthTokenRefreshFailedError: Constructor; -declare const OAuthStateVerificationFailedError: Constructor; -declare const ProductClientConstructionError: Constructor; - declare enum LogLevel { ERROR = "error", WARN = "warn", @@ -150,6 +63,26 @@ declare class ConsoleLogger implements Logger { private static isMoreOrEqualSevere; } +type AllKeysOf = T extends any ? keyof T : never; +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type ExactlyOneOf = { + [K in keyof T]: T[K] & ProhibitKeys, keyof T[K]>>; +}[number]; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type ProhibitKeys = { + [P in K]?: never; +}; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + interface AuthOptions { clientId: string; clientSecret: string; @@ -195,6 +128,17 @@ declare abstract class Auth { }>, "grant_type">): Promise; } +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} + interface S2SAuthToken { accessToken: string; expirationTimeIso: string; @@ -233,12 +177,31 @@ declare class EventManager { protected withContext, Context>(): ContextListener; } +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + interface HttpReceiverOptions extends Partial { endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; port?: number | string | undefined; - webhooksSecretToken: string; - logger?: Logger; - logLevel?: LogLevel; + webhooksSecretToken?: string | undefined; } type SecureServerOptions = { [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; @@ -251,10 +214,15 @@ declare class HttpReceiver implements Receiver { private logger; constructor(options: HttpReceiverOptions); canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; private getServerCreator; private hasEndpoint; private hasSecureOptions; init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; start(port?: number | string): Promise; stop(): Promise; private writeTemporaryRedirect; @@ -313,38 +281,68 @@ type CommonClientOptions = GetAuthOptions interface ClientReceiverOptions { receiver: R; } -type ClientConstructorOptions, R extends Receiver> = IsReceiverDisabled extends true ? O : O & (ClientReceiverOptions | HttpReceiverOptions); +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); type ExtractInstallerOptions = A extends InteractiveAuth ? [ ReturnType ] extends [true] ? WideInstallerOptions : object : object; type ExtractAuthTokenType = A extends Auth ? T : never; -type GenericClientOptions = CommonClientOptions; type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); -type IsReceiverDisabled> = [ - O["disableReceiver"] -] extends [true] ? true : false; type WideInstallerOptions = { installerOptions: InstallerOptions; }; declare abstract class ProductClient, ReceiverType extends Receiver> { private readonly auth; readonly endpoints: EndpointsType; - readonly webEventConsumer: EventProcessorType; + readonly webEventConsumer?: EventProcessorType | undefined; private readonly receiver?; constructor(options: ClientConstructorOptions); protected abstract initAuth(options: OptionsType): AuthType; protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; - protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; private initDefaultReceiver; - start(this: IsReceiverDisabled extends true ? never : this): Promise>; + start(): Promise>; } +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; +} +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; + +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} interface InstallerOptions { directInstall?: boolean | undefined; installPath?: string | undefined; redirectUri: string; redirectUriPath?: string | undefined; stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; } /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -358,50 +356,19 @@ interface InstallerOptions { */ declare abstract class InteractiveAuth extends Auth { installerOptions?: ReturnType; - getAuthorizationUrl(): Promise; + getAuthorizationUrl(): Promise; getFullRedirectUri(): string; - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }: InstallerOptions): { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { directInstall: boolean; installPath: string; redirectUri: string; redirectUriPath: string; stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; }; } -declare enum StatusCode { - OK = 200, - TEMPORARY_REDIRECT = 302, - BAD_REQUEST = 400, - NOT_FOUND = 404, - METHOD_NOT_ALLOWED = 405, - INTERNAL_SERVER_ERROR = 500 -} -interface ReceiverInitOptions { - eventEmitter: GenericEventManager; - interactiveAuth?: InteractiveAuth | undefined; -} -interface Receiver { - canInstall(): true | false; - init(options: ReceiverInitOptions): void; - start(...args: any[]): MaybePromise; - stop(...args: any[]): MaybePromise; -} - -interface AwsLambdaReceiverOptions { - webhooksSecretToken: string; -} -declare class AwsLambdaReceiver implements Receiver { - private eventEmitter?; - private readonly webhooksSecretToken; - constructor({ webhooksSecretToken }: AwsLambdaReceiverOptions); - buildResponse(statusCode: StatusCode, body: object): LambdaFunctionURLResult; - canInstall(): false; - init({ eventEmitter }: ReceiverInitOptions): void; - start(): LambdaFunctionURLHandler; - stop(): Promise; -} - /** * Credentials for access token & refresh token, which are used to access Zoom's APIs. * @@ -429,6 +396,61 @@ declare class OAuth extends InteractiveAuth { private refreshAccessToken; } +interface RivetError extends Error { + readonly errorCode: ErrorCode; +} + +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + +interface AwsLambdaReceiverOptions { + webhooksSecretToken: string; +} +declare class AwsLambdaReceiver implements Receiver { + private eventEmitter?; + private readonly webhooksSecretToken; + constructor({ webhooksSecretToken }: AwsLambdaReceiverOptions); + buildResponse(statusCode: StatusCode, body: object): LambdaFunctionURLResult; + canInstall(): false; + init({ eventEmitter }: ReceiverInitOptions): void; + start(): LambdaFunctionURLHandler; + stop(): Promise; +} + type ChatChannelsPerformOperationsOnChannelsRequestBody = { method: "archive" | "unarchive"; channel_ids: string[]; @@ -3710,4 +3732,4 @@ declare class TeamChatS2SAuthClient typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -450,7 +453,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -460,14 +466,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -579,9 +591,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -595,6 +604,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? createServer : createServer$1; } @@ -609,8 +631,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -627,69 +661,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -723,18 +772,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -827,7 +882,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -848,7 +905,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. @@ -856,86 +913,7 @@ class ProductClient { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -965,7 +943,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } diff --git a/users/users.cjs b/users/users.cjs index e13edf5..cc5db3c 100644 --- a/users/users.cjs +++ b/users/users.cjs @@ -426,6 +426,9 @@ class JwtStateStore { const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; const hasInstallerOptions = (obj) => typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -452,7 +455,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -462,14 +468,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -581,9 +593,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -597,6 +606,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; } @@ -611,8 +633,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -629,69 +663,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, exports.StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -725,18 +774,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -745,86 +800,7 @@ class HttpReceiver { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -854,7 +830,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } @@ -1171,7 +1147,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -1192,7 +1170,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. diff --git a/users/users.d.ts b/users/users.d.ts index 1306f5d..d0ab590 100644 --- a/users/users.d.ts +++ b/users/users.d.ts @@ -1,88 +1,8 @@ -import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { Server } from 'node:http'; import { ServerOptions } from 'node:https'; -type AllPropsOptional = Exclude<{ - [P in keyof T]: undefined extends T[P] ? True : False; -}[keyof T], undefined> extends True ? True : False; -type Constructor = new (...args: any[]) => T; -type MaybeArray = T | T[]; -type MaybePromise = T | Promise; -type StringIndexed = Record; - -/** - * {@link StateStore} defines methods for generating and verifying OAuth state. - * - * This interface is implemented internally for the default state store; however, - * it can also be implemented and passed to an OAuth client as well. - */ -interface StateStore { - /** - * Generate a new state string, which is directly appended to the OAuth `state` parameter. - */ - generateState(): MaybePromise; - /** - * Verify that the state received during OAuth callback is valid and not forged. - * - * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. - * - * @param state The state parameter that was received during OAuth callback - */ - verifyState(state: string): MaybePromise; -} -/** - * Guard if an object implements the {@link StateStore} interface — most notably, - * `generateState()` and `verifyState(state: string)`. - */ -declare const isStateStore: (obj: unknown) => obj is StateStore; - -interface TokenStore { - getLatestToken(): MaybePromise; - storeToken(token: Token): MaybePromise; -} - -interface RivetError extends Error { - readonly errorCode: ErrorCode; -} - -declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ - readonly ApiResponseError: "zoom_rivet_api_response_error"; - readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; - readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; - readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; - readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; - readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; - readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; - readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; - readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; - readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; - readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; - readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; - readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; - readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; - readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; - readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; - readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; -}[K]>; -declare const ApiResponseError: Constructor; -declare const AwsReceiverRequestError: Constructor; -declare const ClientCredentialsRawResponseError: Constructor; -declare const S2SRawResponseError: Constructor; -declare const CommonHttpRequestError: Constructor; -declare const ReceiverInconsistentStateError: Constructor; -declare const ReceiverOAuthFlowError: Constructor; -declare const HTTPReceiverConstructionError: Constructor; -declare const HTTPReceiverPortNotNumberError: Constructor; -declare const HTTPReceiverRequestError: Constructor; -declare const OAuthInstallerNotInitializedError: Constructor; -declare const OAuthTokenDoesNotExistError: Constructor; -declare const OAuthTokenFetchFailedError: Constructor; -declare const OAuthTokenRawResponseError: Constructor; -declare const OAuthTokenRefreshFailedError: Constructor; -declare const OAuthStateVerificationFailedError: Constructor; -declare const ProductClientConstructionError: Constructor; - declare enum LogLevel { ERROR = "error", WARN = "warn", @@ -142,6 +62,19 @@ declare class ConsoleLogger implements Logger { private static isMoreOrEqualSevere; } +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + interface AuthOptions { clientId: string; clientSecret: string; @@ -187,6 +120,17 @@ declare abstract class Auth { }>, "grant_type">): Promise; } +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} + interface S2SAuthToken { accessToken: string; expirationTimeIso: string; @@ -225,12 +169,31 @@ declare class EventManager { protected withContext, Context>(): ContextListener; } +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + interface HttpReceiverOptions extends Partial { endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; port?: number | string | undefined; - webhooksSecretToken: string; - logger?: Logger; - logLevel?: LogLevel; + webhooksSecretToken?: string | undefined; } type SecureServerOptions = { [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; @@ -243,10 +206,15 @@ declare class HttpReceiver implements Receiver { private logger; constructor(options: HttpReceiverOptions); canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; private getServerCreator; private hasEndpoint; private hasSecureOptions; init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; start(port?: number | string): Promise; stop(): Promise; private writeTemporaryRedirect; @@ -305,38 +273,68 @@ type CommonClientOptions = GetAuthOptions interface ClientReceiverOptions { receiver: R; } -type ClientConstructorOptions, R extends Receiver> = IsReceiverDisabled extends true ? O : O & (ClientReceiverOptions | HttpReceiverOptions); +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); type ExtractInstallerOptions = A extends InteractiveAuth ? [ ReturnType ] extends [true] ? WideInstallerOptions : object : object; type ExtractAuthTokenType = A extends Auth ? T : never; -type GenericClientOptions = CommonClientOptions; type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); -type IsReceiverDisabled> = [ - O["disableReceiver"] -] extends [true] ? true : false; type WideInstallerOptions = { installerOptions: InstallerOptions; }; declare abstract class ProductClient, ReceiverType extends Receiver> { private readonly auth; readonly endpoints: EndpointsType; - readonly webEventConsumer: EventProcessorType; + readonly webEventConsumer?: EventProcessorType | undefined; private readonly receiver?; constructor(options: ClientConstructorOptions); protected abstract initAuth(options: OptionsType): AuthType; protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; - protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; private initDefaultReceiver; - start(this: IsReceiverDisabled extends true ? never : this): Promise>; + start(): Promise>; } +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; +} +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; + +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} interface InstallerOptions { directInstall?: boolean | undefined; installPath?: string | undefined; redirectUri: string; redirectUriPath?: string | undefined; stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; } /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -350,36 +348,87 @@ interface InstallerOptions { */ declare abstract class InteractiveAuth extends Auth { installerOptions?: ReturnType; - getAuthorizationUrl(): Promise; + getAuthorizationUrl(): Promise; getFullRedirectUri(): string; - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }: InstallerOptions): { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { directInstall: boolean; installPath: string; redirectUri: string; redirectUriPath: string; stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; }; } -declare enum StatusCode { - OK = 200, - TEMPORARY_REDIRECT = 302, - BAD_REQUEST = 400, - NOT_FOUND = 404, - METHOD_NOT_ALLOWED = 405, - INTERNAL_SERVER_ERROR = 500 +/** + * Credentials for access token & refresh token, which are used to access Zoom's APIs. + * + * As access token is short-lived (usually a single hour), its expiration time is checked + * first. If it's possible to use the access token, it's used; however, if it has expired + * or is close to expiring, the refresh token should be used to generate a new access token + * before the API call is made. Refresh tokens are generally valid for 90 days. + * + * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} + * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. + * It's likely that this error will be rare, but it _can_ be thrown. + */ +interface OAuthToken { + accessToken: string; + expirationTimeIso: string; + refreshToken: string; + scopes: string[]; } -interface ReceiverInitOptions { - eventEmitter: GenericEventManager; - interactiveAuth?: InteractiveAuth | undefined; +declare class OAuth extends InteractiveAuth { + private assertResponseAccessToken; + private fetchAccessToken; + getToken(): Promise; + initRedirectCode(code: string): Promise; + private mapOAuthToken; + private refreshAccessToken; } -interface Receiver { - canInstall(): true | false; - init(options: ReceiverInitOptions): void; - start(...args: any[]): MaybePromise; - stop(...args: any[]): MaybePromise; + +interface RivetError extends Error { + readonly errorCode: ErrorCode; } +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + interface AwsLambdaReceiverOptions { webhooksSecretToken: string; } @@ -5031,33 +5080,6 @@ type UsersEvents = GroupAdminAddedEvent | GroupLockSettingsUpdatedEvent | GroupA declare class UsersEventProcessor extends EventManager { } -/** - * Credentials for access token & refresh token, which are used to access Zoom's APIs. - * - * As access token is short-lived (usually a single hour), its expiration time is checked - * first. If it's possible to use the access token, it's used; however, if it has expired - * or is close to expiring, the refresh token should be used to generate a new access token - * before the API call is made. Refresh tokens are generally valid for 90 days. - * - * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} - * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. - * It's likely that this error will be rare, but it _can_ be thrown. - */ -interface OAuthToken { - accessToken: string; - expirationTimeIso: string; - refreshToken: string; - scopes: string[]; -} -declare class OAuth extends InteractiveAuth { - private assertResponseAccessToken; - private fetchAccessToken; - getToken(): Promise; - initRedirectCode(code: string): Promise; - private mapOAuthToken; - private refreshAccessToken; -} - type UsersOAuthOptions = CommonClientOptions; declare class UsersOAuthClient = UsersOAuthOptions> extends ProductClient { protected initAuth({ clientId, clientSecret, tokenStore, ...restOptions }: OptionsType): OAuth; @@ -5072,4 +5094,4 @@ declare class UsersS2SAuthClient typeof obj.installerOptions.redirectUri !== "undefined" && typeof obj.installerOptions.stateStore !== "undefined"; @@ -450,7 +453,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -460,14 +466,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -579,9 +591,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -595,6 +604,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? createServer : createServer$1; } @@ -609,8 +631,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -627,69 +661,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -723,18 +772,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -743,86 +798,7 @@ class HttpReceiver { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -852,7 +828,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } @@ -1169,7 +1145,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -1190,7 +1168,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. diff --git a/videosdk/videosdk.cjs b/videosdk/videosdk.cjs index c7092a2..6a363b9 100644 --- a/videosdk/videosdk.cjs +++ b/videosdk/videosdk.cjs @@ -426,6 +426,9 @@ class JwtStateStore { const DEFAULT_INSTALL_PATH = "/zoom/oauth/install"; const DEFAULT_CALLBACK_PATH = "/zoom/oauth/callback"; +const DEFAULT_STATE_COOKIE_NAME = "zoom-oauth-state"; +const DEFAULT_STATE_COOKIE_MAX_AGE = 600; // 10 minutes in seconds +const MAXIMUM_STATE_MAX_AGE = 3600; // 1 hour in seconds const OAUTH_AUTHORIZE_PATH = "/oauth/authorize"; /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -450,7 +453,10 @@ class InteractiveAuth extends Auth { searchParams.set("redirect_uri", this.getFullRedirectUri()); searchParams.set("response_type", "code"); searchParams.set("state", generatedState); - return authUrl.toString(); + return { + fullUrl: authUrl.toString(), + generatedState + }; } getFullRedirectUri() { if (!this.installerOptions?.redirectUri || !this.installerOptions.redirectUriPath) { @@ -460,14 +466,20 @@ class InteractiveAuth extends Auth { } // Don't return a type; we want it to be as narrow as possible (used for ReturnType). // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }) { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }) { const updatedOptions = { directInstall: Boolean(directInstall), installPath: installPath ? prependSlashes(installPath) : DEFAULT_INSTALL_PATH, redirectUri, redirectUriPath: redirectUriPath ? prependSlashes(redirectUriPath) : DEFAULT_CALLBACK_PATH, - stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }) + stateStore: isStateStore(stateStore) ? stateStore : new JwtStateStore({ stateSecret: stateStore }), + stateCookieName: stateCookieName ?? DEFAULT_STATE_COOKIE_NAME, + stateCookieMaxAge: stateCookieMaxAge ?? DEFAULT_STATE_COOKIE_MAX_AGE }; + if (updatedOptions.stateCookieMaxAge > MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -579,9 +591,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -595,6 +604,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? node_https.createServer : node_http.createServer; } @@ -609,8 +631,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -627,69 +661,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, exports.StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, exports.StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, exports.StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, exports.StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, exports.StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, exports.StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, exports.StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, exports.StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -723,18 +772,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(exports.StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -791,7 +846,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -812,7 +869,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. @@ -820,86 +877,7 @@ class ProductClient { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -929,7 +907,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${node_path.basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); } diff --git a/videosdk/videosdk.d.ts b/videosdk/videosdk.d.ts index 7a83883..0ad32c0 100644 --- a/videosdk/videosdk.d.ts +++ b/videosdk/videosdk.d.ts @@ -1,88 +1,8 @@ -import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { AxiosResponse } from 'axios'; +import { LambdaFunctionURLResult, LambdaFunctionURLHandler } from 'aws-lambda'; import { Server } from 'node:http'; import { ServerOptions } from 'node:https'; -type AllPropsOptional = Exclude<{ - [P in keyof T]: undefined extends T[P] ? True : False; -}[keyof T], undefined> extends True ? True : False; -type Constructor = new (...args: any[]) => T; -type MaybeArray = T | T[]; -type MaybePromise = T | Promise; -type StringIndexed = Record; - -/** - * {@link StateStore} defines methods for generating and verifying OAuth state. - * - * This interface is implemented internally for the default state store; however, - * it can also be implemented and passed to an OAuth client as well. - */ -interface StateStore { - /** - * Generate a new state string, which is directly appended to the OAuth `state` parameter. - */ - generateState(): MaybePromise; - /** - * Verify that the state received during OAuth callback is valid and not forged. - * - * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. - * - * @param state The state parameter that was received during OAuth callback - */ - verifyState(state: string): MaybePromise; -} -/** - * Guard if an object implements the {@link StateStore} interface — most notably, - * `generateState()` and `verifyState(state: string)`. - */ -declare const isStateStore: (obj: unknown) => obj is StateStore; - -interface TokenStore { - getLatestToken(): MaybePromise; - storeToken(token: Token): MaybePromise; -} - -interface RivetError extends Error { - readonly errorCode: ErrorCode; -} - -declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ - readonly ApiResponseError: "zoom_rivet_api_response_error"; - readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; - readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; - readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; - readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; - readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; - readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; - readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; - readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; - readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; - readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; - readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; - readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; - readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; - readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; - readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; - readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; -}[K]>; -declare const ApiResponseError: Constructor; -declare const AwsReceiverRequestError: Constructor; -declare const ClientCredentialsRawResponseError: Constructor; -declare const S2SRawResponseError: Constructor; -declare const CommonHttpRequestError: Constructor; -declare const ReceiverInconsistentStateError: Constructor; -declare const ReceiverOAuthFlowError: Constructor; -declare const HTTPReceiverConstructionError: Constructor; -declare const HTTPReceiverPortNotNumberError: Constructor; -declare const HTTPReceiverRequestError: Constructor; -declare const OAuthInstallerNotInitializedError: Constructor; -declare const OAuthTokenDoesNotExistError: Constructor; -declare const OAuthTokenFetchFailedError: Constructor; -declare const OAuthTokenRawResponseError: Constructor; -declare const OAuthTokenRefreshFailedError: Constructor; -declare const OAuthStateVerificationFailedError: Constructor; -declare const ProductClientConstructionError: Constructor; - declare enum LogLevel { ERROR = "error", WARN = "warn", @@ -142,6 +62,19 @@ declare class ConsoleLogger implements Logger { private static isMoreOrEqualSevere; } +type AllPropsOptional = Exclude<{ + [P in keyof T]: undefined extends T[P] ? True : False; +}[keyof T], undefined> extends True ? True : False; +type Constructor = new (...args: any[]) => T; +type MaybeArray = T | T[]; +type MaybePromise = T | Promise; +type StringIndexed = Record; + +interface TokenStore { + getLatestToken(): MaybePromise; + storeToken(token: Token): MaybePromise; +} + interface AuthOptions { clientId: string; clientSecret: string; @@ -187,6 +120,21 @@ declare abstract class Auth { }>, "grant_type">): Promise; } +interface ClientCredentialsToken { + accessToken: string; + expirationTimeIso: string; + scopes: string[]; +} + +interface JwtToken { + token: string; + expirationTimeIso: string; +} +declare class JwtAuth extends Auth { + private generateToken; + getToken(): Promise; +} + interface S2SAuthToken { accessToken: string; expirationTimeIso: string; @@ -225,12 +173,31 @@ declare class EventManager { protected withContext, Context>(): ContextListener; } +declare enum StatusCode { + OK = 200, + TEMPORARY_REDIRECT = 302, + BAD_REQUEST = 400, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + INTERNAL_SERVER_ERROR = 500 +} +interface ReceiverInitOptions { + eventEmitter?: GenericEventManager | undefined; + interactiveAuth?: InteractiveAuth | undefined; +} +interface Receiver { + canInstall(): true | false; + init(options: ReceiverInitOptions): void; + start(...args: any[]): MaybePromise; + stop(...args: any[]): MaybePromise; +} + interface HttpReceiverOptions extends Partial { endpoints?: MaybeArray | undefined; + logger?: Logger | undefined; + logLevel?: LogLevel | undefined; port?: number | string | undefined; - webhooksSecretToken: string; - logger?: Logger; - logLevel?: LogLevel; + webhooksSecretToken?: string | undefined; } type SecureServerOptions = { [K in (typeof secureServerOptionKeys)[number]]: ServerOptions[K]; @@ -243,10 +210,15 @@ declare class HttpReceiver implements Receiver { private logger; constructor(options: HttpReceiverOptions); canInstall(): true; + private buildDeletedStateCookieHeader; + private buildStateCookieHeader; + private getRequestCookie; private getServerCreator; private hasEndpoint; private hasSecureOptions; init({ eventEmitter, interactiveAuth }: ReceiverInitOptions): void; + private setResponseCookie; + private areNormalizedUrlsEqual; start(port?: number | string): Promise; stop(): Promise; private writeTemporaryRedirect; @@ -305,38 +277,68 @@ type CommonClientOptions = GetAuthOptions interface ClientReceiverOptions { receiver: R; } -type ClientConstructorOptions, R extends Receiver> = IsReceiverDisabled extends true ? O : O & (ClientReceiverOptions | HttpReceiverOptions); +type ClientConstructorOptions, R extends Receiver> = (O & { + disableReceiver: true; +}) | (O & (ClientReceiverOptions | HttpReceiverOptions)); type ExtractInstallerOptions = A extends InteractiveAuth ? [ ReturnType ] extends [true] ? WideInstallerOptions : object : object; type ExtractAuthTokenType = A extends Auth ? T : never; -type GenericClientOptions = CommonClientOptions; type GetAuthOptions = AuthOptions> & (A extends S2SAuth ? S2SAuthOptions : object); -type IsReceiverDisabled> = [ - O["disableReceiver"] -] extends [true] ? true : false; type WideInstallerOptions = { installerOptions: InstallerOptions; }; declare abstract class ProductClient, ReceiverType extends Receiver> { private readonly auth; readonly endpoints: EndpointsType; - readonly webEventConsumer: EventProcessorType; + readonly webEventConsumer?: EventProcessorType | undefined; private readonly receiver?; constructor(options: ClientConstructorOptions); protected abstract initAuth(options: OptionsType): AuthType; protected abstract initEndpoints(auth: AuthType, options: OptionsType): EndpointsType; - protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType; + protected abstract initEventProcessor(endpoints: EndpointsType, options: OptionsType): EventProcessorType | undefined; private initDefaultReceiver; - start(this: IsReceiverDisabled extends true ? never : this): Promise>; + start(): Promise>; } +/** + * {@link StateStore} defines methods for generating and verifying OAuth state. + * + * This interface is implemented internally for the default state store; however, + * it can also be implemented and passed to an OAuth client as well. + */ +interface StateStore { + /** + * Generate a new state string, which is directly appended to the OAuth `state` parameter. + */ + generateState(): MaybePromise; + /** + * Verify that the state received during OAuth callback is valid and not forged. + * + * If state verification fails, {@link OAuthStateVerificationFailedError} should be thrown. + * + * @param state The state parameter that was received during OAuth callback + */ + verifyState(state: string): MaybePromise; +} +/** + * Guard if an object implements the {@link StateStore} interface — most notably, + * `generateState()` and `verifyState(state: string)`. + */ +declare const isStateStore: (obj: unknown) => obj is StateStore; + +interface AuthorizationUrlResult { + fullUrl: string; + generatedState: string; +} interface InstallerOptions { directInstall?: boolean | undefined; installPath?: string | undefined; redirectUri: string; redirectUriPath?: string | undefined; stateStore: StateStore | string; + stateCookieName?: string | undefined; + stateCookieMaxAge?: number | undefined; } /** * {@link InteractiveAuth}, an extension of {@link Auth}, is designed for use cases where authentication @@ -350,36 +352,79 @@ interface InstallerOptions { */ declare abstract class InteractiveAuth extends Auth { installerOptions?: ReturnType; - getAuthorizationUrl(): Promise; + getAuthorizationUrl(): Promise; getFullRedirectUri(): string; - setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore }: InstallerOptions): { + setInstallerOptions({ directInstall, installPath, redirectUri, redirectUriPath, stateStore, stateCookieName, stateCookieMaxAge }: InstallerOptions): { directInstall: boolean; installPath: string; redirectUri: string; redirectUriPath: string; stateStore: StateStore; + stateCookieName: string; + stateCookieMaxAge: number; }; } -declare enum StatusCode { - OK = 200, - TEMPORARY_REDIRECT = 302, - BAD_REQUEST = 400, - NOT_FOUND = 404, - METHOD_NOT_ALLOWED = 405, - INTERNAL_SERVER_ERROR = 500 -} -interface ReceiverInitOptions { - eventEmitter: GenericEventManager; - interactiveAuth?: InteractiveAuth | undefined; +/** + * Credentials for access token & refresh token, which are used to access Zoom's APIs. + * + * As access token is short-lived (usually a single hour), its expiration time is checked + * first. If it's possible to use the access token, it's used; however, if it has expired + * or is close to expiring, the refresh token should be used to generate a new access token + * before the API call is made. Refresh tokens are generally valid for 90 days. + * + * If neither the access token nor the refresh token is available, {@link OAuthTokenRefreshFailedError} + * shall be thrown, informing the developer that neither value can be used, and the user must re-authorize. + * It's likely that this error will be rare, but it _can_ be thrown. + */ +interface OAuthToken { + accessToken: string; + expirationTimeIso: string; + refreshToken: string; + scopes: string[]; } -interface Receiver { - canInstall(): true | false; - init(options: ReceiverInitOptions): void; - start(...args: any[]): MaybePromise; - stop(...args: any[]): MaybePromise; + +interface RivetError extends Error { + readonly errorCode: ErrorCode; } +declare const isCoreError: (obj: unknown, key?: K | undefined) => obj is RivetError<{ + readonly ApiResponseError: "zoom_rivet_api_response_error"; + readonly AwsReceiverRequestError: "zoom_rivet_aws_receiver_request_error"; + readonly ClientCredentialsRawResponseError: "zoom_rivet_client_credentials_raw_response_error"; + readonly S2SRawResponseError: "zoom_rivet_s2s_raw_response_error"; + readonly CommonHttpRequestError: "zoom_rivet_common_http_request_error"; + readonly ReceiverInconsistentStateError: "zoom_rivet_receiver_inconsistent_state_error"; + readonly ReceiverOAuthFlowError: "zoom_rivet_receiver_oauth_flow_error"; + readonly HTTPReceiverConstructionError: "zoom_rivet_http_receiver_construction_error"; + readonly HTTPReceiverPortNotNumberError: "zoom_rivet_http_receiver_port_not_number_error"; + readonly HTTPReceiverRequestError: "zoom_rivet_http_receiver_request_error"; + readonly OAuthInstallerNotInitializedError: "zoom_rivet_oauth_installer_not_initialized_error"; + readonly OAuthTokenDoesNotExistError: "zoom_rivet_oauth_does_not_exist_error"; + readonly OAuthTokenFetchFailedError: "zoom_rivet_oauth_token_fetch_failed_error"; + readonly OAuthTokenRawResponseError: "zoom_rivet_oauth_token_raw_response_error"; + readonly OAuthTokenRefreshFailedError: "zoom_rivet_oauth_token_refresh_failed_error"; + readonly OAuthStateVerificationFailedError: "zoom_rivet_oauth_state_verification_failed_error"; + readonly ProductClientConstructionError: "zoom_rivet_product_client_construction_error"; +}[K]>; +declare const ApiResponseError: Constructor; +declare const AwsReceiverRequestError: Constructor; +declare const ClientCredentialsRawResponseError: Constructor; +declare const S2SRawResponseError: Constructor; +declare const CommonHttpRequestError: Constructor; +declare const ReceiverInconsistentStateError: Constructor; +declare const ReceiverOAuthFlowError: Constructor; +declare const HTTPReceiverConstructionError: Constructor; +declare const HTTPReceiverPortNotNumberError: Constructor; +declare const HTTPReceiverRequestError: Constructor; +declare const OAuthInstallerNotInitializedError: Constructor; +declare const OAuthTokenDoesNotExistError: Constructor; +declare const OAuthTokenFetchFailedError: Constructor; +declare const OAuthTokenRawResponseError: Constructor; +declare const OAuthTokenRefreshFailedError: Constructor; +declare const OAuthStateVerificationFailedError: Constructor; +declare const ProductClientConstructionError: Constructor; + interface AwsLambdaReceiverOptions { webhooksSecretToken: string; } @@ -394,15 +439,6 @@ declare class AwsLambdaReceiver implements Receiver { stop(): Promise; } -interface JwtToken { - token: string; - expirationTimeIso: string; -} -declare class JwtAuth extends Auth { - private generateToken; - getToken(): Promise; -} - type ByosStorageUpdateBringYourOwnStorageSettingsRequestBody = { bring_our_own_storage: boolean; storage_location_id?: string; @@ -1885,4 +1921,4 @@ declare class VideoSdkClient MAXIMUM_STATE_MAX_AGE) { + // This method is always called from ProductClient, so this should be fine. + throw new ProductClientConstructionError(`stateCookieMaxAge cannot be greater than ${MAXIMUM_STATE_MAX_AGE.toString()} seconds.`); + } this.installerOptions = updatedOptions; return updatedOptions; } @@ -577,9 +589,6 @@ class HttpReceiver { server; logger; constructor(options) { - if (!options.webhooksSecretToken) { - throw new HTTPReceiverConstructionError("webhooksSecretToken is a required constructor option."); - } this.options = mergeDefaultOptions(options, { endpoints: HttpReceiver.DEFAULT_ENDPOINT }); this.options.endpoints = prependSlashes(this.options.endpoints); this.logger = @@ -593,6 +602,19 @@ class HttpReceiver { canInstall() { return true; } + buildDeletedStateCookieHeader(name) { + return `${name}=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; Path=/; Secure;`; + } + buildStateCookieHeader(name, value, maxAge) { + return `${name}=${value}; HttpOnly; Max-Age=${maxAge.toString()}; Path=/; Secure;`; + } + getRequestCookie(req, name) { + return req.headers.cookie + ?.split(";") + .find((cookie) => cookie.trim().startsWith(name)) + ?.split("=")[1] + ?.trim(); + } getServerCreator() { return this.hasSecureOptions() ? createServer : createServer$1; } @@ -607,8 +629,20 @@ class HttpReceiver { this.eventEmitter = eventEmitter; this.interactiveAuth = interactiveAuth; } + setResponseCookie(res, cookie) { + const existingCookies = res.getHeader("Set-Cookie") ?? []; + const cookiesArray = Array.isArray(existingCookies) ? existingCookies + : typeof existingCookies === "string" ? [existingCookies] + : [existingCookies.toString()]; + res.setHeader("Set-Cookie", [...cookiesArray, cookie]); + } + areNormalizedUrlsEqual(firstUrl, secondUrl) { + const normalizedFirstUrl = firstUrl.endsWith("/") ? firstUrl.slice(0, -1) : firstUrl; + const normalizedSecondUrl = secondUrl.endsWith("/") ? secondUrl.slice(0, -1) : secondUrl; + return normalizedFirstUrl == normalizedSecondUrl; + } start(port) { - if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port) { + if (typeof port !== "number" && isNaN(Number(port)) && !this.options.port && this.options.port !== 0) { const errorMessage = "HTTP receiver must have number-coercible port found in constructor option or method call."; this.logger.error(errorMessage); throw new HTTPReceiverPortNotNumberError(errorMessage); @@ -625,69 +659,84 @@ class HttpReceiver { // Handle interactive OAuth flow, if user is going to installPath or redirectUriPath if (interactiveAuth && interactiveAuth instanceof InteractiveAuth && interactiveAuth.installerOptions) { const { installerOptions } = interactiveAuth; - if (pathname == installerOptions.installPath) { - const authUrl = await Promise.resolve(interactiveAuth.getAuthorizationUrl()); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.installPath)) { + const { fullUrl, generatedState } = await interactiveAuth.getAuthorizationUrl(); + const stateCookie = this.buildStateCookieHeader(installerOptions.stateCookieName, generatedState, installerOptions.stateCookieMaxAge); await (installerOptions.directInstall ? - this.writeTemporaryRedirect(res, authUrl) - : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(authUrl))); + this.writeTemporaryRedirect(res, fullUrl, stateCookie) + : this.writeResponse(res, StatusCode.OK, defaultInstallTemplate(fullUrl), stateCookie)); return; } // The user has navigated to the redirect page; init the code - if (pathname === installerOptions.redirectUriPath) { - const authCode = searchParams.get("code"); - const stateCode = searchParams.get("state"); + if (this.areNormalizedUrlsEqual(pathname, installerOptions.redirectUriPath)) { + const authCodeParam = searchParams.get("code"); + const stateCodeParam = searchParams.get("state"); + const stateCodeCookie = this.getRequestCookie(req, installerOptions.stateCookieName); try { - if (!authCode || !stateCode) { + // Can't proceed if no auth code or state code in search parameters + if (!authCodeParam || !stateCodeParam) { const errorMessage = "OAuth callback did not include code and/or state in request."; this.logger.error(errorMessage); throw new ReceiverOAuthFlowError(errorMessage); } - // Wrapped in `await Promise.resolve(...)`, as method may return a `Promise` or may not. - await Promise.resolve(installerOptions.stateStore.verifyState(stateCode)); - await Promise.resolve(interactiveAuth.initRedirectCode(authCode)); - await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate()); + // Ensure that the state token is verified, according to our state store + await installerOptions.stateStore.verifyState(stateCodeParam); + // Ensure that the state token we received (in search parameters) IS THE SAME as the state cookie + if (!stateCodeCookie || stateCodeCookie !== stateCodeParam) { + const errorMessage = "The state parameter is not from this browser session."; + this.logger.error(errorMessage); + throw new ReceiverOAuthFlowError(errorMessage); + } + await interactiveAuth.initRedirectCode(authCodeParam); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.OK, defaultCallbackSuccessTemplate(), deletionStateCookie); return; } catch (err) { const htmlTemplate = isCoreError(err) ? defaultCallbackKnownErrorTemplate(err.name, err.message) : defaultCallbackUnknownErrorTemplate(); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate); + const deletionStateCookie = this.buildDeletedStateCookieHeader(installerOptions.stateCookieName); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, htmlTemplate, deletionStateCookie); return; } } } - // We currently only support a single endpoint, though this will change in the future. - if (!this.hasEndpoint(pathname)) { - await this.writeResponse(res, StatusCode.NOT_FOUND); - return; - } - // We currently only support POST requests, as that's what Zoom sends. - if (req.method !== "post" && req.method !== "POST") { - await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); - return; - } - try { - const { webhooksSecretToken } = this.options; - const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); - const processedEvent = request.processEvent(); - if (isHashedUrlValidation(processedEvent)) { - await this.writeResponse(res, StatusCode.OK, processedEvent); + // This section is only applicable if we have a webhooks secret token—if we don't, then this + // receiver is, in effect, just for OAuth usage, meaning installing and validating. + if (this.options.webhooksSecretToken) { + // We currently only support a single endpoint, though this will change in the future. + if (!this.hasEndpoint(pathname)) { + await this.writeResponse(res, StatusCode.NOT_FOUND); + return; } - else { - await this.eventEmitter?.emit(processedEvent.event, processedEvent); - await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + // We currently only support POST requests, as that's what Zoom sends. + if (req.method !== "post" && req.method !== "POST") { + await this.writeResponse(res, StatusCode.METHOD_NOT_ALLOWED); + return; } - } - catch (err) { - if (isCoreError(err, "CommonHttpRequestError")) { - await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + try { + const { webhooksSecretToken } = this.options; + const request = await CommonHttpRequest.buildFromIncomingMessage(req, webhooksSecretToken); + const processedEvent = request.processEvent(); + if (isHashedUrlValidation(processedEvent)) { + await this.writeResponse(res, StatusCode.OK, processedEvent); + } + else { + await this.eventEmitter?.emit(processedEvent.event, processedEvent); + await this.writeResponse(res, StatusCode.OK, { message: "Zoom event processed successfully." }); + } } - else { - console.error(err); - await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { - error: "An unknown error occurred. Please try again later." - }); + catch (err) { + if (isCoreError(err, "CommonHttpRequestError")) { + await this.writeResponse(res, StatusCode.BAD_REQUEST, { error: err.message }); + } + else { + console.error(err); + await this.writeResponse(res, StatusCode.INTERNAL_SERVER_ERROR, { + error: "An unknown error occurred. Please try again later." + }); + } } } })()); @@ -721,18 +770,24 @@ class HttpReceiver { resolve(); }); } - writeTemporaryRedirect(res, location) { + writeTemporaryRedirect(res, location, setCookie) { return new Promise((resolve) => { + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(StatusCode.TEMPORARY_REDIRECT, { Location: location }); res.end(() => { resolve(); }); }); } - writeResponse(res, statusCode, bodyContent) { + writeResponse(res, statusCode, bodyContent, setCookie) { return new Promise((resolve) => { const mimeType = typeof bodyContent === "object" ? "application/json" : "text/html"; bodyContent = typeof bodyContent === "object" ? JSON.stringify(bodyContent) : bodyContent; + if (setCookie) { + this.setResponseCookie(res, setCookie); + } res.writeHead(statusCode, { "Content-Type": mimeType }); res.end(bodyContent, () => { resolve(); @@ -789,7 +844,9 @@ class ProductClient { // Only create an instance of `this.receiver` if the developer did not explicitly disable it. if (!isReceiverDisabled(options)) { // Throw error if receiver enabled, but no explicit receiver or a webhooks secret token provided. - if (!hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { + // This is mainly applicable for products where we expect webhooks to be used; in events where webhooks are not + // expected, then it's perfectly fine for the developer to not provide a receiver of a webhooks secret token. + if (this.webEventConsumer && !hasExplicitReceiver(options) && !hasWebhooksSecretToken(options)) { throw new ProductClientConstructionError("Options must include a custom receiver, or a webhooks secret token."); } this.receiver = (hasExplicitReceiver(options) ? @@ -810,7 +867,7 @@ class ProductClient { } async start() { if (!this.receiver) { - throw new ReceiverInconsistentStateError("Receiver not constructed. Was disableReceiver set to true?"); + throw new ReceiverInconsistentStateError("Receiver failed to construct. Was disableReceiver set to true?"); } // Method call is wrapped in `await` and `Promise.resolve()`, as the call // may or may not return a promise. This is not required when implementing `Receiver`. @@ -818,86 +875,7 @@ class ProductClient { } } -const type = "module"; -const name = "@zoom/rivet"; -const author = "Zoom Communications, Inc."; -const contributors = [ - { - name: "James Coon", - email: "james.coon@zoom.us", - url: "https://www.npmjs.com/~jcoon97" - }, - { - name: "Will Ezrine", - email: "will.ezrine@zoom.us", - url: "https://www.npmjs.com/~wezrine" - }, - { - name: "Tommy Gaessler", - email: "tommy.gaessler@zoom.us", - url: "https://www.npmjs.com/~tommygaessler" - } -]; -const packageManager = "pnpm@9.9.0"; -const version = "0.2.2"; -const scripts = { - test: "vitest", - "test:coverage": "vitest --coverage", - "export": "rollup --config ./rollup.config.mjs", - prepare: "husky", - lint: "eslint './packages/**/*.ts' --ignore-pattern '**/*{Endpoints,EventProcessor}.ts' --ignore-pattern '**/*.{spec,test,test-d}.ts'" -}; -const devDependencies = { - "@eslint/js": "^9.12.0", - "@rollup/plugin-commonjs": "^28.0.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.3.0", - "@rollup/plugin-typescript": "^12.1.0", - "@tsconfig/recommended": "^1.0.7", - "@tsconfig/strictest": "^2.0.5", - "@types/eslint__js": "^8.42.3", - "@types/node": "^22.7.5", - "@types/semver": "^7.5.8", - "@types/supertest": "^6.0.2", - "@vitest/coverage-v8": "2.1.3", - dotenv: "^16.4.5", - eslint: "^9.12.0", - "eslint-plugin-n": "^17.11.1", - "eslint-plugin-promise": "^7.1.0", - "get-port": "^7.1.0", - husky: "^9.1.6", - "lint-staged": "^15.2.10", - nock: "^13.5.5", - prettier: "^3.3.3", - "prettier-plugin-organize-imports": "^4.1.0", - rollup: "^4.24.0", - "rollup-plugin-copy": "^3.5.0", - "rollup-plugin-dts": "^6.1.1", - semver: "^7.6.3", - supertest: "^7.0.0", - "ts-node": "^10.9.2", - tslib: "^2.7.0", - typescript: "^5.6.3", - "typescript-eslint": "^8.8.1", - vitest: "2.1.3" -}; -var packageJson = { - type: type, - name: name, - author: author, - contributors: contributors, - packageManager: packageManager, - version: version, - scripts: scripts, - devDependencies: devDependencies, - "lint-staged": { - "*": "prettier --ignore-unknown --write", - "*.ts !*{Endpoints,EventProcessor}.ts !*.{spec,test,test-d}.ts": [ - "eslint --fix", - "eslint" - ] -} -}; +const version = "0.3.0"; class WebEndpoints { /** @internal */ @@ -927,7 +905,7 @@ class WebEndpoints { return (async ({ path, body, query }) => await this.makeRequest(method, baseUrlOverride, urlPathBuilder(path), requestMimeType ?? WebEndpoints.DEFAULT_MIME_TYPE, body, query)).bind(this); } buildUserAgent() { - return (`rivet/${packageJson.version} ` + + return (`rivet/${version} ` + `${basename(process.title)}/${process.version.replace("v", "")} ` + `${os.platform()}/${os.release()}`); }