How we link Discord & Ghost

How we link Discord & Ghost

If you're reading this, there's a decent chance you've clicked the button on the top right of this page to sign in with Discord. Ghost has no native support for creating memberships from third parties. This article will detail how we got that button there, how it works and how we did some really cool stuff with Ghost's API to get it there.


Creating an Oauth application

Step zero to getting a Discord login working is creating an Oauth application on Discord. From there we establish what we want the Oauth URL to be. There are a few elements to an Oauth URL:

  • Client ID
  • Scope
  • Redirect URI
  • Response Type
  • Prompt

When creating the Discord application, we get the Client ID. The scopes we need are identify and email. These are both needed to create a member on Ghost. Prompt is simply a true/false value to ask if the user should always see the auth screen, we set this to False.


Creating backend to handle Oauth

The next step is to cover the Redirect URI. This is the page users are sent to when they carry out the Discord element of the Oauth flow. In our case, the URI is:

https://api.panleyent.com/displace/login/redirect

This URI is part of an existing backend, operating via a Flask server. A basic Oauth handler is placed on this endpoint, as seen below:

@app.route('/displace/login/redirect', methods = ['GET'])
def displaceloginghost():
    tokenurl = 'https://discord.com/api/oauth2/token'
    clientid = CLIENTID
    clientsecret = CLIENTSECRET
    redirurl = 'https://api.panleyent.com/displace/login/redirect'
    userurl = 'https://discord.com/api/v8/users/@me'
    codegrant = request.args.get('code')
    if codegrant:
        resp = requests.post(
        	tokenurl,
            headers = {
            	'content-type':'application/x-www-form-urlencoded'
            },
            data = {
            	'client_id': clientid,
            	'client_secret': clientsecret,
            	'grant_type': 'authorization_code',
            	'code': codegrant,
            	'redirect_uri': redirurl,
            	'scope': 'identify email'
        	}
        )
        resp.raise_for_status()
        if resp.json:
            userinfo = resp.json()
            auth = userinfo['token_type']+' '+userinfo['access_token']
            userinfo['expires_in'] += int(time.time())
            resp = requests.get(
            	userurl,
                headers = {
                	'Authorization':auth
                }
            )
            resp.raise_for_status()
            if resp.json:
                userinfo = resp.json()

At the end of this function, we have the users identify payload, which includes the users email, as provided by the email Oauth scope. We don't need to retain the token data for this use-case, so we don't keep that data. Now we have a URI that can carry out the Discord Oauth flow to the point we have an end users data, let's move onto the Ghost side of things!


Discovering how Ghost creates members

There's actually no public documentation on how Ghost creates members. This is because the endpoints it relies on are considered actively in development, and subject to change at any point. This also means that accessing the endpoints requires creating a token with access to canary endpoints. First, we'll get into how ghost actually handles user creation.

To do this, we'll monitor the Network tab while creating a member manually. We can see here that when a member is created, a POST request is sent to:

https://dat.place/ghost/api/canary/admin/members/

In your case, dat.place can be replaced with whatever domain your ghost blog is hosted on. The POST request contains a member object. The test object can be seen below:

{
    "members": [
        {
            "name": "test",
            "email": "test@gmail.com",
            "note": "",
            "subscriptions": [],
            "subscribed": true,
            "comped": false,
            "email_count": 0,
            "email_opened_count": 0,
            "email_open_rate": null,
            "products": [],
            "labels": []
        }
    ]
}

The important things for us here are email, name, note and labels. We'll use these when creating accounts to bind them to a Discord user's email and username & discriminator combo, as well as marking them as being created via Oauth using the labels field. The note field will be used to store parts of the Discord user object.


Working with the Ghost API

The Ghost API has a robust authentication system, relying on API keys consisting of a UUID and secret that are colon delimited. These are then used to generate a time-limited JWT used as an auth token. There are various different types of API key, the standard being those for integrations (accessiable and generateable via the in integrations tab). Notably, we'd need an API key with access to the admin API. However, for our use case we also need to give the token access to the canary API. The code snippit below shows how we generate these tokens:

GHOST_API_KEY = 'UUID:SECRET'
GHOST_ID, GHOST_SECRET = GHOST_API_KEY.split(':')
iat = int(time.time())

header = {
	'alg': 'HS256',
    'typ': 'JWT',
    'kid': GHOST_ID
}

