tunnel-hub: GitHub authentication (owner isolation + per-user registration) - #796
Merged
Conversation
…uth) First slice of the hub-authentication proposal: the data foundation the later auth slices hang the viewer identity off. Pure refactor — inert until a real owner/viewer is supplied, so the hub behaves exactly as today. - Add `Backend.Owner` (GitHub login that registered it; "" = anonymous / self-hosted / auth off), derived server-side later, never from the body. - `identity()` puts Owner first, so two users' identically-named project/branch never collide on one slot. - `List(sortKey, viewerLogin)` filters to the viewer's own backends; viewerLogin "" returns all (preserving today's behaviour). Threaded through `/api/backends` and the periodic reaper. - `viewerLogin(r)` seam returns "" for now; slice 2's session-cookie middleware fills it in. Tests: TestList_FiltersByOwner (per-viewer filtering + "" sees all), TestIdentity_OwnerDisambiguates (different owners don't collide; two anonymous registrations still share a slot). Proposal: docs/11-proposals/PROPOSAL_hub_authentication.md (slice 1 marked done). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A hosted, internet-facing hub needs a durable record of who authenticated, who was denied, and who is using it — both to investigate abuse (a leaked X-Hub-Key used to register as another user) and to answer "who is on the hub, and how much". The in-memory registry forgets, so ephemeral log lines aren't enough. Add an "Audit logging & usage tracking" section: - Durable append-only JSONL store (audit.jsonl, mode 0600), behind an audit.Sink interface (jsonl/noop/stdout), queryable with DuckDB read_json_auto (composes with the analyze-runtime warehouse pattern). - Event schema (ts/event/login/ip/subdomain/owner/outcome/detail) and the emit points: login_ok/logout/callback_fail/access_deny (slice 2), key_mint/key_revoke/register_ok/register_deny (slice 3). - Secrets are never recorded (no tokens/cookies/keys/session-secret). - login_ok + register_ok are the "who is using the hub" signal; no per-request preview logging (volume). PII/retention documented; open mode writes nothing unless --audit-log is set. - New --audit-log flag; data-model + slices 2/3 + testing updated; open question on retention default and JSONL-vs-SQLite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
The durable audit trail for the hub: an append-only record of who
authenticated, who was denied, and who registered a preview — so the hub
can answer "who is using it" and support abuse investigation, and be
queried after the fact (JSONL → DuckDB read_json_auto).
- `audit.Event` (ts/event/login/ip/subdomain/owner/outcome/detail) with a
structural guarantee: no field for a token/cookie/key/secret, so a caller
cannot record one (asserted by TestEvent_HasNoSecretField).
- `audit.Sink` interface with noop / stdout / JSONL-file impls; `New(spec)`
resolves the --audit-log flag ("" or "-" = off, "stdout", else a 0600
file path).
Emit points are wired in 2c (login/deny) and slice 3 (keys/register).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Add the viewer-plane auth for the tunnel-hub: a GitHub OAuth login
that mints an HMAC-signed SSO session cookie shared across every
*.<cookie-domain> preview.
- AuthConfig: GitHub OAuth + session-cookie config. Zero value
(empty client id) is open mode — middleware no-ops, viewer
resolution returns "" (everyone sees everything), registration
keeps honouring the legacy X-Hub-Secret.
- signSession/verifySession: base64url(payload).HMAC-SHA256 cookie
carrying {login, exp}, constant-time verification, expiry check.
- /auth/github/login, /auth/github/callback, /auth/logout handlers
with a signed OAuth state carrying the post-login return URL and
an open-redirect guard (safeReturn) restricting bounce targets to
the hub host or a cookie-domain subdomain.
- authorizePreview: owner check for a preview request — open mode /
unowned backend allow, no session 302s to hub login (carrying the
originating URL), owner allows, other 403s.
- Emits login_ok / logout / callback_fail / access_deny to the audit
sink; secrets never logged (Event carries no secret field).
Tests: cookie + state sign/verify round-trip (valid, tampered,
expired), open-mode no-op, stubbed-GitHub callback sets the session
and writes login_ok, forged state → 400 + callback_fail, preview
authorize allow/redirect/deny with an access_deny audit line, and
clientIP X-Forwarded-For handling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Thread AuthConfig through the tunnel-hub front and expose it on the command: - server.go: ServerOptions.Auth; mount /auth/* on the hub host when enabled; call authorizePreview on the preview path before proxying (no-op in open/soft mode, 302→login or 403 when --require-auth). - api.go: APIOptions.Auth; /api/backends filters by Auth.sessionLogin(r), replacing the slice-1 viewerLogin stub. - authorizePreview now gates enforcement on RequireAuth: soft mode (auth on, --require-auth=false) filters the listing but leaves owned previews open; --require-auth (default on) enforces the owner check. - cmd_tunnelhub.go: --github-oauth-client-id, --github-oauth-client-secret (env MXCLI_HUB_GH_SECRET), --session-secret (env MXCLI_HUB_SESSION_SECRET), --cookie-domain, --require-auth (default on), --audit-log. buildHubAuth returns nil (open mode) without a client id and generates an ephemeral session key with a warning when none is supplied. Updated the command help. All nil-safe: open mode (nil AuthConfig) preserves today's behaviour. Proposal slice 2 marked done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Add the registration-plane credential so a hosted hub can attribute
each preview to a GitHub user without a shared secret.
- KeyStore (keys.go): mint/resolve/revoke opaque hub API keys bound to
a GitHub login. Keys are stored SHA-256-hashed — a store dump yields
no usable credential — and the plain key is returned only once, at
mint. In-memory for the first cut (lost on restart; re-run login).
- POST /api/keys: validate the caller's GitHub token (Bearer) against
GET /user once, mint a key for that login, and discard the token —
it never reaches the register hot path or storage. DELETE /api/keys
revokes the presented X-Hub-Key (idempotent). Both 404 in open mode.
- /api/register now authorizes via authorizeRegister:
- open mode (no Auth): the legacy X-Hub-Secret gate, owner "".
- auth on: X-Hub-Key → login stamped as Backend.Owner. --require-auth
(default) rejects a missing/invalid key (401); soft mode registers
anonymously (owner-less). Owner is server-derived, never trusted
from the request body (RegisterRequest.Owner is json:"-").
- Emits key_mint / key_revoke / register_ok / register_deny to the
audit sink, threaded through ServerOptions.Audit → APIOptions.Audit
(also available in open mode when --audit-log is set).
Tests: KeyStore mint/resolve/revoke, distinct-key-per-mint, hashed-not-
plaintext storage; mint→register stamps owner and filters the listing;
--require-auth rejects keyless/bogus-key with register_deny; soft mode
allows anonymous register; revoked key can't register; open-mode keys
404 and open-mode register is unchanged (owner-less).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Complete the client side so a hosted, authenticated hub is usable
end to end.
- GET /api/auth-config (tunnelhub): advertises whether auth is required
and the OAuth App client id (a public value) so `auth hub login` needs
no configuration; open-mode hubs report authEnabled:false.
- hubauth package (client):
- device flow: request device code, poll GitHub honouring
authorization_pending / slow_down / expiry, mint a hub key via
POST /api/keys (GitHub token sent once, never stored), save it.
- per-host key storage in ~/.mxcli/auth.json (new SchemeHubKey,
profile "hub:<host>"); ResolveKey = MXCLI_HUB_KEY env → stored.
- `mxcli auth hub login/status/logout`: bootstrap, inspect, and revoke
(logout best-effort DELETE /api/keys, then drops the local copy).
- `mxcli run --hub` resolves the key and RegisterWithHub sends X-Hub-Key
(owner stamped) alongside the legacy X-Hub-Secret, so an authed hub and
an open self-hosted hub both work from the same client.
Tests: hubauth store round-trip + env-override precedence, HostOf,
device-flow pending→success with stubbed GitHub, mint, full Login stores
the key, open-mode-hub login errors; auth-config open vs authed; docker
RegisterWithHub signature threaded through re-register.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Document `mxcli auth hub login/status/logout`, MXCLI_HUB_KEY for unattended (web) environments, and the token-containment guarantee in the run-local skill; mark proposal slice 4 done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
External code review of the hub-auth PR surfaced one real information-disclosure bug plus three hardening gaps; all fixed here. - Anonymous /api/backends leak (primary): sessionLogin() returns "" both for "auth off" and "no/invalid session", and List(_, "") returns ALL backends — so on a --require-auth hub an unauthenticated GET enumerated every user's previews (subdomain/project/owner/ports). handleBackends now 401s when auth is enabled and there is no valid session; the admin page redirects an anonymous visitor to login instead of serving a shell whose fetch would 401. - Session/state HMAC confusion: the session cookie and OAuth state shared one signing scheme. Domain-separate them with a signing tag (signTagged/verifyTagged, tags "session"/"state") so neither can be replayed as the other. - OAuth handlers ignored HTTP status: exchangeCode/fetchLogin now check resp.StatusCode (and the token endpoint's error field) before decoding, and cap the body with io.LimitReader. - Spoofable audit IP: the hub is the TLS edge, so clientIP now uses RemoteAddr and ignores the client-supplied X-Forwarded-For. Acknowledged + deferred in the proposal: in-memory key store (restart invalidates keys) and no rate-limit on /api/keys (keys are login-bound + revocable). Tests: anonymous /api/backends 401s and leaks nothing while a valid session sees its own preview; session/state domain separation (neither verifies as the other); clientIP ignores XFF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
tunnel-hub: GitHub authentication (owner isolation + per-user registration)
The admin overview gave no way to confirm which GitHub account the session belongs to. Add GET /api/whoami (returns the session login / authEnabled) and render "signed in as <login>" plus a Sign out button in the header; the button POSTs /auth/logout and reloads. Open-mode hubs return authEnabled:false and the indicator stays hidden. Test: whoami in open mode (disabled), auth-on-no-session (enabled, empty login), and auth-on-with-session (returns the login). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Three fixes surfaced by testing the deployed authenticated hub: - #31C (reliability): a failed hub registration no longer aborts the whole `run --hub`. An unreachable hub or stale/rejected key now warns and degrades to a normal local run (app boots on localhost; only the public preview URL is lost), instead of killing the process before the runtime starts. The downstream tunnel/heartbeat already guards on a nil registration. - #31B (compat): with auth enabled, the shared X-Hub-Secret is honored again as a fallback registration credential — a valid X-Hub-Key stamps the owner, a valid X-Hub-Secret registers owner-less, neither is refused. A configured secret now actually gates soft mode too (a keyless, secretless register is denied rather than silently allowed). - #31A (usability): `mxcli auth hub login --token <pat>` mints the hub key directly from a GitHub token, skipping the device flow — for environments whose egress proxy blocks GitHub's device endpoints (e.g. Claude Code web containers, which only permit repo-scoped paths). Tests: secret-fallback (auth-on valid/invalid secret; soft-mode-with- secret gating) and LoginWithToken mint+store. run-local skill documents the --token escape hatch, non-fatal registration, and the secret fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
A hub API key is not tied to the browser session and has no expiry — but the key store was in-memory, so every hub restart (redeploy, crash, reboot) wiped all keys and forced every user to re-mint and reconfigure MXCLI_HUB_KEY. Make the store durable so a key stays valid until it is explicitly revoked. - NewKeyStoreFile(path): load on open, write-through on mint/revoke, atomic (temp+rename) mode 0600. Keys remain SHA-256-hashed on disk (a dump yields no usable credential); records carry only login + createdAt. Refuses a world/group-readable file on Unix. - tunnel-hub: --keys-file flag, defaulting to ~/.mxcli/hub-keys.json when auth is enabled, so persistence is on by default for a hosted hub. Threaded ServerOptions.KeysFile → NewKeyStoreFile. Startup logs the durable path. - NewKeyStore() stays in-memory for tests / open-mode. Tests: persistence across a simulated restart (mint → reopen resolves; revoke → reopen gone), 0600 file mode, and rejection of a too-open file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Make the hub's own browser page the primary way to get a key — the path
that actually works for the default target (Claude Code web/mobile,
whose container can't reach GitHub's device endpoints).
Server:
- POST /api/keys now also accepts a signed-in **session cookie**, not
only a GitHub bearer token, so a browser user mints without a PAT.
Cookie auth is CSRF-guarded: it requires the X-Hub-Mint custom header
(a cross-site form can't set one) and, when an Origin is present, a
same-origin one.
- New /cli page: sign in, click "Create a hub key", copy it, with
MXCLI_HUB_KEY instructions. Linked from the admin header ("Get CLI
key"). Both pages are gated behind a session by the existing redirect.
Client — device flow removed (it was blocked in the container anyway
and is now superseded by browser-mint):
- hubauth Client slimmed to the headless path: MintHubKey / RevokeHubKey
/ LoginWithToken. Dropped RequestDeviceCode/PollForToken/DeviceCode/
FetchAuthConfig/Login and the poll clock plumbing (deviceflow.go →
hubkey.go).
- `mxcli auth hub login` with no --token now prints the /cli URL + the
MXCLI_HUB_KEY steps; `--token <pat>` remains the headless/CI mint.
Tests: browser-mint success (cookie + X-Hub-Mint + same-origin),
CSRF/cross-origin/anon rejections; MintHubKey error-status; token
login. run-local skill rewritten around the browser page + durable key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Stop the /cli page from silently accumulating live keys. Visiting the page still mints nothing (mint is only ever the button click), but each click used to add another valid key with no way to see or revoke old ones from the browser. - KeyStore.CountLogin / RevokeLogin (bulk revoke by login). - POST /api/keys now rotates by default: revokes the caller's existing keys before minting one, so a user ends up with exactly one key. ?replace=false keeps the old ones (mint an additional key). - GET /api/keys reports the caller's active-key count. - DELETE /api/keys with no X-Hub-Key revokes ALL the caller's keys (session-authed, same CSRF guard) — the leak-recovery / cleanup path; DELETE with X-Hub-Key still revokes that single key (CLI logout). - keyCaller() unifies bearer-or-cookie(+CSRF) auth across mint/list/ revoke; list is a safe read (no CSRF). - /cli page: shows "You have N active keys", "Create" rotates by default with a "keep existing" checkbox, and a "Revoke all keys" button (with confirm). Rotation revokes emit an audit key_revoke. Tests: CountLogin/RevokeLogin; mint rotates (old key dies, count stays 1), GET count, ?replace=false accumulates, DELETE revoke-all. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Post-retirement cleanup flagged by the sudoku live-hub test: the `mxcli auth hub login` Short help still read "Mint and store a hub API key via GitHub device flow" though the device flow is gone. Fix that one-liner and the matching stale package/struct doc comments (hubauth package doc, authHubCmd comment, AuthConfigResponse) to reflect the browser /cli page + --token model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
Sweep remaining docs after the device-flow retirement + browser-mint + durable/rotating keys: - docs-site run-local.md: add an "Authenticated hub (GitHub)" section (OAuth flags, /cli key page, MXCLI_HUB_KEY, durable keys, non-fatal registration) and replace the stale "per-tenant auth is a follow-up" security note; add the hub-key row to the flag table. - run-local skill: flag-table row points to the /cli browser page. - PROPOSAL_hub_authentication.md: add an "as-built amendments" note at the top (device flow → /cli browser mint; in-memory → durable keys; rotate-by-default + count + revoke-all; keys don't expire by design) while keeping the original design narrative for the audit trail. - CLAUDE.md: add a tunnel-hub GitHub-authentication bullet to the implemented-features list (viewer plane, registration plane, key issuance, audit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4
tunnel-hub auth: post-deploy fixes + signed-in identity on admin page
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.
Adds opt-in GitHub authentication to the multi-tenant tunnel-hub so a hosted instance (e.g. hub.mxcli.org) can show each user only their own app previews and limit who may register a runtime. Built and tested end-to-end against a live deployed hub.
Self-hosted hubs are unchanged. Everything is gated on --github-oauth-client-id; without it the hub is byte-for-byte today’s open behaviour (shared --secret, open registration, unfiltered admin listing).
Two planes, both keyed to GitHub
Viewer plane (browser) — GitHub OAuth web flow, an HMAC-signed SSO session cookie (Domain=., so one sign-in covers every *. preview), owner-checked previews (--require-auth, default on → 302 unauthenticated viewers to login, 403 non-owners; soft mode filters the admin listing but leaves previews open), /api/backends filtered to the viewer (unauthenticated → 401), and a “signed in as …” indicator via /api/whoami.
Registration plane (headless container) — durable, SHA-256-hashed hub API keys presented as X-Hub-Key on /api/register, which stamps Backend.Owner. The legacy shared X-Hub-Secret still works as an owner-less fallback (open hubs, transition).
Key issuance — built for the Claude Code container
The target environment (Claude Code web/mobile) runs in a container whose egress proxy blocks GitHub’s device endpoints, so there is no device flow. Instead:
• The hub serves a signed-in /cli browser page that mints a key from the session cookie (no PAT), with copy + MXCLI_HUB_KEY instructions. Works from any device.
• Keys are durable (--keys-file, default ~/.mxcli/hub-keys.json, mode 0600) so they survive hub restarts — set MXCLI_HUB_KEY once.
• Keys rotate by default (a new mint replaces the caller’s old keys; ?replace=false to keep them), GET /api/keys reports the active count, and a session-authed DELETE /api/keys revokes all (leak recovery). Keys do not expire by design.
• mxcli auth hub login --token is the headless/CI path; mxcli run --hub reads MXCLI_HUB_KEY (env → ~/.mxcli/auth.json) and degrades to a normal local run if registration fails (unreachable hub / stale key) instead of aborting.
Audit
Append-only JSONL trail (--audit-log, mode 0600) emitting login_ok / logout / callback_fail / access_deny / key_mint / key_revoke / register_ok / register_deny. Secrets are un-loggable by construction — the Event struct carries no token/cookie/key field.
New flags / commands
• mxcli tunnel-hub: --github-oauth-client-id, --github-oauth-client-secret (env MXCLI_HUB_GH_SECRET), --session-secret (env MXCLI_HUB_SESSION_SECRET), --cookie-domain, --require-auth (default on), --audit-log, --keys-file.
• mxcli auth hub login [--token ] / status / logout; mxcli run --hub reads MXCLI_HUB_KEY.
Security notes
GitHub token stays on the client except for the one-time key-mint call; the hub stores only the opaque key (hashed). Session cookie carries {login, exp} only, HMAC-signed, with an open-redirect guard on the OAuth state and domain separation between session and state tokens. Cookie-authenticated mint/revoke are CSRF-guarded (custom X-Hub-Mint header + same-origin). Audit IPs come from RemoteAddr (the hub is the TLS edge), not a spoofable X-Forwarded-For.
Testing
Unit + integration (GitHub stubbed via httptest) across tunnelhub, tunnelhub/audit, hubauth, internal/auth, docker: OAuth callback + cookie/state sign-verify, key mint/resolve/revoke + hashed + durable storage across a simulated restart, owner-stamped register, --require-auth deny paths + audit lines, browser-mint (success + CSRF/cross-origin/anon rejection), rotate/count/revoke-all, secret fallback, and open-mode-unchanged assertions. go vet + full build clean. Verified end-to-end on a live hub (owner-gated previews, anonymous /api/backends → 401, /cli key flow, durable keys, non-fatal registration).
Implements docs/11-proposals/PROPOSAL_hub_authentication.md (see the “as-built amendments” note at its top).