Skip to content

tunnel-hub: GitHub authentication (owner isolation + per-user registration) - #796

Merged
ako merged 19 commits into
mendixlabs:mainfrom
ako:main
Jul 28, 2026
Merged

tunnel-hub: GitHub authentication (owner isolation + per-user registration)#796
ako merged 19 commits into
mendixlabs:mainfrom
ako:main

Conversation

@ako

@ako ako commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

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).

claude and others added 19 commits July 27, 2026 14:09
…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
@ako
ako merged commit ead8926 into mendixlabs:main Jul 28, 2026
4 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.

2 participants