payload = {
	'iat': iat,
	'exp': iat + 5 * 60,
	'aud': '/canary/admin/'
}

GHOST_TOKEN = jwt.encode(
	payload,
    bytes.fromhex(GHOST_SECRET),
    algorithm='HS256',
    headers=header
)

You can see here, the AUD field in the payload asks for access to /canary/admin/. Standard tokens will ask for a specific version number (currently v3 is cited in the docs). Using canary gives access to the canary version of the API, which as we can see from the eariler network tab explorations, is what Ghost uses to create members. Now we have our JWT auth token, we can place it in an authorisation header for out POST request to generate a member object!


Creating members & updating existing ones

So, we have all we need to create a member object on Ghost! Now all we need to do is plug in the Discord data right? Well, not quite. First, we need a system in place to make sure we're not creating dupliacte users, otherwise every time a user logs in with Discord, they'll create a new account. As you can imagine, this isn't a very good idea. To circumvent this, we'll bind Ghost members with the DiscordOauth tag to a Discord ID. To do this, when we create the member object on Ghost, we do it like so:

{
    "members": [
        {
            "name": userinfo["username"]+"#"+userinfo["discriminator"],
            "email": userinfo["email"],
            "note": json.dumps(
            	{
                	"id": userinfo["id"]
                }
            ),
            "subscriptions": [],
            "subscribed": true,
            "comped": false,
            "email_count": 0,
            "email_opened_count": 0,
            "email_open_rate": null,
            "products": [],
            "labels": ["DiscordOauth"]
        }
    ]
}

We can see here that the note contains dumped JSON, with the users Discord ID. It's kept as JSON for future expandability on keeping additional machine readable data within the Ghost member object. We could hash this data for extra privacy, however notes aren't public and in this case, contain no PII. We also apply the label DiscordOauth. We'll use this later to check for existing accounts. Speaking of, how do we search for users? Once again, the Chrome networking tab comes in handy!

We can see here that filters are applied in a querystring. We'll decode this URL and see the UTF-8 result is:

https://dat.place/ghost/api/canary/admin/members/?order=created_at desc&limit=50&page=1&filter=label:[discordoauth]&include=labels,emailRecipients

The most important aspect here is the filter. We'll inflate the limit to 1,000 which should mean we don't need to paginate which will make our lives easier when searching. The response from the search is as follows:

{
    "members": [
        {
            "id": "UID",
            "uuid": "UUID",
            "email": "test@gmail.com",
            "name": "test",
            "note": "{\"id\": \"90339695967350784\"}",
            "geolocation": "",
            "subscribed": true,
            "created_at": "2021-10-14T01:08:07.000Z",
            "updated_at": "2021-10-14T01:08:09.000Z",
            "labels": [
                {
                    "id": "61663238dd29f7000182bbd2",
                    "name": "DiscordOauth",
                    "slug": "discordoauth",
                    "created_at": "2021-10-13T01:11:20.000Z",
                    "updated_at": "2021-10-13T01:11:20.000Z"
                }
            ],
            "subscriptions": [],
            "avatar_image": "https://gravatar.com/avatar/...",
            "comped": false,
            "email_count": 0,
            "email_opened_count": 0,
            "email_open_rate": null,
            "status": "free"
        }
    ],
    "meta": {
        "pagination": {
            "page": 1,
            "limit": 50,
            "pages": 1,
            "total": 17,
            "next": null,
            "prev": null
        }
    }
}

From here it should be very simple to send a GET request to the established URL and iterate over the members returned. For each member, we can check if the note field is present and not blank, then attempt to load it as a JSON object. Once we have the JSON object from the note field, we can pull the ID and check it against the ID of the authed Discord user. If the ID matches, instead of sending a POST to create a new member, we can send a PUT to update the existing member (in case the user changes their Discord email or username/discriminator). We now have a system that allows an Oauthed Discord user to create or update a Ghost member object!


Logging in the Authed user

This next step is incredibly interesting. Usually, to log into a member on Ghost, you need to use your email and use a one-use time-limited link sent to that email. This is very high-friction for the user, so we'll supplant that. This means, once again, using the network tab. Ghost allows site administrators to 'impersonate' members natively. We'll use this button and inspect to see how the client gets these impersionation URLs. Using the same methods as before, we find that Ghost sends a GET request to:

