Finish GitHub's OAuth flow server-side, against a state - #638
Merged
Merged
Conversation
The flow used to end in the browser: the login page read ?code= out of the query and posted it to /api/login, which exchanged it and set the session. Nothing tied the code to the visitor who started the flow, so a code obtained for one account could be replayed into another visitor's browser and leave them signed in as somebody else. /login/github now mints an unguessable state, keeps it and the intended destination in the session, and sends the state to GitHub. GitHub's redirect_uri is already /login, so its return lands in loginHandler, which hands it to GithubCallback rather than rendering the page. The code is exchanged only if the state comes back unchanged, and the state is spent either way, so the same code and state cannot be presented twice. /api/login is gone. It was the hole rather than the page: it took a bare code as a form post and set the session from it, and requireJSON deliberately exempted it. SameSite=Lax does not help — it governs sending a cookie cross-site, not setting one — so a cross-site form could plant a session without the login page being involved at all. Nothing accepts a raw code now. #637 proposed a new /login/github/callback route, which would have changed redirect_uri and required the callback registered in each site's GitHub OAuth app to be updated. Handling the return at /login, where redirect_uri already points, gives the same property with no operational step. Along the way: - redirect_uri is constant now. GitHub matches it against the registered callback, so a value that does not vary is likelier to match; next rides in the session instead, which also keeps it out of a URL handed to a third party and logged. - requestAccessToken no longer fails outright when .env is missing. A deployment may set client_id and client_secret in the environment; what matters is having both, which it now checks. - The themes drop the ?code= block, which can no longer fire, and post the email-login endpoints relatively rather than rebuilding an origin. The callback tests are internal so they can point the token endpoint at a stand-in. That matters: asserting only that no session was created passes even with the state check removed, because an exchange that never reaches GitHub fails anyway. What separates a rejected callback from a failed one is whether the token endpoint was called at all, and with the check bypassed these fail with "a mismatched state logged someone in". Verified over HTTP against a local instance: a wrong state, and a code handed to a browser that never started a login, are both refused before any exchange; a matching state reaches the exchange; replaying it is refused; and POST /api/login is 404. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
A newly added test (TestNoRawOAuthCodeEndpoint) will fail due to an over-broad substring check that matches existing /api/login/email* routes.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
This PR hardens the GitHub OAuth login flow by moving the authorization-code exchange fully server-side and binding it to an unguessable state stored in the session, closing a login CSRF/session-swapping gap described in #637.
Changes:
- Add server-minted OAuth
statestored in the session on/login/github, and validate/spend it on GitHub’s return to/loginbefore exchanging the code. - Remove the raw OAuth-code ingestion endpoint (
POST /api/login) and update the default theme to stop posting?code=from the browser. - Add targeted tests (including internal tests with a fake token endpoint) to prove mismatched/missing state never reaches the token exchange.
| File | Description |
|---|---|
| themes/default/templates/login.html | Removes browser-side GitHub callback posting; makes email-login posts relative. |
| goblog.go | Routes GitHub callback handling through /login server-side; removes /api/login route. |
| csrf_test.go | Drops /api/login from requireJSON tests; adds a guard test intended to ensure the endpoint stays removed. |
| blog/github_login_test.go | Updates authorize URL tests and adds session-backed login initiation/state assertions. |
| blog/blog.go | Makes redirect_uri constant, adds state to authorize URL, and stores state/next in session on /login/github. |
| auth/github_oauth.go | Introduces GithubCallback that validates/spends state and completes login server-side. |
| auth/github_oauth_internal_test.go | Adds internal tests with a fake GitHub server to assert state mismatch prevents token exchange. |
| auth/auth.go | Refactors login exchange into completeGithubLogin, hardens env loading, and makes GitHub endpoints overridable for tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Review read the check as matching /api/login/email. It does not: the search string carries the closing quote, so "/api/login/email" is not a match — grep -c '"/api/login"' over goblog.go finds none, and the test passes. Kept the broad check, which catches the route however it is registered, and said why in a comment. The OTP routes are now asserted positively as well, so the distinction is demonstrated rather than argued, and so removing the OAuth route cannot quietly take email login with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Fixes #637.
The gap
GitHub's OAuth flow finished in the browser: the login page read
?code=from the query and posted it to/api/login, which exchanged it and set the session. Nothing tied the code to the visitor who started the flow, so a code obtained for one account could be replayed into another visitor's browser, leaving them signed in as somebody else.The fix
/login/githubmints an unguessable state, keeps it and the intended destination in the session, and sends the state to GitHub. GitHub'sredirect_uriis already/login, so its return lands inloginHandler, which hands it toGithubCallbackinstead of rendering the page. The code is exchanged only if the state comes back unchanged, and the state is spent either way./api/loginis gone — it was the hole, not the page:It took a bare code as a form post and set the session from it, and
requireJSONdeliberately exempted it.SameSite=Laxdoesn't help — it governs sending a cookie cross-site, not setting one — so a cross-site form could plant a session without the login page being involved at all. Nothing accepts a raw code now.No registered-callback change after all
#637 proposed a new
/login/github/callbackroute, which would have changedredirect_uriand required the callback registered in each site's GitHub OAuth app to be updated — an operational step on every deployment. Handling the return at/login, whereredirect_urialready points, gives the same security property with no such step. My write-up in the issue assumed the new route was necessary; it isn't.Along the way
redirect_uriis constant now. GitHub matches it against the registered callback, so a value that doesn't vary is likelier to match.nextrides in the session instead, which also keeps it out of a URL handed to a third party and written to logs.requestAccessTokenno longer fails outright when.envis missing. A deployment may setclient_id/client_secretin the real environment; what matters is having both, which it now checks.?code=block and post the email-login endpoints relatively rather than rebuilding an origin.The tests needed a second attempt
My first version asserted only that no session was created on a bad state. Those passed with the state check removed — with no GitHub to talk to, the exchange fails anyway, so they proved nothing.
They're internal tests now, pointing the token endpoint at a stand-in, so they can assert on what actually distinguishes a rejected callback from a failed one: whether the token endpoint was reached at all. With the check bypassed they fail properly:
TestGithubCallback_HappyPathpins the other direction — a matching state does exchange and lands on the storednext— so the rejection tests mean something.TestNoRawOAuthCodeEndpointguards the removed route.Verified over HTTP against a local instance
302 /login?error=github, no exchange302 /login?error=github, no exchangeerror requesting token access— no real GitHub)state did not match the session; ignoring the codePOST /api/login404Merge order
Same as the last round: this merges and releases first, then the theme PRs, which now carry this change too — goblogplatform/goblog-site-theme#12, goblogplatform/goblog-theme-forest#6, goblogplatform/goblog-theme-minimal#4.
Un-updated themes keep working either way: the server handles
/login?code=before the page renders, so their?code=script never fires and never reaches the removed endpoint.🤖 Generated with Claude Code