e2e: a loopback provider's credential is never renewed, and why - #259
Merged
Conversation
The OAuth e2e covered obtaining a credential and never keeping one — the existing fixture issues an hour-long token, so no test reached either gateway renewer. Covering that turned up the reason it had not been: signing in and staying signed in disagree about the same server. `auth login --allow-local` and `server add --local` are honoured by both login paths, which derive AllowLoopback from provenance; neither renewal path can, because gateway/auth.go builds the oauthflow client with the flag unset and its factory never sees the entry. The screen refuses before making a request, so the authorization server is never contacted. What that costs a user is a self-hosted OAuth server that works after login and stops at the first expiry, permanently, with one WARN in the gateway log. What it costs this suite is the success path: the only authorization server a test may run is on loopback, and TLS does not help because the carve-out is gated on AllowLoopback whatever the scheme. The new case pins the refusal instead, asserting that the token endpoint is not reached at all rather than reached and turned away — a distinction the counter can see and a log line cannot. docs/modules/oauth.md carries the asymmetry as gap 5, including that the trade is open: the screen exists because a loopback OAuth endpoint is usually a misconfiguration, and --local is exactly the operator saying this one is not. Changing that is a security-control decision and is deliberately not taken here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
The OAuth suite covered getting a credential and not keeping one:
oauthlogin_test.goissues an hour-long token, so nothing in it reaches therenewal machinery. Renewal is where the failure a user actually meets lives —
a login is verified the minute it is performed, while a refresh that does not
work presents days later as a server that worked yesterday.
Trying to cover it turned up why it was uncovered. Signing in and staying
signed in disagree about the same server.
--allow-localandprovenance=localare honoured by both login paths; no renewal path has anequivalent, so the oauthflow screen refuses the refresh — for both gateway
renewers and the daemon's — before it makes a request.
The user-visible consequence: a self-hosted OAuth MCP server works after login
and stops the moment its token expires, permanently, with only a WARN in the
gateway log. It also bounds this suite — no e2e can demonstrate a successful
refresh, because the only authorization server a test may run is on loopback
and TLS does not help.
So this lands the refusal as a pinned, fail-closed property, and records the
asymmetry in
docs/modules/oauth.mdwhere someone looks for it. It does notchange the security control — that is a decision to take deliberately, not
in passing.