Skip to content

Fix OAuth authorize 500: encode login next URL as JS, not HTML - #17

Merged
thorwhalen merged 1 commit into
mainfrom
fix/oauth-login-next-js-encoding
Aug 1, 2026
Merged

Fix OAuth authorize 500: encode login next URL as JS, not HTML#17
thorwhalen merged 1 commit into
mainfrom
fix/oauth-login-next-js-encoding

Conversation

@thorwhalen

Copy link
Copy Markdown
Member

Symptom

Completing the OAuth login for a remote MCP connector (Claude.ai) returned HTTP 500 at /auth/oauth/authorize right after sign-in — the browser saved the plain-text Internal Server Error body as an authorize.txt download.

Root cause

The sign-in page threads the post-login destination back through inline JS as const NEXT = "...", but built that literal with html.escape(). Inside a <script> element the browser does not decode HTML entities, so every & in the destination became a literal &amp;. For a multi-parameter next (the OAuth /authorize URL), the post-login redirect parsed ?response_type=code&client_id=X&redirect_uri=Y as params response_type, amp;client_id, amp;redirect_uri — so client_id arrived empty. The file-backed client store then resolved the empty key to its own root directory (IsADirectoryError) → 500.

A plain-path next (no &) hid this for normal app logins; the MCP OAuth flow is the first multi-param next to exercise it.

Fix

  • pages.py: new _js_string() encodes next_url as a proper JS string literal (JSON-encoded, with &/</> as \uXXXX so it's inert in the HTML tokenizer but decodes back at runtime). Applied to both the standard and shared login pages.
  • oauth_server.py: defense-in-depth — a blank/missing client_id short-circuits to the clean 400 "unknown client" page instead of hitting the store and 500ing.
  • Tests: new test_pages.py (JS-encoding preserves query params, no </script> breakout, no HTML-escaping into JS) + a blank-client_id 400 regression in test_oauth_server.py. Suite 195 → 200 passing.

https://claude.ai/code/session_01Gw5RPgrQhC88Hc3DyACYWF

The sign-in page threads the post-login destination back through inline JS
(`const NEXT = "..."`), but built that literal with `html.escape()`. Inside a
`<script>` element the browser does not decode HTML entities, so every `&` in
the destination URL became a literal `&amp;`. For a multi-parameter `next`
(the OAuth `/authorize` URL), the redirect after login parsed
`?response_type=code&client_id=X&redirect_uri=Y` as params `response_type`,
`amp;client_id`, `amp;redirect_uri` — dropping the real names. `client_id` thus
arrived empty at `/authorize`, and the file-backed client store resolved the
empty key to its root directory (IsADirectoryError) → HTTP 500. A plain-path
`next` (no `&`) hid the bug; the MCP OAuth flow is the first multi-param `next`.

- pages.py: new `_js_string()` encodes `next_url` as a JS string literal
  (JSON + &/</> so it's inert to the HTML tokenizer yet decodes
  back at runtime). Applied to both the standard and shared login pages.
- oauth_server.py: a blank/missing client_id now short-circuits to the clean
  400 "unknown client" page instead of hitting the store and 500ing.
- tests: new test_pages.py (JS-encoding + no-breakout + no HTML-escape) and a
  blank-client_id 400 regression in test_oauth_server.py. Suite 195 -> 200.

Claude-Session: https://claude.ai/code/session_01Gw5RPgrQhC88Hc3DyACYWF
@thorwhalen
thorwhalen merged commit 9414999 into main Aug 1, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant