Skip to content

oauth: skip OAuth validation when serviceAuths already authenticated request#58

Closed
yjp20 wants to merge 2 commits into
mainfrom
youngjin/skip-oauth-when-service-authed
Closed

oauth: skip OAuth validation when serviceAuths already authenticated request#58
yjp20 wants to merge 2 commits into
mainfrom
youngjin/skip-oauth-when-service-authed

Conversation

@yjp20
Copy link
Copy Markdown
Member

@yjp20 yjp20 commented Apr 28, 2026

Problem

When an MCP server is configured with both the global OAuth provider (proxy.auth) and per-server serviceAuths, the middleware chain in internal/mcpfront.go:404-419 is built like this:

[mcpLogger, corsMiddleware, OAuthValidate, ServiceAuth, mcpRecover]

ChainMiddleware wraps last-wraps-first, so the actual execution order is:

mcpRecover -> ServiceAuth -> OAuthValidate -> corsMiddleware -> mcpLogger -> handler

A request with Authorization: Basic <creds matching serviceAuths> flows like this:

  1. NewServiceAuthMiddleware matches the credentials, sets servicecontext.WithAuthInfo, calls next.ServeHTTP(...).
  2. NewValidateTokenMiddleware runs next. It looks at the Authorization header, sees Basic instead of Bearer, and returns 401 with Invalid authorization header format and an RFC 9728 WWW-Authenticate: Bearer challenge — without consulting the service-auth context that ServiceAuth just set.

Net effect: configuring serviceAuths.basic on a server with global OAuth made Basic auth unauthenticatable, even when the credentials are correct. Same issue applies to serviceAuths.bearer tokens that aren't valid OAuth access tokens.

integration/basic_auth_test.go doesn't catch this because it passes nil for the auth config, so authServer == nil and OAuth middleware is never installed.

Fix

In NewValidateTokenMiddleware, short-circuit when service auth has already authenticated the request:

if _, ok := servicecontext.GetAuthInfo(ctx); ok {
    next.ServeHTTP(w, r)
    return
}

Symmetrically, OAuth-authenticated requests already pass through NewServiceAuthMiddleware cleanly via the existing oauth.GetUserFromContext check at the top of that middleware.

…request

When a server is configured with both the global OAuth provider and per-server
serviceAuths, the middleware chain is ordered (last-wraps-first via
ChainMiddleware) so ServiceAuth runs before OAuthValidate:

    mcpRecover -> ServiceAuth -> OAuthValidate -> ... -> handler

A request with `Authorization: Basic <creds matching serviceAuths>` would
succeed in NewServiceAuthMiddleware (which sets servicecontext on the
context and calls next.ServeHTTP), then immediately be rejected by
NewValidateTokenMiddleware with "Invalid authorization header format"
because the Basic header isn't a Bearer token. Effectively, configuring
serviceAuths.basic on a server with global OAuth made Basic auth
unauthenticatable.

Fix: have NewValidateTokenMiddleware skip OAuth validation when service
auth has already authenticated the request (servicecontext.GetAuthInfo
returns ok). Symmetrically, OAuth-authenticated requests still pass
through ServiceAuth via the existing oauth.GetUserFromContext check at
the top of NewServiceAuthMiddleware.

Adds an integration test (oauth_with_service_auth_test.go) covering both
the bearer-token and basic-auth paths against a server with global OAuth
configured.
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a bypass in the OAuth validation middleware for requests already authenticated via service-specific credentials and includes integration tests for this behavior. However, the current middleware implementation may cause service authentication to block standard OAuth tokens if it precedes the validator in the chain. It is recommended to adjust the middleware logic and add a test case to validate that standard OAuth functionality is preserved in this configuration.

Comment thread internal/oauth/provider.go
Comment thread integration/oauth_with_service_auth_test.go
Verifies that a valid OAuth access token (minted via the full
authorization-code flow against the fake Google IDP) still authenticates
successfully when the same server has serviceAuths configured.
Symmetric to the bearer/basic service-auth cases — ensures ServiceAuth
doesn't inadvertently block legitimate OAuth requests.
@yjp20 yjp20 requested a review from dgellow April 28, 2026 21:09
@dgellow
Copy link
Copy Markdown
Contributor

dgellow commented Apr 29, 2026

Thanks young-jin! If you don't mind I will instead open another PR to fix the general issue of auth composition. That's what I see as the root cause here

@dgellow dgellow closed this Apr 29, 2026
dgellow added a commit that referenced this pull request Apr 29, 2026
Closes #58.

When a server is configured with both global OAuth and per-server
`serviceAuths`, the two authenticators previously fought each other:
whichever ran first rejected anything it didn't recognize, even when the
other one would have accepted it. Configuring service tokens alongside
OAuth meant either OAuth users or service callers worked, never both,
depending on chain order. The PR that closed the original report fixed
one direction; this branch fixes both.

Beyond that, service auth requests had no real downstream identity. They
surfaced in logs as `user=""`, slipped past per-user session and
connection limits, and weren't tracked in storage. To audit or
rate-limit them you'd have had nothing to key on. Each `serviceAuths`
entry now carries a `name` (required for bearer, optional for basic —
defaults to the username) that resolves to a per-server identity. That
identity flows through every place that already used the OAuth user's
email: logs, session keys, limit pools, audit trails. A bearer token
shared across many CI runners shares one identity by design; separate
entries get separate pools.

A few security items got picked up along the way. Basic auth now runs
bcrypt against a dummy hash on no-match, so response timing no longer
reveals which usernames exist. Empty bearer tokens and empty basic
passwords are rejected at config load — closes the case where a
misconfigured `{"$env":"VAR"}` would silently authenticate
`Authorization: Bearer ` (no token). The identity domain used for
service auth is reserved (RFC 9476's `.alt`); the OAuth flow refuses any
IDP-claimed email in that domain, so a real user can't impersonate a
service identity.

Existing configs with bearer entries need a one-line addition (`"name":
"..."`) — there's no sensible default for bearer since the array of
tokens has no admin-visible label.
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.

2 participants