Discord OAuth2 in Ghost

Discord OAuth2 in Ghost

If you've used this site, you might have pressed the 'Sign In' button in the top right. That button usually opens Ghost's native login prompt (which takes Email & an OTP as sign-in). But what if you want other auth providers (like Discord)? In this article we'll use a CloudFlare Worker to handle Discord as an Auth provider for Ghost!

The source code for this project can be found here!

Setting up your workspace

Before we start writing any code, we need to install wrangler and init our worker. We'll be using TypeScript for this worker (Because type safety is cool) but you can use JavaScript if you prefer. Your working folder should look something like this:

The Ghost Admin API

Ghost surfaces an Admin API which allows you to programatically create, alter and even log in Ghost members. Before we start integrating this API, let's break down the API methods we'll need to use:

  • /ghost/api/admin/members (POST, GET)
  • /ghost/api/admin${member.id}/signin_urls (GET)

With this information in hand, we need to find out which auth methods & permissions are needed to access these endpoints. The stickler here is the signin_urls endpoint as it NEEDS user authentication (in the latest Ghost versions, previously you could use a staff token on the experimental API). This means we'll need one more API method to obtain our Auth credentials:

  • /ghost/api/admin/session (POST)

ghost.ts

const ADMIN_API_BASE_URL: string = 'https://blog.pnly.io/ghost/api/admin'

export async function getGhostAuth(
    username: string,
    password: string
): Promise<string | null> {
    const r = await fetch(
        `${ADMIN_API_BASE_URL}/session`,
        {
            method: 'POST',
            headers: {
                'content-type': 'application/json',
                'user-agent': 'PNLY Ghost API Worker (cloudflare 1.0)',
                origin: 'https://login.pnly.io'
            },
            body: JSON.stringify({
                username,
                password
            })
        }
    )
    return r.headers.get('set-cookie')
}

export async function getMemberByEmail(auth: string, email: string): Promise<GhostMember | undefined> {
    const response = await fetch(
        `${ADMIN_API_BASE_URL}/members?filter=email:${email}`,
        {
            headers: {
                cookie: auth,
                'content-type': 'application/json',
                'user-agent': 'PNLY Ghost API Worker (cloudflare 1.0)',
                origin: 'https://login.pnly.io'
            }
        }
    )
    if (!response.ok) {
        throw new Error(`[GHOST] ${response.url}: ${response.status} ${response.statusText}\n${await response.text()}`)
    }
    const responseJson: {members: GhostMember[]} = await response.json()
    return responseJson.members[0]
}

export async function createMemberByEmail(auth: string, email: string, name: string | undefined = undefined): Promise<GhostMember> {
    const response = await fetch(
        `${ADMIN_API_BASE_URL}/members?filter=email:${email}`,
        {
            headers: {
                cookie: auth,
                'content-type': 'application/json',
                'user-agent': 'PNLY Ghost API Worker (cloudflare 1.0)',
                origin: 'https://login.pnly.io'
            },
            method: 'POST',
            body: JSON.stringify({
                members: [{
                    email,
                    name
                }]
            })
        }
    )
    if (!response.ok) {
        throw new Error(`[GHOST] ${response.url}: ${response.status} ${response.statusText}\n${await response.text()}`)
    }
    const responseJson: {members: GhostMember[]} = await response.json()
    return responseJson.members[0]
}

export async function getMemberImpersonationByEmail(auth: string, email: string): Promise<string> {
    const member = await getMemberByEmail(auth, email)
    if (!member) {
        throw new Error('Member does not exist')
    }
    const response = await fetch(
        `${ADMIN_API_BASE_URL}/members/${member.id}/signin_urls`,
        {
            headers: {
                cookie: auth,
                'content-type': 'application/json',
                'user-agent': 'PNLY Ghost API Worker (cloudflare 1.0)',
                origin: 'https://login.pnly.io'
            }
        }
    )
    if (!response.ok) {
        throw new Error(`[GHOST] ${response.url}: ${response.status} ${response.statusText}\n${await response.text()}`)
    }
    const responseJson: {member_signin_urls: {url: string}[]} = await response.json()
    return responseJson.member_signin_urls[0].url
}

Handling Discord OAuth2

Now we have the Ghost API integrated, we need to handle Discord OAuth2. To do this, we'll use the Authorization Code Grant. This is relatively simple to do, you can use an existing Discord OAuth2 wrapper, or create your own. If creating your own, I reccomend you use Discord API Types to simplify your typing. You will need to create your own Discord app and note down your App's secret, ID and set a redirect URI (This will be the URL users are sent to after authing with Discord, we want this to be the URL our worker is on!).

discord.ts

const DISCORD_API_BASE_URL: string = 'https://discord.com/api/v10'

