Example App

Below we provide an example App that demonstrates all of the requests we have discussed in this section.

You will need to have completed the steps in Creating Your App so you have your Client ID and Client Secret to provide.

The Example App supports Node.js v16 and above.

CLIENT_ID='xxxxx' CLIENT_SECRET='yyyyy' node apps_example.js

If your browser does not open automatically please visit the link in the console message to start the demonstration.

You may find it easier to open the below code in your preferred editor rather than reading it on this page.

// See https://docs.usecanopy.com/reference/apps-example

import assert from 'node:assert/strict';
import { exec } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { createServer } from 'node:http';

// You will need to set these environment variables to the values you were given
// when your App was created.
// https://docs.usecanopy.com/reference/apps-create
const { CLIENT_ID, CLIENT_SECRET } = process.env;
assert(CLIENT_ID, 'CLIENT_ID not set');
assert(CLIENT_SECRET, 'CLIENT_SECRET not set');

// You probably won’t need to change this value but it can be useful to choose a
// different API endpoint to inspect the requests.
const CANOPY_CONNECT_ORIGIN =
    process.env.CANOPY_CONNECT_ORIGIN ?? 'https://app.usecanopy.com';

/**
 * For production you will need to pre-configure any `redirect_uri` values you
 * want to use, however Apps in Sandbox Mode can use any localhost URL.
 * @see {@link https://docs.usecanopy.com/reference/apps-create#sandbox-mode}
 */
const REDIRECT_URI = new URL('http://localhost:25055/auth-result');

/**
 * A simple store for session data. Your app would probably use a database or
 * other caching software.
 * @type {Record<Session['id'], Session['data']>}
 */
const SESSIONS = {};
const SESSION_COOKIE = 'cc-app-demo-sid';

startApp();

/**
 * The first part of the OAuth 2.0 flow begins when a user asks to link your App
 * with their Canopy Connect account. You will redirect the user on to our
 * `/oauth2/authorize` endpoint.
 *
 * @see {@link https://docs.usecanopy.com/reference/apps-authorization}
 *
 * @returns {HandlerResult}
 */
function generateAuthRequest() {
    // We’ll use `state` to store a “nonce” for the request and ensure it
    // matches when the user returns to our App. This helps to prevent
    // Cross-Site Request Forgery (CSRF) attacks.
    const state = randomBytes(8).toString('hex');

    // Generate an OAuth PKCE code verifier and challenge.
    // https://docs.usecanopy.com/reference/apps-authorization#proof-key-for-code-exchange-pkce
    const codeVerifier = randomBytes(32).toString('base64url');
    const codeChallenge = createHash('sha256')
        .update(codeVerifier)
        .digest('base64url');

    // We’ll store the state and code verifier in a session to access later.
    const session = createSession({ state, codeVerifier });

    // Create the URI to send the user to.
    // https://docs.usecanopy.com/reference/apps-authorization#proof-key-for-code-exchange-pkce
    const url = new URL('/oauth2/authorize', CANOPY_CONNECT_ORIGIN);
    url.searchParams.set('client_id', `${CLIENT_ID}`);
    url.searchParams.set('redirect_uri', `${REDIRECT_URI}`);
    url.searchParams.set('scope', 'read:pulls read:webhooks write:webhooks');
    url.searchParams.set('response_type', 'code');
    url.searchParams.set('code_challenge_method', 'S256');
    url.searchParams.set('code_challenge', codeChallenge);
    url.searchParams.set('state', state);

    // We are going to use the default `query` mode so the user is returned to
    // our app in a GET request. We do not need to set a `response_mode`.
    // https://docs.usecanopy.com/reference/apps-authorization#response-data

    return {
        statusCode: 303,
        headers: {
            Location: url.toString(),
            'Set-Cookie': `${SESSION_COOKIE}=${session.id}; Path=/; HttpOnly`,
        },
    };
}

/**
 * When the user returns to our App they should have a `code` query parameter
 * with the Authorization Code we need to request an Access Token.
 *
 * @see {@link https://docs.usecanopy.com/reference/apps-authorization#response-data}
 *
 * @param {HandlerOptions} options
 * @returns {Promise<HandlerResult>}
 */
