diff --git a/cloudflare/workers-github-oauth.md b/cloudflare/workers-github-oauth.md new file mode 100644 index 000000000..707d28846 --- /dev/null +++ b/cloudflare/workers-github-oauth.md @@ -0,0 +1,161 @@ +# GitHub OAuth for a static site using Cloudflare Workers + +My [tools.simonwillison.net](https://tools.simonwillison.net/) site is a growing collection of small HTML and JavaScript applications hosted as static files on GitHub Pages. + +Many of those tools take advantage of external APIs such as those provided by OpenAI and Anthropic and Google Gemini, thanks to the increasingly common `access-control-allow-origin: *` [CORS header](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). + +I want to start building tools that work with [the GitHub API](https://docs.github.com/en/rest), in order to implement things like saving data to a [Gist](https://gist.github.com/) + +To do that, I needed to implement [OAuth](https://docs.github.com/en/apps/oauth-apps): redirecting users to GitHub to request permission to access their data and then storing an access token in their browser's `localStorage` to be used by JavaScript running on my site. + +There is just one catch: it currently isn't possible to implement GitHub OAuth entirely from the client, because that API depends on a secret that must be held server-side and cannot be exposed. + +This morning, I had an idea: my tools site is [hosted by GitHub Pages](https://github.com/simonw/tools), but it's served via my [Cloudflare](https://www.cloudflare.com/) account for the `simonwillison.net` domain. + +Could I spin up a tiny [Cloudflare Workers](https://workers.cloudflare.com/) server-side script implementing GitHub OAuth and add it to a path on that `tools` subdomain? + +The answer turned out to be yes. + +## Getting Claude to write me a Worker + +I prompted [Claude](https://claude.ai/) with the following: + +> `Write a Cloudflare worker that implements an oauth flow with GitHub to get a token scoped for gist read and write only` +> +> `Landing on the worker page redirects to GitHub for the oauth - GitHub sends back to the same page, which then outputs a script block that sets the access key in localstorage` + +This is the simplest possible design for an OAuth flow: send the user straight to GitHub, then exchange the resulting `?code=` for an access token and write that to `localStorage`. + +Here's [the full Claude transcript](https://gist.github.com/simonw/975b8934066417fe771561a1b672ad4f). Claude gave me almost *exactly* what I needed - the only missing detail was that it set the `redirectUri` to `url.origin` (just the site domain) when it should have been the full URL to the worker page. + +I edited the code to its final version, which looked like this: +```javascript +export default { + async fetch(request, env) { + const url = new URL(request.url); + const clientId = env.GITHUB_CLIENT_ID; + const clientSecret = env.GITHUB_CLIENT_SECRET; + const redirectUri = env.GITHUB_REDIRECT_URI; + + // If we have a code, exchange it for an access token + if (url.searchParams.has('code')) { + const code = url.searchParams.get('code'); + + // Exchange the code for an access token + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code: code, + redirect_uri: redirectUri + }) + }); + + const tokenData = await tokenResponse.json(); + + // Return HTML that stores the token and closes the window + return new Response(` + + + + GitHub OAuth Success + + + + + + `, { + headers: { + 'Content-Type': 'text/html' + } + }); + } + + // If no code, redirect to GitHub OAuth + const githubAuthUrl = new URL('https://github.com/login/oauth/authorize'); + githubAuthUrl.searchParams.set('client_id', clientId); + githubAuthUrl.searchParams.set('redirect_uri', redirectUri); + githubAuthUrl.searchParams.set('scope', 'gist'); + githubAuthUrl.searchParams.set('state', crypto.randomUUID()); + + return Response.redirect(githubAuthUrl.toString(), 302); + } +}; +``` +I find it hard to imagine a simpler implementation of this pattern. + +The GitHub API has been around for a very long time, so it shouldn't be surprising that Claude knows exactly how to write the above code. I was still delighted at how much work it had saved me. + +(I should mention now that I completed this entire project on my phone, before I got up to make the morning coffee.) + +## Deploying this to Cloudflare Workers + +There are four steps to deploying this: + +1. Configuring a GitHub OAuth application to get a client ID and secret +2. Create a new worker with the code +3. Set the three variables it needs +4. Configure the correct URL to serve the worker + +I created the GitHub OAuth app here: https://github.com/settings/applications/new + +The most important thing to get right here is the "Authorization callback URL": I set that to `https://tools.simonwillison.net/github-auth` - a URL that didn't exist yet but would after I deployed the Worker. + +Then in my Cloudflare dashboard, I navigated to Workers & Pages and clicked "Create" and then "Create Worker". + +I deployed the default "Hello world" example and edited it to paste in the code that Claude had written for me. + +The Cloudflare editing UI was *not* built with mobile phones in mind, but I just managed to paste in my code with it! + +The Cloudflare workers editing UI looks awful in Mobile Safari - a very narrow column with some visible code and half the page taken up with extra settings. + +I used the "settings" page to set the `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` and `GITHUB_REDIRECT_URI` environment variables needed by the Worker. + +The last step was to configure that URL. I navigated to my `simonwillison.net` Cloudflare dashboard, hit "Workers Routes" and then added a new route mapping `tools.simonwillison.net/github-auth*` to the Worker I had just created. + +That final `*` wildcard turned out to be necessary to ensure that `?code=` querystring URLs would also activate the `/github-auth` worker - without that I got a 404 served by GitHub Pages for those URLs instead. + +## Using this from an application + +The OAuth flow works by setting a `github_token` key in `localStorage()` for the entire `tools.simonwillison.net` domain - which means JavaScript on any page can check for that key and make API calls to the GitHub Gist API if the key is present. + +Here's some more code [Claude wrote](https://gist.github.com/simonw/29efb202da39775761ab6ab498d942ca) for integrating the new auth mechanism into an existing tool: + +```javascript +function checkGithubAuth() { + const token = localStorage.getItem('github_token'); + if (token) { + authLinkContainer.style.display = 'none'; + saveGistBtn.style.display = 'inline-block'; + } else { + authLinkContainer.style.display = 'inline-block'; + saveGistBtn.style.display = 'none'; + } +} + +function startAuthPoll() { + const pollInterval = setInterval(() => { + if (localStorage.getItem('github_token')) { + checkGithubAuth(); + clearInterval(pollInterval); + } + }, 1000); +} + +authLink.addEventListener('click', () => { + window.open('https://tools.simonwillison.net/github-auth', 'github-auth', 'width=600,height=800'); + startAuthPoll(); +}); +``` + +`authLink` here is a reference to a "Authenticate with GitHub" link on that page. Clicking that link uses `window.open` to open a popup showing my new `/github-auth` page, which then automatically redirects to GitHub for permission. + +Clicking the link also starts a every-second poll to check if `github_token` has been set in `localStorage` yet. As soon as that becomes available the polling ends, the "Authenticate with GitHub" UI is hidden and a new `saveGistBtn` (a button to save a Gist) is made visible.