export async function handleOauth(
    secret: string,
    client_id: string,
    redirect_uri: string,
    code: string
): Promise<RESTPostOAuth2AccessTokenResult> {
    const oauthParams = new URLSearchParams()
    oauthParams.append('client_id', client_id)
    oauthParams.append('client_secret', secret)
    oauthParams.append('grant_type', 'authorization_code')
    oauthParams.append('code', code)
    oauthParams.append('redirect_uri', redirect_uri)
  
    const oauthReq = await fetch(
        'https://discord.com/api/v10/oauth2/token',
        {
            method: 'POST',
            body: oauthParams,
            headers: {
                'content-type': 'application/x-www-form-urlencoded'
            }
        }
    )

    if (!oauthReq.ok) {
        console.warn(JSON.stringify(await oauthReq.json(), null, 2))
        throw new Error(`Discord API error, OAuth2: ${oauthReq.status}`)
    }

    const oauthData: RESTPostOAuth2AccessTokenResult = await oauthReq.json()

    return oauthData
}

export async function OAuth2IdentifyUser(
    tokenData: RESTPostOAuth2AccessTokenResult
): Promise<APIUser> {
    const userReq = await fetch(
        `https://discord.com/api/v10/users/@me`,
        {
            headers: {
                'content-type': 'application/json; charset=utf-8',
                authorization: `Bearer ${tokenData.access_token}`
            }
        }
    )

    const userData: APIUser = await userReq.json()

    return userData
}

Putting it all together

Now we have our major two elements integrated (Ghost API & Discord Oauth2), we need to put both elements together. Because we've cleanly exported all of our functions, we can make a very simple handler for this. Take note of the env vars required for this part of the source code, we'll come back to these later!

As part of the source code below, we always check if a user's email is already registered before we create a Ghost member for that email. This prevents duplicate users being created, but this isn't infalliable. If a Discord user changes their email, a new Ghost member will be created. You can tie emails to Discord user IDs using Workers KV if you'd like.

Include the email & identify scope in your OAUTH2_AUTH_URI!

helpers.ts

import { Env } from "..";
import { OAuth2IdentifyUser, handleOauth } from "./discord";
import { createMemberByEmail, getGhostAuth, getMemberByEmail, getMemberImpersonationByEmail, GhostMember } from "./ghost";
const OAUTH2_AUTH_URI: string = 'https://discord.com/oauth2/authorize?client_id=1216308158038020137&response_type=code&redirect_uri=https%3A%2F%2Flogin.pnly.io&scope=identify+email&prompt=none'

export async function resolveSiteHit(
    request: Request,
    env: Env
): Promise<Response> {
    const REQUEST_URL = new URL(request.url)
    const CODE = REQUEST_URL.searchParams.get('code')
    if (CODE) {
        const OAUTH2_DATA = await handleOauth(
            env.DISCORD_SECRET,
            env.DISCORD_APP_ID,
            env.DISCORD_REDIRECT_URI,
            CODE
        )
        const USER_DATA = await OAuth2IdentifyUser(OAUTH2_DATA)
        if (!USER_DATA.email) {return Response.redirect('https://blog.pnly.io')}
        const GHOST_AUTH = await getGhostAuth(
            env.ADMIN_USER,
            env.ADMIN_PASS
        )
        if (!GHOST_AUTH) {return Response.redirect('https://blog.pnly.io')}
        const GHOST_USER = await getMemberByEmail(
            GHOST_AUTH,
            USER_DATA.email
        )
        if (!GHOST_USER) {
            const NEW_GHOST_USER = await createMemberByEmail(
                GHOST_AUTH,
                USER_DATA.email,
                USER_DATA.global_name ?? USER_DATA.username
            )
            const REDIRECT = await getMemberImpersonationByEmail(
                GHOST_AUTH,
                USER_DATA.email
            )
            return Response.redirect(REDIRECT)
        }
        const REDIRECT = await getMemberImpersonationByEmail(
            GHOST_AUTH,
            USER_DATA.email
        )
        return Response.redirect(REDIRECT)
    }
    return Response.redirect(OAUTH2_AUTH_URI)
}

Configuring your CF Worker

Now you have all your handlers set up, you'll need to fill in your index file and set up your wrangler.toml.

index.ts

import { resolveSiteHit } from "./handlers/helpers"

export interface Env {
	ADMIN_USER: string
	ADMIN_PASS: string
	DISCORD_SECRET: string
	DISCORD_APP_ID: string
	DISCORD_REDIRECT_URI: string
}

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
		return await resolveSiteHit(
			request,
			env
		)
	},
};

wrangler.toml

name = "bloglogin"
main = "src/index.ts"
compatibility_date = "2024-03-04"

[vars]
ADMIN_USER = "ghost admin username (email)"
ADMIN_PASS = "ghost admin password"
DISCORD_SECRET = "discord app's secret"
DISCORD_APP_ID = "discord app's id"
DISCORD_REDIRECT_URI = "https://login.pnly.io (your worker's URL)"

Deploying & extra configuration

Once you have all of your code ready, go ahead and use the deploy command to get your worker up and running. Take note of the URL that Wrangler publishes to. If you don't have a site set up on CloudFlare, you'll probably want to use this URL as your Discord Redirect URI.

If you DO have a site on CloudFlare, you might want to set a subdomain as a Worker Route for this Worker.