Self-Hosting Decap CMS Authentication: Implementing GitHub OAuth with Cloudflare Pages Functions

Technologies Used
Decap CMSCloudflare Pages

Decap CMS (formerly Netlify CMS) is a powerful, Git-based content management system that offers a fantastic developer and editor experience. While it seamlessly integrates with Git providers like GitHub, GitLab, and Bitbucket, handling authentication can sometimes be a sticking point if you're not deploying directly on Netlify. Specifically, managing the OAuth flow for providers like GitHub requires a server-side component to exchange authorization codes for access tokens.

This is where Cloudflare Pages Functions shine! They provide a serverless environment directly within your Cloudflare Pages project, allowing you to run backend code without managing a separate server. This post will walk you through implementing a self-hosted GitHub OAuth authentication flow for Decap CMS using Cloudflare Pages Functions.

Bridging the OAuth Gap

When a user tries to log into Decap CMS via GitHub, the following steps occur:

  1. Authorization Request: Decap CMS redirects the user to GitHub to grant permissions.
  2. Callback with Code: GitHub redirects the user back to your site with an authorization code.
  3. Token Exchange (The Missing Piece): Your site needs to send this code along with your application's client_id and client_secret to GitHub's token endpoint to get an access_token. This step must happen server-side to keep your client_secret secure.
  4. Token Delivery: The access_token is then passed back to Decap CMS, allowing it to interact with GitHub on the user's behalf.

Without a server to handle step 3, you're stuck. Cloudflare Pages Functions fill this gap perfectly, offering a zero-cost, fully integrated solution that lives right alongside your frontend code.

Architecture Overview

Our solution involves three key files:

  • decap/auth.ts: This Cloudflare Pages Function initiates the GitHub OAuth flow. It redirects the user to GitHub's authorization page.
  • decap/callback.ts: This Cloudflare Pages Function handles the redirect back from GitHub. It exchanges the authorization code for an access_token and then uses a small JavaScript snippet to pass this token securely back to the Decap CMS client.
  • lib/OAuthClient.ts: A helper class that encapsulates the logic for constructing OAuth URLs and making token exchange requests.

Step-by-Step Implementation

Let's dive into setting this up!

1. Create a GitHub OAuth App

First, you need to register your application with GitHub.

  1. Go to your GitHub Settings -> Developer settings -> OAuth Apps.
  2. Click New OAuth App.
  3. Fill in the details:
  • Application name: Something descriptive, e.g., "Your Site Decap CMS"
  • Homepage URL: The URL of your Decap CMS instance (e.g., https://your-site.pages.dev)
  • Authorization callback URL: This is critical. It should point to your Cloudflare Pages Function. If your site is https://your-site.pages.dev, the callback URL will be https://your-site.pages.dev/decap/callback?provider=github.
  1. Click Register application.
  2. On the next screen, you will see your Client ID. Click Generate a new client secret to get your Client Secret. Save both of these values immediately, as you won't be able to see the client secret again.

2. Configure Cloudflare Pages Environment Variables

Your Cloudflare Pages Functions need access to the Client ID and Client Secret you just obtained.

  1. In your Cloudflare dashboard, navigate to your Pages project.
  2. Go to Settings -> Environment variables.
  3. Add two new variables:
  • GITHUB_OAUTH_ID: Paste your GitHub OAuth App's Client ID here.
  • GITHUB_OAUTH_SECRET: Paste your GitHub OAuth App's Client Secret here.

3. Implement the Cloudflare Pages Functions

Now, let's add the code to your project. Create a functions directory at the root of your project, and inside it, create a decap directory.

your-project/
├── functions/
│   ├── decap/
│   │   ├── auth.ts
│   │   └── callback.ts
│   └── lib/
│       └── OAuthClient.ts
├── public/
│   └── admin/
│       └── index.html
│       └── config.yml
└── ...

functions/lib/OAuthClient.ts

This utility class abstracts away the details of constructing URLs and making fetch requests for GitHub's OAuth endpoints.

type OAuthConfig = {
    id: string;
    secret: string;
    target: {
        tokenHost: string;
        tokenPath: string;
        authorizePath: string;
    };
};

export class OAuthClient {
    private clientConfig: OAuthConfig;

    constructor(config: OAuthConfig) {
        this.clientConfig = config;
    }

    authorizeURL(options: { redirect_uri: string; scope: string; state: string }) {
        const { clientConfig } = this;
        const { tokenHost, authorizePath } = clientConfig.target;
        const { redirect_uri, scope, state } = options;

        return `${tokenHost}${authorizePath}?response_type=code&client_id=${clientConfig.id}&redirect_uri=${redirect_uri}&scope=${scope}&state=${state}`;
    }

    async getToken(options: { code: string; redirect_uri: string }) {
        const { clientConfig } = this;
        const { tokenHost, tokenPath } = clientConfig.target;
        const { code, redirect_uri } = options;

        const response = await fetch(`${tokenHost}${tokenPath}`, {
            method: 'POST',
            headers: {
                Accept: 'application/json',
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                client_id: clientConfig.id,
                client_secret: clientConfig.secret,
                code,
                redirect_uri,
                grant_type: 'authorization_code',
            }),
        });

        const json = (await response.json()) as { access_token: string };
        return json.access_token;
    }
}