async function handleAuthResult({ requestHeaders, requestURL }) {
    const session = getSession(requestHeaders);
    assert(session?.data?.state, 'No session state');
    assert(session?.data?.codeVerifier, 'No session codeVerifier');

    try {
        const query = requestURL.searchParams;

        // We check that the returned `state` matches our request to protect our
        // user from CSRF attacks.
        if (query.get('state') !== session.data.state) {
            throw new Error(
                `Query param state does not match session state: ${query.get(
                    'state'
                )} vs ${session.data.state}`
            );
        }

        // The query parameters may have an `error` code if the request was
        // denied or an error was encountered.
        // https://docs.usecanopy.com/reference/apps-authorization#failure
        if (query.has('error')) {
            throw new Error(
                `Query params indicate an error: ${JSON.stringify(
                    Object.fromEntries(query),
                    null,
                    2
                )}`
            );
        }

        // A successful request means the user will return to our app with an
        // Authorization Code.
        // https://docs.usecanopy.com/reference/apps-authorization#success
        const code = query.get('code');
        if (!code) {
            throw new Error(
                `Query params did not have code: ${JSON.stringify(
                    Object.fromEntries(query),
                    null,
                    2
                )}`
            );
        }

        // Now that we have an Auth Code our backend servers can use it to
        // request an Access Token.
        // https://docs.usecanopy.com/reference/apps-access-tokens
        const result = await requestAccessToken(
            code,
            session.data.codeVerifier
        );

        processTokenResult(session, result);

        return {
            statusCode: 200,
            title: 'Successful OAuth2 Exchange!',
            body: `
<pre>${JSON.stringify(result, null, 2)}</pre>
<form action="/fetch-pull-data" method="post">
<button type="submit">
    Fetch Pull Data
</button>
</form>
            `.trim(),
        };
    } catch (err) {
        return {
            statusCode: 400,
            title: 'Failed OAuth2 Exchange!',
            body: `
<pre>${err?.stack ?? err}</pre>
<p><a href="/">Homepage</a></p>
            `.trim(),
        };
    }
}

/**
 * The client-side flow has provided an Authorization Code which we can now use
 * to generate the Access Token we will use to make API requests. The other
 * requests all took place in the user’s browser, but now we switch to our
 * backend server code.
 *
 * @see {@link https://docs.usecanopy.com/reference/apps-access-tokens}
 *
 * @param {string} code
 * @param {string} codeVerifier
 * @returns {Promise<Record<string, unknown>>}
 */
function requestAccessToken(code, codeVerifier) {
    const url = new URL('/oauth2/token', CANOPY_CONNECT_ORIGIN);
    const data = {
        client_id: `${CLIENT_ID}`,
        code,
        grant_type: 'authorization_code',
        redirect_uri: `${REDIRECT_URI}`,

        // We use the client secret we were given when we created our app to
        // prove this requests come from our servers.
        client_secret: `${CLIENT_SECRET}`,

        // We use the secret code verifier we generated earlier to complete the
        // PKCE exchange.
        // https://docs.usecanopy.com/reference/apps-authorization#proof-key-for-code-exchange-pkce
        code_verifier: codeVerifier,
    };

    return doRequest('POST', url, data);
}

/**
 * Now that we have stored an Access Token we can use it to make API calls on
 * the Team’s behalf.
 *
 * If you don’t have any pull data yet you can generate some using our sandbox
 * credentials (eg. using `user_good` and `pass_good`).
 *
 * @see {@link https://docs.usecanopy.com/reference/sandbox-credentials}
 * @see {@link https://docs.usecanopy.com/reference/get-pulls}
 *
 * @param {HandlerOptions} options
 * @returns {Promise<HandlerResult>}
 */
