8000 TOML config file, MCP OAuth and Tailscale exposure by adeelahmad · Pull Request #15 · adeelahmad/Tether · GitHub
[go: up one dir, main page]

Skip to content

TOML config file, MCP OAuth and Tailscale exposure - #15

Merged
adeelahmad merged 2 commits into
mainfrom
claude/toml-config-jd8ene
Oct 11, 2026
Merged

adeelahmad merged 2 commits into
mainfrom
claude/toml-config-jd8ene

Conversation

@adeelahmad
@adeelahmad adeelahmad commented Oct 11, 2026 •
Copy link
Copy Markdown
Owner

Requested by Adeel · project thread

What

Before: every setting was a flag or TETHER_* variable, and per-token rules needed a separate JSON policy file. MCP clients that only sign in with OAuth (claude.ai connectors, some IDEs) could not connect. Exposing Tether on a tailnet meant hand-writing --addr "$(tailscale ip -4):8787".

After: tether serve also reads a TOML config file (--config, $TETHER_CONFIG, or ~/.config/tether/config.toml when it exists). Precedence is flag > env > file > default. The file covers:

  • Binding and auth: [server] addr / insecure_allow_public / public_url, [auth] token / token_env / token_file, plus [[auth.tokens]] with the same per-token rules as --policy-file. --policy-file keeps working and now also accepts .toml.
  • Allow and deny lists: [policy] allow / deny.
  • Headers: [headers].extra is sent by Chrome on every request from Tether's tabs. [headers].strip lists client headers the proxy drops.
  • Cookies: [[cookies]] are seeded into the browser's cookie jar at startup. Values can come from env via value_env.
  • Chrome profile: [chrome].profile / --chrome-profile picks a profile inside the user data dir when launching.
  • Console capture: [console] enabled / buffer.
  • MCP OAuth: [mcp.oauth] / --mcp-oauth turns on a built-in OAuth 2.1 authorization server. It provides metadata discovery, dynamic registration, mandatory PKCE S256, refresh tokens, and optional pre-registered clients.
  • Tailscale: [tailscale] / --tailscale listens on this machine's tailnet IP. publish = "serve" | "funnel" (--tailscale-publish) publishes over HTTPS with the installed tailscale CLI and removes the publish again on shutdown. Publishing also sets public_url to the *.ts.net name for OAuth. Both options need a token.

tether.example.toml is a full commented example, and docs/CONFIG.md is the reference.

Why

Adeel asked for a TOML config covering auth, binding, headers, cookies, allow/deny lists, MCP OAuth, Chrome profile, console capture and Tailscale.

How

  • internal/config/file.go decodes the file strictly: unknown keys are errors. It turns the file into flag defaults that sit below env and explicit flags, and resolves the file-only settings. Relative paths are resolved against the file's directory.
  • internal/oauth keeps almost no state. Dynamic clients, access tokens and refresh tokens are HMAC-signed with <data_dir>/oauth.key (0600), so they survive restarts. Auth codes are in memory and single use. The consent page asks for a Tether token, and the grant acts as that token's principal. Each grant is bound to a keyed fingerprint of that token, so removing or rotating the token kills the grant.
  • In internal/server, the OAuth endpoints are served before auth. OAuth access tokens are accepted under /mcp only. A 401 on /mcp carries WWW-Authenticate: Bearer … resource_metadata=….
  • internal/tailnet shells out to the tailscale CLI: status --json, serve|funnel --bg, and … off on shutdown. If the CLI is missing, the bind falls back to scanning the network interfaces. It embeds no Tailscale code, because current tailscale.com/tsnet requires a much newer Go than this module targets.
  • isHTTPS / sameOrigin moved to httpx so the remote UI and the consent page share them.

How tested

  • gofmt, go vet, golangci-lint run (0 issues), go test -race ./...
  • make test-integration against headless Chromium, including a new test showing configured headers and cookies reach a real Chrome and stripped headers don't.
  • Smoke runs of the binary:
    • TOML config with launch, profile and OAuth: /mcp 401 points at the metadata, discovery works, and oauth.key is created.
    • --tailscale-publish serve against a stand-in CLI: it publishes on start, removes the publish on shutdown, and the issuer becomes the ts.net name.
  • Docs updated (README, docs/CONFIG.md, MCP.md, POLICIES.md, SECURITY.md, CHANGELOG)

Security

  • No new way to reach the browser without auth.
    • OAuth and Tailscale publish both require a configured token, enforced at startup.
    • The consent page needs that token, accepts only same-origin posts, and delays wrong guesses.
    • OAuth tokens open /mcp only, and redirect URIs must be https, loopback http, or an app scheme.
    • --tailscale binds a non-loopback address, so the existing token rule applies.
    • Funnel logs a public-exposure warning.
    • Nothing new is logged that holds a cookie or token.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X2DYcpGzmzj1fmtyBvzsuj

Add a TOML config file (--config, $TETHER_CONFIG, or config.toml in the
per-user Tether directory) covering binding, auth and per-token rules,
allow/deny lists, console capture, the MCP and remote faces, plus
settings only the file can express: headers Chrome sends from Tether's
tabs, client headers the proxy strips, cookies seeded at startup, and
the Chrome profile to launch. Precedence is flag > env > file > default.

Add a built-in OAuth 2.1 authorization server for the MCP face
(metadata discovery, dynamic registration, PKCE, refresh tokens), so
OAuth-only MCP clients can connect. The consent page asks for a Tether
token; OAuth tokens act as it and open /mcp only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2DYcpGzmzj1fmtyBvzsuj
@adeelahmad adeelahmad self-assigned this Oct 11, 2026
Comment thread 8000 internal/oauth/flow.go
}
q.Set("iss", s.base(r))
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusSeeOther)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This redirect is the OAuth authorization response, so it has to go to a client-supplied URI. It isn't an open redirect.

redirect() only runs after parseAuthRequest has matched redirect_uri against the client's registered URIs. That match is exact, except that loopback ports are port-agnostic as RFC 8252 requires. An unknown client or an unregistered URI renders an error page and never redirects. Registered URIs are limited to https, loopback http, or an app scheme (checkRedirectURI). A code is only issued after the person enters a valid Tether token. This is the same constraint OAuth 2.1 §4.1.1 imposes, so I'm leaving the code as is. I suggest dismissing the alert as "won't fix / used by design".


Generated by Claude Code

[tailscale] (and --tailscale / --tailscale-publish) uses the installed
tailscale CLI: listen on this machine's tailnet address, and optionally
publish over HTTPS with `tailscale serve` or `tailscale funnel`, removed
again on shutdown. Publishing sets public_url to the tailnet HTTPS name
for MCP OAuth. Both need a token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2DYcpGzmzj1fmtyBvzsuj
@adeelahmad adeelahmad changed the title TOML config file and MCP OAuth TOML config file, MCP OAuth and Tailscale exposure Oct 11, 2026
@adeelahmad
adeelahmad merged commit c9b3d8f into main Oct 11, 2026
13 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.

3 participants

0