Skip to content

Make service auth a first-class identity alongside OAuth#59

Merged
dgellow merged 5 commits into
mainfrom
sam/auth-trier-gate
Apr 29, 2026
Merged

Make service auth a first-class identity alongside OAuth#59
dgellow merged 5 commits into
mainfrom
sam/auth-trier-gate

Conversation

@dgellow
Copy link
Copy Markdown
Contributor

@dgellow dgellow commented 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.

dgellow added 3 commits April 29, 2026 15:48
When a server is configured with both global OAuth and per-server
serviceAuths, each authenticator independently rejects requests it
doesn't recognize, so chaining them composes failures rather than
successes. Either ServiceAuth eats valid OAuth tokens, or OAuth eats
valid serviceAuths credentials, depending on chain order.

Each authenticator becomes non-rejecting — sets context on success,
passes through otherwise. A single RequireAuth gate at the end of the
chain produces the 401 with the appropriate WWW-Authenticate (RFC 9728
Bearer when OAuth is enabled, Basic realm otherwise). Any one
configured method succeeding now satisfies the route, regardless of
chain order or which other methods are also configured.
Skipping bcrypt when the username didn't match a configured entry let an
attacker enumerate valid usernames by measuring response time —
configured users took ~50ms, unknown users returned in microseconds.

Always run bcrypt against either the matched entry's hash or a dummy
hash generated once at construction time. Authentication only succeeds
when both the username matched and bcrypt verified the password.
Without this, configuring `tokens: [""]` lets `Authorization: Bearer `
(scheme with no token) authenticate against the empty entry.
gemini-code-assist[bot]

This comment was marked as outdated.

Symmetric to the empty bearer token check. Without this, a literal
`password: ""` in the config would let `Authorization: Basic <base64("user:")>`
authenticate. Env var references are already rejected upstream by
ParseConfigValue when the variable is unset or empty, so this check
specifically catches the explicit-empty-string case.
@dgellow dgellow changed the title Sam/auth trier gate Make service auth a first-class identity alongside OAuth Apr 29, 2026
@dgellow dgellow marked this pull request as ready for review April 29, 2026 16:16
@dgellow dgellow requested a review from yjp20 April 29, 2026 16:16
@dgellow
Copy link
Copy Markdown
Contributor Author

dgellow commented Apr 29, 2026

/gemini review

gemini-code-assist[bot]

This comment was marked as outdated.

ServiceAuth requests showed up in logs as user="" and bypassed all
per-user limits, because nothing populated the user identity for them.

Each entry now has a `name` (required for bearer, defaults to username
for basic) and resolves to a per-server identity like
`<server>.<name>@serviceauth.mcpfront.alt`. Logs, session keys, and
per-user limits all see this identity the same way they see an OAuth
user's email.

Names must be unique within a server's serviceAuths. The reserved
domain is rejected if an OAuth identity provider ever returns it, so a
real user can't impersonate a service identity.
@dgellow dgellow force-pushed the sam/auth-trier-gate branch from 427e3b5 to 043c9ff Compare April 29, 2026 16:30
@dgellow dgellow enabled auto-merge (squash) April 29, 2026 16:31
@dgellow dgellow merged commit a58465d into main Apr 29, 2026
2 checks passed
@dgellow dgellow deleted the sam/auth-trier-gate branch April 29, 2026 16:34
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