functions/decap/auth.ts

This function handles the initial authentication request from Decap CMS. It constructs the authorization URL and redirects the user to GitHub.

import { randomBytes } from 'node:crypto';
import { OAuthClient } from '../lib/OAuthClient';

interface Env {
    GITHUB_OAUTH_ID: string;
    GITHUB_OAUTH_SECRET: string;
}

const createOAuth = (env: Env) => {
    return new OAuthClient({
        id: env.GITHUB_OAUTH_ID,
        secret: env.GITHUB_OAUTH_SECRET,
        target: {
            tokenHost: 'https://github.com',
            tokenPath: '/login/oauth/access_token',
            authorizePath: '/login/oauth/authorize',
        },
    });
};

const handleAuth = async (url: URL, env: Env) => {
    const provider = url.searchParams.get('provider');
    if (provider !== 'github') {
        return new Response('Invalid provider', { status: 400 });
    }

    const oauth2 = createOAuth(env);
    // Generates a random state to prevent CSRF attacks
    const authorizationUri = oauth2.authorizeURL({
        redirect_uri: `https://${url.hostname}/decap/callback?provider=github`,
        scope: 'repo,user', // Request necessary permissions
        state: randomBytes(4).toString('hex'), 
    });

    // Redirects the user to GitHub for authorization
    return new Response(null, { headers: { location: authorizationUri }, status: 301 });
};

export function onRequest(context) {
    const url = new URL(context.request.url);
    return handleAuth(url, context.env);
}

Explanation:

  • import { randomBytes } from 'node:crypto';: Used to generate a secure random state parameter, crucial for preventing Cross-Site Request Forgery (CSRF) attacks.
  • createOAuth(env): Initializes our OAuthClient with the GITHUB_OAUTH_ID and GITHUB_OAUTH_SECRET from the environment variables.
  • oauth2.authorizeURL(...): This calls our OAuthClient to construct the full GitHub authorization URL.
  • redirect_uri: Must exactly match the URL registered in your GitHub OAuth App settings.
  • scope: Defines the permissions your app needs (e.g., repo for accessing repositories, user for basic user info).
  • state: A randomly generated string passed to GitHub and then returned in the callback. You should ideally store and verify this to ensure the callback originated from your request. For simplicity in this Decap CMS context, we're not explicitly verifying it on callback, but it's good practice.
  • return new Response(null, { headers: { location: authorizationUri }, status: 301 });: This performs the HTTP redirect to GitHub.

functions/decap/callback.ts

This function receives the code from GitHub, exchanges it for an access_token, and then communicates this token back to the Decap CMS client.

import { OAuthClient } from '../lib/OAuthClient';

interface Env {
    GITHUB_OAUTH_ID: string;
    GITHUB_OAUTH_SECRET: string;
}