async function fetchPullData({ requestHeaders }) {
    const session = getSession(requestHeaders);
    assert(session?.data?.accessToken, 'No session accessToken');
    assert(session?.data?.teamID, 'No session teamID');

    const url = new URL(
        `/api/v1.0.0/teams/${session.data.teamID}/pulls?limit=1`,
        CANOPY_CONNECT_ORIGIN
    );

    const pullData = await doRequest('GET', url, undefined, {
        Authorization: `Bearer ${session.data.accessToken}`,
    });

    if (pullData.success !== true) {
        throw new Error(
            `Pull Data request failed: ${JSON.stringify(pullData, null, 2)}`
        );
    }

    return {
        statusCode: 200,
        title: 'Successful Pull Data Request!',
        body: `
<p><code>${url}</code></p>
<pre>${JSON.stringify(pullData, null, 2)}</pre>
<form action="/refresh-access-token" method="post">
    <button type="submit">
        Refresh the Access Token
    </button>
</form>
        `.trim(),
    };
}

/**
 * While Access Tokens are relatively short lived, you’ll also be given a
 * Refresh Token that has a longer expiry time. You can use the Refresh Token to
 * get a new Access Token when needed.
 *
 * Note that when you refresh your Access Token you will also be given a new
 * Refresh Token. Refresh Tokens can only be used once.
 *
 * @see {@link https://docs.usecanopy.com/reference/apps-refresh-tokens}
 *
 * @param {HandlerOptions} options
 * @returns {Promise<HandlerResult>}
 */
async function refreshAccessToken({ requestHeaders }) {
    const session = getSession(requestHeaders);
    assert(session?.data?.accessToken, 'No session accessToken');
    assert(session?.data?.expiresAt, 'No session expiresAt');
    assert(session?.data?.refreshToken, 'No session refreshToken');

    const url = new URL('/oauth2/token', CANOPY_CONNECT_ORIGIN);

    const result = await doRequest('POST', url, {
        client_id: `${CLIENT_ID}`,
        client_secret: `${CLIENT_SECRET}`,
        grant_type: 'refresh_token',
        refresh_token: session.data.refreshToken,
    });

    processTokenResult(session, result);

    return {
        statusCode: 200,
        title: 'Access Token Refreshed',
        body: `
<p>
    Note that it wasn’t necessary to refresh the Access Token until
    ${session.data.expiresAt}, so this was just for demonstration purposes.
</p>
<pre>${JSON.stringify(result, null, 2)}</pre>
<p><a href="/">Homepage</a></p>
        `.trim(),
    };
}

/**
 * Whether requesting an Access Token for the first time, or using the Refresh
 * Token to generate a new one after expiry, the response body is the same. This
 * is a little helper function to extract the common code.
 *
 * @see {@link https://docs.usecanopy.com/reference/apps-access-tokens}
 * @see {@link https://docs.usecanopy.com/reference/apps-refresh-tokens}
 *
 * @param {Session} session
 * @param {Record<string, unknown>} result
 */
function processTokenResult(session, result) {
    assert(
        typeof result.access_token === 'string' &&
            typeof result.expires_in === 'number' &&
            typeof result.refresh_token === 'string' &&
            typeof result.team_id === 'string' &&
            result.token_type === 'bearer',
        `Unexpected token response: ${JSON.stringify(result)}`
    );

    // When the Access Token expires we’ll need to fetch a new one using the
    // Refresh Token. We do return an `expires_in` value with the number of
    // seconds until expiry, but for a more precise result you can decode
    // the Access Token JSON Web Token and use the `exp` Unix timestamp.
    // https://docs.usecanopy.com/reference/apps-refresh-tokens
    const expiresAt = getJWTExpiry(result.access_token);

    // We need to store the credentials for future use. In this demo we will
    // just store it in the user’s session, but a real App would probably
    // store this in a database linked to our other user data.
    updateSession(session.id, {
        accessToken: result.access_token,
        expiresAt: expiresAt.toISOString(),
        refreshToken: result.refresh_token,
        teamID: result.team_id,
    });
}

/* ************************************************************************** */
/* ************************************************************************** */
/* ************************************************************************** */
/* ************************************************************************** */
/* ************************************************************************** */
/* ************************************************************************** */
/* ************************************************************************** */
/* ************************************************************************** */
/* ************************************************************************** */
/* ************************************************************************** */

