UNPKG

@canva/create-app

Version:

A command line tool for creating Canva Apps.

394 lines (340 loc) 11.3 kB
import * as crypto from "crypto"; import "dotenv/config"; import * as express from "express"; import * as basicAuth from "express-basic-auth"; import { JSONFileDatabase } from "../database/database"; import { createBearerMiddleware } from "../../utils/backend/bearer_middleware"; import * as debug from "debug"; /** * Prefix your start command with `DEBUG=express:route:oauth` to enable debug logging * for this middleware */ const debugLogger = debug("express:route:oauth"); /** * 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 TOKEN_EXPIRY_S = 4 * 60 * 60; // 4 hours const REFRESH_EXPIRY_S = 30 * 24 * 3600; // 30 days const CLIENT_ID = "client_id"; const CLIENT_SECRET = "client_secret"; const REDIRECT_URI = "https://www.canva.com/apps/oauth/authorized"; /** * For simplicity, we simply generate random token values and store them in our database * for production you may want to use JWTs or other stateless methods. */ interface Token { value: string; expiry: number; // Unix timestamp } export interface User { id: string; authorizationCode?: Token; accessToken?: Token; refreshToken?: Token; } interface RedirectUriRecord { redirectUri: string; authorizationCode: string; } export interface Data { users: User[]; redirect_uris: RedirectUriRecord[]; } /** * For more information on Authentication, refer to our documentation: {@link https://www.canva.dev/docs/apps/authenticating-users/#types-of-authentication}. */ export const createAuthRouter = () => { /** * 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: [], redirect_uris: [] }); const router = express.Router(); /** * The urlencoded middleware allows us to parse form data from the request body. * This is required for the OAuth 2.0 Token Exchange endpoint. */ router.use(express.urlencoded({ extended: true })); /** * This is the OAuth 2.0 Authorization endpoint * https://datatracker.ietf.org/doc/html/rfc6749#section-3.1 * * This endpoint does not implement PKCE, which you would want to do * for production. */ router.get( "/auth/authorize", basicAuth({ users: { [USERNAME]: PASSWORD }, challenge: true, }), async (req, res) => { const redirectUri = decodeURIComponent( req.query.redirect_uri?.toString() ?? REDIRECT_URI, ); const clientId = req.query.client_id?.toString(); const responseType = req.query.response_type?.toString(); const state = req.query.state?.toString(); if (!clientId) { return res.status(400).send("Missing required client ID parameter"); } if (clientId !== CLIENT_ID) { return res.status(400).send("Invalid client ID"); } if (redirectUri !== REDIRECT_URI) { return res.status(400).send(`Invalid redirect URI ${redirectUri}`); } const failureResponse = (error: string) => { const params = new URLSearchParams({ error, state: state || "", }); res.redirect(302, `${redirectUri}?${params}`); }; if (responseType !== "code") { return failureResponse("unsupported_response_type"); } // You should implement PKCE to protect against authorization code interception attacks // https://datatracker.ietf.org/doc/html/rfc7636 // this is left off for simplicity in this example // Generate a unique authorization code const authorizationCode = crypto.randomUUID(); // Load the database const data = await db.read(); // Add the user to the database if they aren't there already if (!data.users.find((user) => user.id === USERNAME)) { data.users.push({ id: USERNAME, authorizationCode: { value: authorizationCode, expiry: Date.now() + COOKIE_EXPIRY_MS, }, }); } else { // If the user is there, update the authorization code // In a production system you would allow at least one authorization code // per user per client, but here we simplify and have one per user. data.users = data.users.map((user) => { if (user.id === USERNAME) { return { ...user, authorizationCode: { value: authorizationCode, expiry: Date.now() + COOKIE_EXPIRY_MS, }, }; } return user; }); } if ( !data.redirect_uris.find((record) => record.redirectUri === redirectUri) ) { data.redirect_uris.push({ redirectUri, authorizationCode }); } else { data.redirect_uris = data.redirect_uris.map((record) => { if (record.redirectUri === redirectUri) { return { ...record, authorizationCode }; } return record; }); } await db.write(data); res.redirect( 302, `${redirectUri}?code=${authorizationCode}&state=${state}`, ); }, ); interface AuthorizationExchangeRequest { grant_type: "authorization_code"; code: string; redirect_uri?: string; } interface RefreshTokenExchangeRequest { grant_type: "refresh_token"; refresh_token: string; } const failureResponse = (res: express.Response, error: string) => { return res.status(400).send(`{"error": "${error}"}`); }; const authorizationCodeExchange = async ( res: express.Response, body: AuthorizationExchangeRequest, ) => { const refresh_token = crypto.randomUUID(); const access_token = crypto.randomUUID(); const code = body.code?.toString(); if (!code) { return failureResponse(res, "invalid_request"); } // Load the database const data = await db.read(); const lastRedirectUrl = data.redirect_uris.find( (record) => record.authorizationCode === code, )?.redirectUri; // The specification requires that if the redirect uri was present in the authorization request, // it must be included and the same in the token exchange request if (lastRedirectUrl !== body.redirect_uri?.toString()) { return failureResponse(res, "invalid_request"); } // Find the user who was issued this authorization code const user = data.users.find( (user) => user.authorizationCode?.value === code, ); if (!user || !user.authorizationCode) { return failureResponse(res, "invalid_grant"); } if (user.authorizationCode.expiry < Date.now()) { return failureResponse(res, "invalid_grant"); } // Update the user with the new access token and refresh token // and clear out their authorization code. Authorization codes // are single use, so attempting to use the old one will now fail data.users = data.users.map((user) => { if (user.id === USERNAME) { return { ...user, authorizationCode: undefined, accessToken: { value: access_token, expiry: Date.now() + TOKEN_EXPIRY_S * 1000, }, refreshToken: { value: refresh_token, expiry: Date.now() + REFRESH_EXPIRY_S * 1000, }, }; } return user; }); await db.write(data); return res.status(200).send( JSON.stringify({ access_token, refresh_token, token_type: "bearer", expires_in: TOKEN_EXPIRY_S, }), ); }; const refreshTokenExchange = async ( res: express.Response, body: RefreshTokenExchangeRequest, ) => { const refresh_token = crypto.randomUUID(); const access_token = crypto.randomUUID(); const old_refresh_token = body.refresh_token?.toString(); if (!old_refresh_token) { return failureResponse(res, "invalid_request"); } // Find the user who was issued this refresh token const data = await db.read(); const user = data.users.find( (user) => user.refreshToken?.value === old_refresh_token, ); if (!user) { return failureResponse(res, "invalid_grant"); } // Update the user with the new refresh token // refresh tokens are single use, so attempting // to use the old one will now fail data.users = data.users.map((u) => { if (u.id === user.id) { return { ...u, accessToken: { value: access_token, expiry: Date.now() + TOKEN_EXPIRY_S * 1000, }, refreshToken: { value: refresh_token, expiry: Date.now() + REFRESH_EXPIRY_S * 1000, }, }; } return u; }); await db.write(data); return res.status(200).send( JSON.stringify({ access_token, refresh_token, token_type: "bearer", expires_in: TOKEN_EXPIRY_S, }), ); }; /** * This endpoint is the Oauth 2.0 Token endpoint * https://datatracker.ietf.org/doc/html/rfc6749#section-3.2 * It serves both exchanging authorization codes for access tokens (and refresh tokens), and * exchanging refresh tokens for new access tokens (and refresh tokens) */ router.post( "/auth/token", basicAuth({ users: { [CLIENT_ID]: CLIENT_SECRET }, challenge: false, }), async (req, res) => { const body = (await req.body) as | AuthorizationExchangeRequest | RefreshTokenExchangeRequest; const grantType = body.grant_type?.toString(); if (!grantType) { return failureResponse(res, "invalid_request"); } if (grantType !== "authorization_code" && grantType !== "refresh_token") { return failureResponse(res, "unsupported_grant_type"); } if (grantType === "refresh_token") { refreshTokenExchange(res, body as RefreshTokenExchangeRequest); } return authorizationCodeExchange( res, body as AuthorizationExchangeRequest, ); }, ); /** * TODO: Add this middleware to all routes that will receive requests from * your app. */ const bearerMiddleware = createBearerMiddleware( async (access_token: string): Promise<string | undefined> => { const data = await db.read(); const user = data.users.find( (user) => user.accessToken?.value === access_token, ); debugLogger( `User found: ${user?.id}, expires ${user?.accessToken?.expiry} compare ${Date.now()}`, ); const isAuthenticated = user && user.accessToken && user.accessToken?.expiry > Date.now(); if (!isAuthenticated) { return; } return user.id; }, ); /** * All routes that start with /api will be protected by bearer authentication */ router.use("/api", bearerMiddleware); /** * This endpoint checks if a user is authenticated. */ router.post("/api/authentication/status", async (req, res) => { // Check if the user is authenticated const isAuthenticated = !!req.user_id; // Return the authentication status res.send({ isAuthenticated, }); }); return router; };