const createOAuth = (env: Env) => {
    return new OAuthClient({
        id: env.GITHUB_OAUTH_ID,
        secret: env.GITHUB_OAUTH_SECRET,
        target: {
            tokenHost: 'https://github.com',
            tokenPath: '/login/oauth/access_token',
            authorizePath: '/login/oauth/authorize',
        },
    });
};

// This function generates an HTML response containing JavaScript
// that uses window.postMessage to send the token back to the Decap CMS window.
const callbackScriptResponse = (status: string, token: string) => {
    return new Response(
        `
<html>
<head>
    <script>
        const receiveMessage = (message) => {
            // Decap CMS expects a specific message format
            window.opener.postMessage(
                'authorization:github:${status}:${JSON.stringify({ token })}',
                '*'
            );
            window.removeEventListener("message", receiveMessage, false);
        }
        window.addEventListener("message", receiveMessage, false);
        window.opener.postMessage("authorizing:github", "*");
    </script>
    <body>
        <p>Authorizing Decap...</p>
    </body>
</head>
</html>
`,
        { headers: { 'Content-Type': 'text/html' } }
    );
};


const handleCallback = async (url: URL, env: Env) => {
    const provider = url.searchParams.get('provider');
    if (provider !== 'github') {
        return new Response('Invalid provider', { status: 400 });
    }

    const code = url.searchParams.get('code');
    if (!code) {
        return new Response('Missing code', { status: 400 });
    }

    const oauth2 = createOAuth(env);
    // Exchange the authorization code for an access token
    const accessToken = await oauth2.getToken({
        code,
        redirect_uri: `https://${url.hostname}/decap/callback?provider=github`,
    });

    // Send the access token back to Decap CMS
    return callbackScriptResponse('success', accessToken);
};

export function onRequest(context) {
    const url = new URL(context.request.url);
    return handleCallback(url, context.env);
}

Explanation:

  • callbackScriptResponse(...): This is the crucial part that talks back to Decap CMS.
  • It generates a small HTML page containing JavaScript.
  • This JavaScript uses window.opener.postMessage to send the access_token back to the window that opened the OAuth popup (which is your Decap CMS admin interface).
  • The message format authorization:github:success:{ "token": "..." } is the specific structure Decap CMS expects to complete the login.
  • code = url.searchParams.get('code'): Extracts the authorization code provided by GitHub in the redirect URL.
  • accessToken = await oauth2.getToken(...): Calls our OAuthClient to make the POST request to GitHub's access_token endpoint, using the code and your client credentials.
  • return callbackScriptResponse('success', accessToken);: Responds with the HTML/JavaScript to close the loop with Decap CMS.

4. Update Your Decap CMS config.yml

Finally, you need to tell Decap CMS to use your new Cloudflare Pages Functions for authentication. In your admin/config.yml (or wherever your config is located), update the backend section:

backend:
  name: github
  repo: your-username/your-repository # e.g. "myname/my-blog"
  branch: main # or "master", "develop", etc.
  base_url: https://your-site.pages.dev/decap # This is the crucial line!
  auth_endpoint: /auth # This will now point to /decap/auth

Important: Replace https://your-site.pages.dev with your actual Cloudflare Pages domain. The base_url directive tells Decap CMS where to find your custom authentication functions.

Deploy and Test!

Commit these changes to your repository. Cloudflare Pages will automatically detect the functions directory and deploy your serverless functions.

Once deployed:

  1. Navigate to your Decap CMS admin panel (e.g., https://your-site.pages.dev/admin/).
  2. Click the "Login with GitHub" button.
  3. You should be redirected to GitHub, asked to authorize your app, and then redirected back to your CMS, successfully logged in!

Conclusion

By leveraging Cloudflare Pages Functions, you've successfully created a robust, serverless, and completely free solution for self-hosting GitHub OAuth authentication for Decap CMS. This approach keeps your entire application within the Cloudflare ecosystem, simplifying deployment and maintenance while providing the necessary backend logic for secure OAuth handshakes.

Explore More Posts 👇