UNPKG

@canva/create-app

Version:

A command line tool for creating Canva Apps.

286 lines (244 loc) 9.25 kB
import * as chalk from "chalk"; import * as crypto from "crypto"; import "dotenv/config"; import * as express from "express"; import * as cookieParser from "cookie-parser"; import * as basicAuth from "express-basic-auth"; import { JSONFileDatabase } from "../database/database"; import { getTokenFromQueryString } from "../../utils/backend/jwt_middleware/jwt_middleware"; import { createJwtMiddleware } from "../../utils/backend/jwt_middleware"; /** * These are the hard-coded credentials for logging in to this template. */ const USERNAME = "username"; const PASSWORD = "password"; const COOKIE_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes const CANVA_BASE_URL = "https://canva.com"; interface Data { users: string[]; } /** * For more information on Authentication, refer to our documentation: {@link https://www.canva.dev/docs/apps/authenticating-users/#types-of-authentication}. */ export const createAuthRouter = () => { const APP_ID = getAppId(); /** * Set up a database with a "users" table. In this example code, the * database is simply a JSON file. */ const db = new JSONFileDatabase<Data>({ users: [] }); const router = express.Router(); /** * The `cookieParser` middleware allows the routes to read and write cookies. * * By passing a value into the middleware, we enable the "signed cookies" feature of Express.js. The * value should be static and cryptographically generated. If it's dynamic (as shown below), cookies * won't persist between server restarts. * * TODO: Replace `crypto.randomUUID()` with a static value, loaded via an environment variable. */ router.use(cookieParser(crypto.randomUUID())); /** * This endpoint is hit at the start of the authentication flow. It contains a state which must * be passed back to canva so that Canva can verify the response. It must also set a nonce in the * user's browser cookies and send the nonce back to Canva as a url parameter. * * If Canva can validate the state, it will then redirect back to the chosen redirect url. */ router.get("/configuration/start", async (req, res) => { /** * Generate a unique nonce for each request. A nonce is a random, single-use value * that's impossible to guess or enumerate. We recommended using a Version 4 UUID that * is cryptographically secure, such as one generated by the `randomUUID` method. */ const nonce = crypto.randomUUID(); // Set the expiry time for the nonce. We recommend 5 minutes. const expiry = Date.now() + COOKIE_EXPIRY_MS; // Create a JSON string that contains the nonce and an expiry time const nonceWithExpiry = JSON.stringify([nonce, expiry]); // Set a cookie that contains the nonce and the expiry time res.cookie("nonce", nonceWithExpiry, { secure: true, httpOnly: true, maxAge: COOKIE_EXPIRY_MS, signed: true, }); // Create the query parameters that Canva requires const params = new URLSearchParams({ nonce, state: req?.query?.state?.toString() || "", }); // Redirect to Canva with required parameters res.redirect(302, `${CANVA_BASE_URL}/apps/configure/link?${params}`); }); /** * This endpoint renders a login page. Once the user logs in, they're * redirected back to Canva, which completes the authentication flow. */ router.get( "/redirect-url", /** * Use a JSON Web Token (JWT) to verify incoming requests. The JWT is * extracted from the `canva_user_token` parameter. */ createJwtMiddleware(APP_ID, getTokenFromQueryString), /** * Warning: For demonstration purposes, we're using basic authentication and * hard- coding a username and password. This is not a production-ready * solution! */ basicAuth({ users: { [USERNAME]: PASSWORD }, challenge: true, }), async (req, res) => { const failureResponse = () => { const params = new URLSearchParams({ success: "false", state: req.query.state?.toString() || "", }); res.redirect(302, `${CANVA_BASE_URL}/apps/configured?${params}`); }; // Get the nonce and expiry time stored in the cookie. const cookieNonceAndExpiry = req.signedCookies.nonce; // Get the nonce from the query parameter. const queryNonce = req.query.nonce?.toString(); // After reading the cookie, clear it. This forces abandoned auth flows to be restarted. res.clearCookie("nonce"); let cookieNonce = ""; let expiry = 0; try { [cookieNonce, expiry] = JSON.parse(cookieNonceAndExpiry); } catch { // If the nonce can't be parsed, assume something has been compromised and exit. return failureResponse(); } // If the nonces are empty, exit the authentication flow. if ( isEmpty(cookieNonceAndExpiry) || isEmpty(queryNonce) || isEmpty(cookieNonce) ) { return failureResponse(); } /** * Check that: * * - The nonce in the cookie and query parameter contain the same value * - The nonce has not expired * * **Note:** We could rely on the cookie expiry, but that is vulnerable to tampering * with the browser's time. This allows us to double-check based on server time. */ if (expiry < Date.now() || cookieNonce !== queryNonce) { return failureResponse(); } // Get the userId from JWT middleware const { userId } = req.canva; // Load the database const data = await db.read(); // Add the user to the database if (!data.users.includes(userId)) { data.users.push(userId); await db.write(data); } // Create query parameters for redirecting back to Canva const params = new URLSearchParams({ success: "true", state: req?.query?.state?.toString() || "", }); // Redirect the user back to Canva res.redirect(302, `${CANVA_BASE_URL}/apps/configured?${params}`); }, ); /** * TODO: Add this middleware to all routes that will receive requests from * your app. */ const jwtMiddleware = createJwtMiddleware(APP_ID); /** * This endpoint is called when a user disconnects an app from their account. * The app is expected to de-authenticate the user on its backend, so if the * user reconnects the app, they'll need to re-authenticate. * * Note: The name of the endpoint is *not* configurable. * * Note: This endpoint is called by Canva's backend directly and must be * exposed via a public URL. To test this endpoint, add a proxy URL, such as * one generated by nGrok, to the 'Add authentication' section in the * Developer Portal. Localhost addresses will not work to test this endpoint. */ router.post("/configuration/delete", jwtMiddleware, async (req, res) => { // Get the userId from JWT middleware const { userId } = req.canva; // Load the database const data = await db.read(); // Remove the user from the database await db.write({ users: data.users.filter((user) => user !== userId), }); // Confirm that the user was removed res.send({ type: "SUCCESS", }); }); /** * All routes that start with /api will be protected by JWT authentication */ router.use("/api", jwtMiddleware); /** * This endpoint checks if a user is authenticated. */ router.post("/api/authentication/status", async (req, res) => { // Load the database const data = await db.read(); // Check if the user is authenticated const isAuthenticated = data.users.includes(req.canva.userId); // Return the authentication status res.send({ isAuthenticated, }); }); return router; }; /** * Checks if a given param is nullish or an empty string * * @param str The string to check * @returns true if the string is nullish or empty, false otherwise */ function isEmpty(str?: string): boolean { return str == null || str.length === 0; } /** * Retrieves the CANVA_APP_ID from the environment variables. * Throws an error if the CANVA_APP_ID environment variable is undefined or set to a default value. * * @returns {string} The Canva App ID * @throws {Error} If CANVA_APP_ID environment variable is undefined or set to a default value */ function getAppId(): string { // TODO: Set the CANVA_APP_ID environment variable in the project's .env file const appId = process.env.CANVA_APP_ID; if (!appId) { throw new Error( `The CANVA_APP_ID environment variable is undefined. Set the variable in the project's .env file.`, ); } if (appId === "YOUR_APP_ID_HERE") { // eslint-disable-next-line no-console console.log( chalk.bgRedBright( "Default 'CANVA_APP_ID' environment variable detected.", ), ); // eslint-disable-next-line no-console console.log( "Please update the 'CANVA_APP_ID' environment variable in your project's `.env` file " + `with the App ID obtained from the Canva Developer Portal: ${chalk.greenBright( "https://www.canva.com/developers/apps", )}\n`, ); } return appId; }