Skip to content

fix: refuse a disallowed email domain with a plain 403, not a step-up - #61

Merged
marselsel merged 1 commit into
mainfrom
fix/plain-403-for-refused-domain
Sep 22, 2026
Merged

marselsel merged 1 commit into
mainfrom
fix/plain-403-for-refused-domain

Conversation

@marselsel

@marselsel marselsel commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Part 3 of 8 in the MCP best-practices stack. Stacked on #60, so merge in order.

Why

When a user signs in with an email domain that isn't in OAUTH_ALLOWED_EMAIL_DOMAINS, the server answered with a 403 plus WWW-Authenticate: Bearer error="insufficient_scope". Claude's lazy-authentication doc says: "A 403 triggers re-authentication only when accompanied by WWW-Authenticate: Bearer error="insufficient_scope" for scope step-up; any other 403 is surfaced as a terminal error."

So a refused user was sent back through sign-in, which can't change their email domain, and ended up in a loop instead of being told they're not allowed. The old code comment claimed this "terminates cleanly", which was wrong for Claude.

Changes

  • Authentication: createAccessTokenVerifier now only authenticates the token and records the verified email in extra.email. The SDK bearer gate still answers 401 with a challenge when the token is missing or invalid.
  • Authorization: a new requireAllowedEmailDomain middleware answers a disallowed domain with a plain 403 ({"error":"access_denied"}) and no challenge. It fails closed when req.auth is missing.
  • oauthGate(...): returns both handlers together, so server.ts can't mount one without the other.

Verification

  • The domain tests now run through real HTTP, asserting the status and the WWW-Authenticate header together:
    • a missing or bad token gets 401 with the challenge
    • a disallowed domain gets 403 with no challenge
    • an allowed domain and an empty allow-list both pass
    • an unverified claim, an unverified userinfo email, or a failed userinfo lookup are refused
    • userinfo misses are not cached
    • the middleware refuses when mounted without the verifier
  • npm test passes all 470 tests.

The domain refusal was a 403 with error="insufficient_scope", which
Claude treats as a scope step-up and answers by sending the user back
through sign-in, so a refused user looped. Claude surfaces only a 403
without that challenge as a terminal error.

The verifier now only authenticates and establishes the verified email;
requireAllowedEmailDomain makes the decision and answers a plain 403.
Both are mounted through oauthGate, so they cannot be wired apart, and
the domain check fails closed without req.auth. Tests go through real
HTTP to pin status and WWW-Authenticate together.
@marselsel
marselsel changed the base branch from feat/server-instructions to main September 22, 2026 21:57
@marselsel
marselsel merged commit 57ef91a into main Sep 22, 2026
2 checks passed
@marselsel
marselsel deleted the fix/plain-403-for-refused-domain branch September 22, 2026 21:57
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