// The functions beyond this point are utilities for creating the Demo App and
// are not relevant for learning about Canopy Connect Apps nor intended for
// production use.

/**
 * @returns {void}
 */
function startApp() {
    const server = createServer(handleAppRequest);

    const homepage = new URL('/', REDIRECT_URI);
    server.listen(Number(homepage.port), homepage.hostname, () => {
        console.log(`The demo app is running at ${homepage}`);
        openBrowser(homepage);
    });
}

/**
 * @param {import('http').IncomingMessage} req
 * @param {import('http').ServerResponse} res
 * @returns {Promise<void>}
 */
async function handleAppRequest(req, res) {
    assert(req.url, 'No request URL');

    const handlers = {
        '/': generateHomepage,
        '/auth-request': generateAuthRequest,
        '/auth-result': handleAuthResult,
        '/fetch-pull-data': fetchPullData,
        '/refresh-access-token': refreshAccessToken,
    };

    const requestURL = new URL(req.url, REDIRECT_URI);
    const handler = handlers[requestURL.pathname];

    if (handler) {
        const requestHeaders = Object.fromEntries(
            Object.entries(req.headers).map(([header, value]) => [
                header,
                `${value}`,
            ])
        );

        try {
            const result = await handler({ requestHeaders, requestURL });
            renderPage(res, result);
        } catch (err) {
            console.error('Unexpected error:');
            console.error(err);

            renderPage(res, {
                statusCode: 500,
                title: 'Unexpected Error!',
                body: `
<pre>${err?.stack ?? err}</pre>
<p><a href="/">Homepage</a></p>
                `.trim(),
            });
        }
    } else {
        renderPage(res, {
            statusCode: 404,
            title: 'Page Not Found',
            body: `
<p><a href="/">Homepage</a></p>
            `.trim(),
        });
    }
}

/**
 * @param {URL} url
 * @returns {void}
 */
function openBrowser(url) {
    let command;
    switch (process.platform) {
        case 'darwin':
            command = 'open';
            break;
        case 'win32':
            command = 'start';
            break;
        default:
            command = 'xdg-open';
    }

    exec(`${command} ${url}`);
}

/**
 * @param {import('node:http').ServerResponse} res
 * @param {HandlerResult} result
 */
function renderPage(res, { statusCode, headers, body, title }) {
    res.statusCode = statusCode;

    if (headers) {
        for (const [header, value] of Object.entries(headers)) {
            res.setHeader(header, value);
        }
    }

    if (!res.hasHeader('content-type')) {
        res.setHeader('content-type', 'text/html; charset=utf-8');
    }

    if (body) {
        res.write(
            `
<!doctype html>
<html>
    <head>
        <meta charset="utf8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>${title} | Canopy Connect Demo App</title>
        <style>
            p, pre {
                max-width: min(90dvw, 60rem);
            }
            pre {
                background: #f0f0f0;
                max-height: 50dvh;
                overflow: auto;
                padding: 1rem;
            }
            button { cursor: pointer; }
        </style>
    </head>
    <body>
<h1>${title}</h1>
${body}
    </body>
</html>
            `.trim()
        );
    }

    res.end();
}

/**
 * @param {HandlerOptions} options
 * @returns {HandlerResult}
 */
function generateHomepage({ requestHeaders }) {
    /** @type {Record<string, string>} */
    const headers = {};

    const session = getSession(requestHeaders);
    if (session?.id) {
        deleteSession(session.id);
        const expires = new Date(0).toUTCString();
        headers[
            'Set-Cookie'
        ] = `${SESSION_COOKIE}=; Path=/; Expires=${expires}`;
    }

    return {
        statusCode: 200,
        headers,
        title: 'Welcome to Demo App',
        body: `
<form action="/auth-request" method="post">
    <button type="submit" style="cursor:pointer">
        Authorize With Canopy Connect
    </button>
</form>
        `.trim(),
    };
}

/**
 * @template {Record<string, string>} T
 * @param {Readonly<T>} data
 * @returns {Session<T>}
 */