https://dat.place/ghost/api/canary/admin/members/UID/signin_urls

However, when we attempt to use our integration API key to get this API route, we find that we're unauthorized. Integrations don't have access to impersonate users (for good reason as this would be a huge security concern, allowing anyone to gain access to a paid users account for example). So instead of using an integration key, we'll need to use a staff key. These are accessiable at the bottom of each staff members settings page. In our case, we'll take the staff key of the Owner account, just so we can be 100% sure it'll have access to all routes. We can now send a GET request, and get a login URL in response. Below is the code for checking if a member exists, updating/creating a member, then logging them in:

resp = requests.get(
    SEARCH_ENDPOINT,
    headers = {
    	'Authorization':f'Ghost {GHOST_TOKEN}',
        'Content-Type':CONTENT_TYPE
    }
)
resp.raise_for_status()
if resp.json:
    ghostusers = resp.json()
    existinguser = False
    for ghostuser in ghostusers['members']:
        print(str(ghostuser))
        if ghostuser['note']:
            if ghostuser['note'] != '':
                ghostuserid = ghostuser['id']
                ghostuserjson = json.loads(ghostuser['note'])
                if ghostuserjson['id'] == userinfo['id']:
                    existinguser = True
                    break
if existinguser:
    if ghostuserjson != userinfo:
        resp = requests.put(
        	f'{ACCOUNT_ENDPOINT}/{ghostuserid}',
        	headers = {
                'Authorization':f'Ghost {GHOST_TOKEN}',
            	'Content-Type':CONTENT_TYPE
            },
            json = GHOST_BODY
        )
        resp.raise_for_status()
else:
    resp = requests.post(
    	ACCOUNT_ENDPOINT,
        headers = {
            'Authorization':f'Ghost {GHOST_TOKEN}',
            'Content-Type':CONTENT_TYPE
        },
        json = GHOST_BODY
    )
    resp.raise_for_status()
    if resp.json:
        ghostuser = ((resp.json())['members'])[0]
        ghostuserid = ghostuser['id']
resp = requests.get(
	f'{ACCOUNT_ENDPOINT}{ghostuserid}/signin_urls',
    headers = {
        'Authorization':f'Ghost {GHOST_TOKEN}',
        'Content-Type':CONTENT_TYPE
    }
)
resp.raise_for_status()
if resp.json:
    url = (((resp.json())['member_signin_urls'])[0])['url']
    return redirect(url)
Tabs & Spacing may be inaccurate here as Ghost's code blocks are a bit janky

You can see at the end, we redirect the user to the URL we had generated from Ghost. This completes the Oauth flow and logs the Discord user in as a Ghost member! This system could easily be expanded to other Oauth or OpenID services.


Making the login button

The final step in making this all work is altering our Ghost theme to have a Discord login button. To make things easier, we'll replace the normal subscribe button in the theme header with a login button. This is very simple to do, and you can see the modifications here:

    <header id="gh-head" class="gh-head {{#if @site.cover_image}}has-cover{{/if}}">
        <nav class="gh-head-inner inner gh-container">
            <div class="gh-head-actions">
                {{#unless @member}}
                    <a class="gh-head-button"  href="OAUTH HERE">
                        Login via Discord
                    </a>
                {{else}}
                    <a class="gh-head-button" href="#/portal/account">
                        {{@member.name}}
                    </a>
                {{/unless}}
            </div>
        </nav>
    </header>

We can see here that if a member isn't logged in, the button references the Oauth URL we established earlier. If they are logged in, it'll display member's name and reference the inapp member popup innate in Ghost.


So, you've done it! But why?

Long story short, Ghost's login system is archaic. Oauth makes this flow 1-2 clicks, entirely in-browser. All navigation is automatic and the flow is much easier for a user to carry out. This means more email subscribers, easier login and when combined with making membership manual add only (which in this case, our script is effectively 'manually' adding members), means only users with a Discord account can subscribe to our service which helps limit spam and reduce Mailgun API usage. This system has countless benefits over the standard membership system innate in Ghost, and if expanded to other Oauth services, could bring Ghost in line with much more modernised frameworks.

Thanks for reading this article! Hopefully you've enjoyed the content above and found the deep dive into the Ghost API & Discord Oauth system informative!