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