@canva/create-app
Version:
A command line tool for creating Canva Apps.
286 lines (244 loc) • 9.25 kB
text/typescript
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;
}