function createSession(data) {
    const id = randomBytes(12).toString('base64url');

    SESSIONS[id] = data;

    return { id, data };
}

/**
 * @param {Readonly<Record<string, string>>} requestHeaders
 * @returns {Session | undefined}
 */
function getSession(requestHeaders) {
    const id = requestHeaders.cookie
        ?.split('; ')
        .find((cookie) => cookie.startsWith(`${SESSION_COOKIE}=`))
        ?.slice(SESSION_COOKIE.length + 1);

    if (id && id in SESSIONS) {
        const data = SESSIONS[id];
        return { id, data };
    }
}

/**
 * @param {string} id
 * @param {Readonly<Record<string, string>>} data
 * @returns {void}
 */
function updateSession(id, data) {
    assert(id in SESSIONS);

    SESSIONS[id] = {
        ...SESSIONS[id],
        ...data,
    };
}

/**
 * @param {string} id
 * @returns {void}
 */
function deleteSession(id) {
    delete SESSIONS[id];
}

/**
 * @param {'GET' | 'POST'} method
 * @param {URL} url
 * @param {Record<string, string>} [data]
 * @param {Record<string, string>} [headers]
 * @returns {Promise<Record<string, unknown>>}
 */
async function doRequest(method, url, data, headers) {
    const { request } = await (url.protocol === 'https:'
        ? import('node:https')
        : import('node:http'));

    return new Promise((resolve, reject) => {
        const req = request(url.toString(), { method });

        req.on('error', reject);
        req.on('response', (res) => {
            let body = '';
            res.on('data', (chunk) => {
                body += chunk;
            });
            res.on('end', () => {
                try {
                    resolve(JSON.parse(body));
                } catch (err) {
                    reject(new Error(`Could not parse body as JSON: ${body}`));
                }
            });
            res.on('error', reject);
        });

        if (headers) {
            Object.entries(headers).forEach(([header, value]) =>
                req.setHeader(header, value)
            );
        }

        if (!req.hasHeader('accept')) {
            req.setHeader('accept', 'application/json');
        }

        if (data) {
            if (!req.hasHeader('content-type')) {
                req.setHeader(
                    'content-type',
                    'application/x-www-form-urlencoded'
                );
            }

            req.getHeader('content-type') ===
            'application/x-www-form-urlencoded'
                ? req.write(new URLSearchParams(data).toString())
                : req.write(JSON.stringify(data));
        }

        req.end();
    });
}

/**
 * @param {string} encodedJWT
 * @returns {Record<string, unknown>}
 */
function decodeJWT(encodedJWT) {
    const { payload } =
        encodedJWT.match(
            /^(?:[a-z0-9_-]+)\.(?<payload>[a-z0-9_-]+)\.(?:[a-z0-9_-]+)$/i
        )?.groups ?? {};

    if (!payload) {
        throw new Error(`Invalid JWT: ${encodedJWT}`);
    }

    try {
        return JSON.parse(Buffer.from(payload, 'base64url').toString());
    } catch (err) {
        throw new Error(`Invalid JWT payload: ${encodedJWT}`, { cause: err });
    }
}

/**
 * @param {string} encodedJWT
 * @returns {Date}
 */
function getJWTExpiry(encodedJWT) {
    const jwt = decodeJWT(encodedJWT);

    if (typeof jwt.exp !== 'number') {
        throw new Error(`Invalid JWT expiry “${jwt.exp}” in ${encodedJWT}`);
    }

    return new Date(jwt.exp * 1_000);
}

/**
 * @typedef {{
 *   readonly requestHeaders: Readonly<Record<string, string>>;
 *   readonly requestURL: URL;
 * }} HandlerOptions
 */

/**
 * @typedef {{
 *   readonly statusCode: number;
 *   readonly headers?: Record<string, string>;
 *   readonly body?: string;
 *   readonly title?: string;
 * }} HandlerResult
 */

/**
 * @template {Record<string, string>} [T=Record<string, string>]
 * @typedef {{
 *   readonly id: string;
 *   readonly data: Readonly<T>;
 * }} Session
 */