From ee7bbc30b03b22778e2046e5b0cca80c88b94c64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:09:52 +0000 Subject: [PATCH 01/17] =?UTF-8?q?feat(tunnel-hub):=20slice=201=20=E2=80=94?= =?UTF-8?q?=20Backend.Owner=20+=20owner-filtered=20List=20(no=20auth)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/tunnelhub/api.go | 9 ++- cmd/mxcli/tunnelhub/api_test.go | 2 +- cmd/mxcli/tunnelhub/registry.go | 20 ++++-- cmd/mxcli/tunnelhub/registry_test.go | 63 ++++++++++++++++--- cmd/mxcli/tunnelhub/server.go | 2 +- .../PROPOSAL_hub_authentication.md | 9 ++- 6 files changed, 85 insertions(+), 20 deletions(-) diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index c0a614d62..25c30f8eb 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -127,9 +127,16 @@ func (a *API) byToken(w http.ResponseWriter, r *http.Request, fn func(token stri } func (a *API) handleBackends(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, a.opts.Registry.List(r.URL.Query().Get("sort"))) + // viewerLogin is "" until the session-cookie middleware (slice 2) supplies the + // authenticated GitHub login; "" lists all backends (self-hosted / auth off). + writeJSON(w, http.StatusOK, a.opts.Registry.List(r.URL.Query().Get("sort"), viewerLogin(r))) } +// viewerLogin returns the authenticated GitHub login for a request, or "" when +// auth is off (self-hosted) or the viewer is anonymous. Slice 1 always returns +// "" (no auth yet); slice 2's session-cookie middleware fills this in. +func viewerLogin(_ *http.Request) string { return "" } + func bearerToken(r *http.Request) string { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) diff --git a/cmd/mxcli/tunnelhub/api_test.go b/cmd/mxcli/tunnelhub/api_test.go index 380e9fcba..55039501f 100644 --- a/cmd/mxcli/tunnelhub/api_test.go +++ b/cmd/mxcli/tunnelhub/api_test.go @@ -115,7 +115,7 @@ func TestAPI_HeartbeatAndDeregister(t *testing.T) { if dr.Code != http.StatusNoContent { t.Errorf("deregister status = %d, want 204", dr.Code) } - if len(reg.List("used")) != 0 { + if len(reg.List("used", "")) != 0 { t.Error("backend should be gone after deregister") } } diff --git a/cmd/mxcli/tunnelhub/registry.go b/cmd/mxcli/tunnelhub/registry.go index f2debb32d..8f99e384f 100644 --- a/cmd/mxcli/tunnelhub/registry.go +++ b/cmd/mxcli/tunnelhub/registry.go @@ -32,6 +32,7 @@ type Backend struct { Solution string `json:"solution"` // optional grouping for multi-app solutions Branch string `json:"branch"` // git branch Worktree string `json:"worktree"` // optional, distinguishes worktrees of one branch + Owner string `json:"owner"` // GitHub login that registered it ("" = anonymous / self-hosted / auth off) Subdomain string `json:"subdomain"` ReversePort int `json:"reversePort"` AppPort int `json:"appPort"` @@ -41,10 +42,12 @@ type Backend struct { LastUsedAt time.Time `json:"lastUsedAt"` // last browser request to the subdomain } -// identity is the stable key for a preview across reconnects: same prefix + -// project + branch + worktree + solution re-registers to the same slot (stable URL). +// identity is the stable key for a preview across reconnects: same owner + prefix +// + project + branch + worktree + solution re-registers to the same slot (stable +// URL). Owner is first so two different users' identically-named project/branch +// never collide on one slot (Owner is "" until auth stamps it — see slice 3). func (b *Backend) identity() string { - return strings.Join([]string{b.Prefix, b.Solution, b.Project, b.Branch, b.Worktree}, "\x00") + return strings.Join([]string{b.Owner, b.Prefix, b.Solution, b.Project, b.Branch, b.Worktree}, "\x00") } // BackendView is a Backend plus derived fields, for the API/admin page. @@ -210,15 +213,20 @@ func (r *Registry) LookupSubdomain(subdomain string) (*Backend, bool) { return &cp, true } -// List returns a snapshot of all backends as views, sorted by the given key -// ("used", "registered", "project"; default "used"), most-recent/first. -func (r *Registry) List(sortKey string) []BackendView { +// List returns a snapshot of backends as views, sorted by the given key ("used", +// "registered", "project"; default "used"), most-recent/first. When viewerLogin is +// non-empty, only that owner's backends are returned; an empty viewerLogin (auth +// off / self-hosted) returns all — preserving today's behaviour. +func (r *Registry) List(sortKey, viewerLogin string) []BackendView { r.mu.Lock() defer r.mu.Unlock() r.reapLocked() out := make([]BackendView, 0, len(r.byID)) for _, b := range r.byID { + if viewerLogin != "" && b.Owner != viewerLogin { + continue + } out = append(out, r.viewLocked(b)) } sortViews(out, sortKey) diff --git a/cmd/mxcli/tunnelhub/registry_test.go b/cmd/mxcli/tunnelhub/registry_test.go index 1d1a3ff0c..d1be9ed4c 100644 --- a/cmd/mxcli/tunnelhub/registry_test.go +++ b/cmd/mxcli/tunnelhub/registry_test.go @@ -83,7 +83,7 @@ func TestRegister_SameIdentityIsStable(t *testing.T) { if !b.LastSeenAt.Equal(clk.t) { t.Error("re-register should refresh the heartbeat") } - if got := r.List("used"); len(got) != 1 { + if got := r.List("used", ""); len(got) != 1 { t.Errorf("want 1 backend after re-register, got %d", len(got)) } } @@ -113,16 +113,16 @@ func TestAvailability_StaleAfterNoHeartbeat(t *testing.T) { r := newTestRegistry(clk) b, _ := r.Register(RegisterRequest{Project: "App", Branch: "main"}) - if av := r.List("used")[0].Availability; av != Available { + if av := r.List("used", "")[0].Availability; av != Available { t.Errorf("fresh backend = %q, want available", av) } clk.add(60 * time.Second) // past StaleFor (45s), before ExpireFor - if av := r.List("used")[0].Availability; av != Stale { + if av := r.List("used", "")[0].Availability; av != Stale { t.Errorf("after 60s = %q, want stale", av) } // A heartbeat brings it back. r.Heartbeat(b.ID) - if av := r.List("used")[0].Availability; av != Available { + if av := r.List("used", "")[0].Availability; av != Available { t.Errorf("after heartbeat = %q, want available", av) } } @@ -134,7 +134,7 @@ func TestReap_RemovesExpiredAndFreesPort(t *testing.T) { clk.add(11 * time.Minute) // past ExpireFor // List triggers a reap. - if got := r.List("used"); len(got) != 0 { + if got := r.List("used", ""); len(got) != 0 { t.Fatalf("expired backend should be reaped, got %d", len(got)) } // Its port is freed and reusable. @@ -156,15 +156,15 @@ func TestList_Sorting(t *testing.T) { r.TouchUsed(b.Subdomain) clk.add(time.Second) r.TouchUsed(a.Subdomain) - if got := r.List("used"); got[0].Project != "Beta" { + if got := r.List("used", ""); got[0].Project != "Beta" { t.Errorf("used sort: first = %q, want Beta", got[0].Project) } // registered: newest first -> Alpha. - if got := r.List("registered"); got[0].Project != "Alpha" { + if got := r.List("registered", ""); got[0].Project != "Alpha" { t.Errorf("registered sort: first = %q, want Alpha", got[0].Project) } // project: alphabetical -> Alpha. - if got := r.List("project"); got[0].Project != "Alpha" { + if got := r.List("project", ""); got[0].Project != "Alpha" { t.Errorf("project sort: first = %q, want Alpha", got[0].Project) } } @@ -182,3 +182,50 @@ func TestPortExhaustion(t *testing.T) { t.Error("expected port-exhaustion error on the 3rd registration") } } + +// TestList_FiltersByOwner covers slice 1: List filters to the viewer's own +// backends, while an empty viewer (auth off / self-hosted) sees everything — +// preserving today's behaviour. Owner is stamped by the auth layer (slice 3); +// here it is set directly on the stored backends to exercise the filter. +func TestList_FiltersByOwner(t *testing.T) { + clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} + r := newTestRegistry(clk) + + a, _ := r.Register(RegisterRequest{Project: "AppA", Branch: "main"}) + a.Owner = "alice" + b, _ := r.Register(RegisterRequest{Project: "AppB", Branch: "main"}) + b.Owner = "bob" + r.Register(RegisterRequest{Project: "AppC", Branch: "main"}) // anonymous (Owner "") + + // Empty viewer sees all three (self-hosted / auth off). + if got := r.List("project", ""); len(got) != 3 { + t.Fatalf("viewer \"\" should see all 3, got %d", len(got)) + } + // A viewer sees only their own. + if got := r.List("project", "alice"); len(got) != 1 || got[0].Project != "AppA" { + t.Errorf("viewer alice should see only AppA, got %d results", len(got)) + } + if got := r.List("project", "bob"); len(got) != 1 || got[0].Project != "AppB" { + t.Errorf("viewer bob should see only AppB, got %d results", len(got)) + } + // A viewer with no backends sees nothing (not the anonymous one). + if got := r.List("project", "carol"); len(got) != 0 { + t.Errorf("viewer carol should see nothing, got %d", len(got)) + } +} + +// TestIdentity_OwnerDisambiguates covers slice 1: two users' identically-named +// project/branch must map to distinct slots (Owner is first in identity), while +// two anonymous ("" owner) registrations still share a slot as they do today. +func TestIdentity_OwnerDisambiguates(t *testing.T) { + base := Backend{Project: "App", Branch: "main"} + alice, bob := base, base + alice.Owner, bob.Owner = "alice", "bob" + if alice.identity() == bob.identity() { + t.Error("same project/branch under different owners must not collide") + } + anon1, anon2 := base, base + if anon1.identity() != anon2.identity() { + t.Error("two anonymous registrations of the same project/branch must share identity") + } +} diff --git a/cmd/mxcli/tunnelhub/server.go b/cmd/mxcli/tunnelhub/server.go index 4b4bfa2fa..fc8c39e67 100644 --- a/cmd/mxcli/tunnelhub/server.go +++ b/cmd/mxcli/tunnelhub/server.go @@ -223,7 +223,7 @@ func (s *Server) Start(ctx context.Context, httpsAddr, httpAddr string) error { case <-ctx.Done(): return case <-reaper.C: - s.reg.List("") + s.reg.List("", "") // periodic reap; viewer "" = all } } }() diff --git a/docs/11-proposals/PROPOSAL_hub_authentication.md b/docs/11-proposals/PROPOSAL_hub_authentication.md index 66eeeb25f..2b4b9cbd8 100644 --- a/docs/11-proposals/PROPOSAL_hub_authentication.md +++ b/docs/11-proposals/PROPOSAL_hub_authentication.md @@ -208,9 +208,12 @@ Client (`mxcli run --hub` / `mxcli auth`): ## Implementation slices -1. **Owner field + filtered list** (no auth yet): add `Backend.Owner`, - `List(sort, viewer)`, thread a viewer through admin/`/api/backends`. Pure refactor, - `""` viewer = today. Tests: registry filtering. +1. **Owner field + filtered list** (no auth yet) — ✅ **done**: added `Backend.Owner`, + `Owner` is first in `identity()` (so two users' same-named project/branch don't + collide), `List(sort, viewer)` filters by owner (`""` viewer = all = today), and a + `viewerLogin(r)` seam (returns `""` until slice 2's cookie) threads through + `/api/backends` + the reaper. Tests: `TestList_FiltersByOwner`, + `TestIdentity_OwnerDisambiguates`. 2. **GitHub OAuth web flow + session cookie**: `/auth/github/*`, signing, middleware on preview + admin; skip WS/ACME. Tests: middleware allow/deny, cookie round-trip (httptest, GitHub stubbed). From 775db82d02fb2f07327c34d19ec1b236ca0f7fe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:24:00 +0000 Subject: [PATCH 02/17] docs(proposal): add audit logging & usage tracking to hub-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .../PROPOSAL_hub_authentication.md | 74 +++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/docs/11-proposals/PROPOSAL_hub_authentication.md b/docs/11-proposals/PROPOSAL_hub_authentication.md index 2b4b9cbd8..2423584e4 100644 --- a/docs/11-proposals/PROPOSAL_hub_authentication.md +++ b/docs/11-proposals/PROPOSAL_hub_authentication.md @@ -152,6 +152,11 @@ Owner string `json:"owner"` // GitHub login that registered it ("" = anonymous/s New durable state: a `keys` store `map[string]string` (hub key → GitHub login). In-memory for the first cut (see *Open questions* re: persistence). +The **audit trail** is the one thing written to disk (append-only `audit.jsonl`, mode +`0600`) — see *Audit logging & usage tracking*. An `auditEvent` struct (ts, event, +login, ip, subdomain, owner, outcome, detail — never a secret) is emitted through an +`audit.Sink` interface, so the store backend is swappable. + ## HTTP surface | Method & path (on `hub.mxcli.org`) | Purpose | @@ -174,6 +179,9 @@ Preview subdomains: unchanged path, now behind the viewer-auth middleware. --session-secret HMAC key for the session cookie (env: MXCLI_HUB_SESSION_SECRET) --require-auth require a valid session for every preview + register (default: on when a client id is set; forced off when it is not) +--audit-log append-only JSONL audit trail of auth + registration events + (default: /audit.jsonl when auth is on; "" / "-" disables; + "stdout" for containers). File mode 0600. ``` **Absent client id ⇒ open mode** — the middleware is a no-op, `/api/register` keeps @@ -206,6 +214,54 @@ Client (`mxcli run --hub` / `mxcli auth`): `--require-auth` defaults on with a client id present; document that a client id without a session secret refuses to start. +## Audit logging & usage tracking + +A hosted, internet-facing hub needs a durable record of **who authenticated, who was +denied, and who is using it** — both to investigate abuse (the threat model already +contemplates a leaked `X-Hub-Key` used to register as another user) and to answer the +plain operational question *"who is on the hub, and how much?"*. Ephemeral log lines +aren't enough; the registry itself is in-memory and reaped, so it forgets. We keep a +**durable audit trail**. + +**Store: append-only JSONL** (`audit.jsonl`, mode `0600`), one event per line. Chosen +over SQLite for the first cut: durable across restarts (unlike the registry/keys), +no schema or migrations, greppable, rotatable with standard tooling, and directly +queryable with DuckDB `read_json_auto` — so "who is using the hub" is a `SELECT` +(this composes with the `analyze-runtime` warehouse pattern). A `Sink` interface backs +it (`jsonlSink` / `noopSink` / `stdoutSink`) so SQLite or an external collector can be +added later without touching call sites. + +**Event schema** (secrets are *never* recorded): + +```json +{"ts":"2026-07-27T10:15:04Z","event":"access_deny","login":"bob","ip":"203.0.113.9", + "subdomain":"app-x","owner":"alice","outcome":"deny","detail":"not owner"} +``` + +| Event | Slice | Emitted when | +|-------|-------|--------------| +| `login_ok` / `logout` | 2 | web-flow session established / cleared | +| `callback_fail` | 2 | bad `state`, or GitHub code exchange failed | +| `access_deny` | 2 | preview request where `cookie.login != Backend.Owner` (the 403 path) | +| `key_mint` / `key_revoke` | 3 | hub API key issued / revoked for a login | +| `register_ok` / `register_deny` | 3 | registration accepted (owner stamped) / rejected (bad/absent `X-Hub-Key`) | + +Fields: `ts` (RFC3339 UTC), `event`, `login` (GitHub login or `""`), `ip` (source, +read from the 443 front's `X-Forwarded-For`), `subdomain`, `owner` (of the target +preview), `outcome`, `detail`. **Never logged:** GitHub tokens, hub keys, session +cookies, `--session-secret`, or the OAuth client secret. + +**Usage, not just auth.** `login_ok` + `register_ok` are the load-bearing "who is +using the hub" signal — distinct logins over time, and which project/branch each +registered. We deliberately do **not** log per-request preview traffic (too high +volume, and low value); the registry's `LastUsedAt` already covers "is it live now". + +**Privacy / retention.** GitHub logins + IPs are PII. The audit file is `0600`; the +proposal documents (and the flag help states) that operators own retention — rotate +or truncate on a schedule. **Open mode** (no client id) writes nothing unless +`--audit-log` is explicitly set, and then only anonymous `register_ok`/`register_deny` +lines (no login). See *Open questions* on retention default and JSONL-vs-SQLite. + ## Implementation slices 1. **Owner field + filtered list** (no auth yet) — ✅ **done**: added `Backend.Owner`, @@ -215,11 +271,14 @@ Client (`mxcli run --hub` / `mxcli auth`): `/api/backends` + the reaper. Tests: `TestList_FiltersByOwner`, `TestIdentity_OwnerDisambiguates`. 2. **GitHub OAuth web flow + session cookie**: `/auth/github/*`, signing, middleware on - preview + admin; skip WS/ACME. Tests: middleware allow/deny, cookie round-trip - (httptest, GitHub stubbed). + preview + admin; skip WS/ACME. **Introduces the audit sink** (`audit` package + + `--audit-log`) and emits `login_ok`/`logout`/`callback_fail`/`access_deny`. Tests: + middleware allow/deny, cookie round-trip (httptest, GitHub stubbed), and an + `access_deny` line is written on a 403. 3. **Hub API keys + registration by key**: `POST/DELETE /api/keys`, `X-Hub-Key` on - `/api/register` → stamp `Owner`; keep `X-Hub-Secret` for open mode. Tests: mint/resolve/ - revoke, register stamps owner, open-mode fallback. + `/api/register` → stamp `Owner`; keep `X-Hub-Secret` for open mode. Emits + `key_mint`/`key_revoke`/`register_ok`/`register_deny`. Tests: mint/resolve/revoke, + register stamps owner, open-mode fallback, audit lines on register accept/reject. 4. **Client**: `mxcli auth hub login/status/logout` (device flow), `run --hub` sends `X-Hub-Key` (env → auth.json → legacy secret). Tests: auth.json round-trip, header selection. @@ -230,7 +289,8 @@ Client (`mxcli run --hub` / `mxcli auth`): ## Testing - Unit: registry owner-filtering; cookie sign/verify; middleware allow/deny/redirect; - key mint/resolve/revoke; header precedence in the client. + key mint/resolve/revoke; header precedence in the client; audit sink writes the + expected JSONL line per event and **never** leaks a token/cookie/secret. - Integration (`-tags integration`, GitHub stubbed via httptest): full web-flow redirect chain; register-with-key stamps owner; a second user's cookie gets 403 on the first's preview. @@ -251,6 +311,10 @@ Client (`mxcli run --hub` / `mxcli auth`): prompt in `docs-site/src/tools/bootstrap-prompt.md`.) 4. **`mxcli.org` OAuth App ownership** — who registers/owns the OAuth App and holds the client secret + session secret for the hosted instance? +5. **Audit retention & backend** — default retention for `audit.jsonl` (rotate/truncate + after N days, or leave to the operator?), and whether to graduate from JSONL to + SQLite once "who is using the hub" needs indexed/aggregated queries rather than a log + scan. First cut: JSONL, operator-owned retention, documented in the flag help. ## Future work From 2c762a95ab2b9ffa0dd3a512179614ab2fcbfd25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:27:29 +0000 Subject: [PATCH 03/17] =?UTF-8?q?feat(tunnel-hub):=20slice=202a=20?= =?UTF-8?q?=E2=80=94=20audit=20sink=20(append-only=20JSONL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/tunnelhub/audit/audit.go | 107 ++++++++++++++++++++++++ cmd/mxcli/tunnelhub/audit/audit_test.go | 95 +++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 cmd/mxcli/tunnelhub/audit/audit.go create mode 100644 cmd/mxcli/tunnelhub/audit/audit_test.go diff --git a/cmd/mxcli/tunnelhub/audit/audit.go b/cmd/mxcli/tunnelhub/audit/audit.go new file mode 100644 index 000000000..6ef757461 --- /dev/null +++ b/cmd/mxcli/tunnelhub/audit/audit.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package audit is the tunnel-hub's durable audit trail — an append-only record +// of who authenticated, who was denied, and who registered a preview. It answers +// "who is using the hub" (login_ok / register_ok events) and supports abuse +// investigation (access_deny / register_deny), and is queryable after the fact +// (JSONL → DuckDB read_json_auto, per the analyze-runtime warehouse pattern). +// +// Secrets are un-loggable by construction: Event has no field for a token, cookie, +// hub key, or session secret, so a caller cannot accidentally record one. +package audit + +import ( + "encoding/json" + "io" + "os" + "sync" + "time" +) + +// Event names. Emitted in slice 2 (auth/session) and slice 3 (keys/registration). +const ( + EventLoginOK = "login_ok" + EventLogout = "logout" + EventCallbackFail = "callback_fail" + EventAccessDeny = "access_deny" + EventKeyMint = "key_mint" + EventKeyRevoke = "key_revoke" + EventRegisterOK = "register_ok" + EventRegisterDeny = "register_deny" +) + +// Event is one audit record. Only non-sensitive identifiers are carried — there +// is deliberately no field for a token/cookie/secret. +type Event struct { + Ts time.Time `json:"ts"` + Event string `json:"event"` + Login string `json:"login,omitempty"` // GitHub login ("" = anonymous / open mode) + IP string `json:"ip,omitempty"` // source (from the 443 front's X-Forwarded-For) + Subdomain string `json:"subdomain,omitempty"` // target preview, when relevant + Owner string `json:"owner,omitempty"` // owner of the target preview, when relevant + Outcome string `json:"outcome,omitempty"` // "ok" | "deny" | "fail" + Detail string `json:"detail,omitempty"` // short human reason, never a secret +} + +// Sink receives audit events. Implementations must be safe for concurrent use. +type Sink interface { + Log(Event) +} + +// noopSink drops everything — the default when auditing is off (open self-hosted +// hub, or no --audit-log). +type noopSink struct{} + +func (noopSink) Log(Event) {} + +// NoOp returns a sink that records nothing. +func NoOp() Sink { return noopSink{} } + +// writerSink serialises each event as one JSON line to an io.Writer. +type writerSink struct { + mu sync.Mutex + w io.Writer +} + +func (s *writerSink) Log(e Event) { + if e.Ts.IsZero() { + e.Ts = time.Now().UTC() + } + b, err := json.Marshal(e) + if err != nil { + return + } + b = append(b, '\n') + s.mu.Lock() + defer s.mu.Unlock() + _, _ = s.w.Write(b) +} + +// Stdout returns a sink that writes JSONL to stdout (useful in containers where +// the platform captures stdout). +func Stdout() Sink { return &writerSink{w: os.Stdout} } + +// NewWriter returns a JSONL sink over an arbitrary writer (used by tests). +func NewWriter(w io.Writer) Sink { return &writerSink{w: w} } + +// JSONL opens (creating, append, mode 0600) an append-only JSONL audit file. +func JSONL(path string) (Sink, error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + return &writerSink{w: f}, nil +} + +// New resolves an --audit-log spec into a sink: "" or "-" disables (NoOp), +// "stdout" writes to stdout, anything else is a JSONL file path. +func New(spec string) (Sink, error) { + switch spec { + case "", "-": + return NoOp(), nil + case "stdout": + return Stdout(), nil + default: + return JSONL(spec) + } +} diff --git a/cmd/mxcli/tunnelhub/audit/audit_test.go b/cmd/mxcli/tunnelhub/audit/audit_test.go new file mode 100644 index 000000000..7c3eca22f --- /dev/null +++ b/cmd/mxcli/tunnelhub/audit/audit_test.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package audit + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestWriterSink_WritesOneJSONLinePerEvent(t *testing.T) { + var buf bytes.Buffer + s := NewWriter(&buf) + + s.Log(Event{Event: EventLoginOK, Login: "alice", IP: "203.0.113.1", Outcome: "ok"}) + s.Log(Event{Event: EventAccessDeny, Login: "bob", Owner: "alice", Subdomain: "app-x", Outcome: "deny", Detail: "not owner"}) + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("want 2 JSONL lines, got %d: %q", len(lines), buf.String()) + } + var e1 Event + if err := json.Unmarshal([]byte(lines[0]), &e1); err != nil { + t.Fatalf("line 1 not valid JSON: %v", err) + } + if e1.Event != EventLoginOK || e1.Login != "alice" || e1.Outcome != "ok" { + t.Errorf("line 1 = %+v, want login_ok/alice/ok", e1) + } + if e1.Ts.IsZero() { + t.Error("sink must stamp Ts when zero") + } + var e2 Event + if err := json.Unmarshal([]byte(lines[1]), &e2); err != nil { + t.Fatalf("line 2 not valid JSON: %v", err) + } + if e2.Event != EventAccessDeny || e2.Owner != "alice" || e2.Subdomain != "app-x" { + t.Errorf("line 2 = %+v, want access_deny/owner=alice/sub=app-x", e2) + } +} + +// TestEvent_HasNoSecretField is the structural guarantee: a caller cannot record +// a token/cookie/hub-key/secret because Event carries no such field. +func TestEvent_HasNoSecretField(t *testing.T) { + banned := []string{"token", "secret", "cookie", "key", "password", "auth"} + tp := reflect.TypeOf(Event{}) + for i := 0; i < tp.NumField(); i++ { + name := strings.ToLower(tp.Field(i).Name) + for _, b := range banned { + if strings.Contains(name, b) { + t.Errorf("Event field %q looks sensitive (contains %q) — audit records must not carry secrets", tp.Field(i).Name, b) + } + } + } +} + +func TestNew_ResolvesSpecs(t *testing.T) { + // "" and "-" → NoOp (a no-op writes nothing and never errors). + for _, spec := range []string{"", "-"} { + s, err := New(spec) + if err != nil { + t.Fatalf("New(%q): %v", spec, err) + } + if _, ok := s.(noopSink); !ok { + t.Errorf("New(%q) = %T, want noopSink", spec, s) + } + } + // "stdout" → a writer sink. + if s, err := New("stdout"); err != nil || s == nil { + t.Fatalf("New(stdout) = %v, %v", s, err) + } + // A path → a JSONL file, mode 0600, appended. + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + s, err := New(path) + if err != nil { + t.Fatalf("New(path): %v", err) + } + s.Log(Event{Ts: time.Unix(1_700_000_000, 0).UTC(), Event: EventRegisterOK, Login: "carol"}) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("audit file mode = %o, want 600", perm) + } + data, _ := os.ReadFile(path) + if !strings.Contains(string(data), `"event":"register_ok"`) || !strings.Contains(string(data), `"login":"carol"`) { + t.Errorf("audit file missing the event: %s", data) + } +} From d5eb45473274ce68f32500145cf12a1c92efb421 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:33:46 +0000 Subject: [PATCH 04/17] hub-auth slice 2: GitHub OAuth web flow + session cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the viewer-plane auth for the tunnel-hub: a GitHub OAuth login that mints an HMAC-signed SSO session cookie shared across every *. 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/tunnelhub/auth.go | 389 +++++++++++++++++++++++++++++++ cmd/mxcli/tunnelhub/auth_test.go | 338 +++++++++++++++++++++++++++ 2 files changed, 727 insertions(+) create mode 100644 cmd/mxcli/tunnelhub/auth.go create mode 100644 cmd/mxcli/tunnelhub/auth_test.go diff --git a/cmd/mxcli/tunnelhub/auth.go b/cmd/mxcli/tunnelhub/auth.go new file mode 100644 index 000000000..454d09b47 --- /dev/null +++ b/cmd/mxcli/tunnelhub/auth.go @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" +) + +// sessionCookieName is the SSO cookie shared across every *. preview. +const sessionCookieName = "mxcli_hub_session" + +// defaultSessionTTL is how long a session cookie is valid before a silent +// re-auth (the GitHub session makes the redirect invisible while still valid). +const defaultSessionTTL = 12 * time.Hour + +// AuthConfig holds the GitHub OAuth + session-cookie configuration for the hub. +// The zero value (empty GitHubClientID) is **open mode**: the middleware is a +// no-op, viewer resolution returns "" (everyone sees everything), and +// registration keeps honouring the legacy X-Hub-Secret — i.e. today's behaviour. +type AuthConfig struct { + GitHubClientID string + GitHubClientSecret string + SessionSecret []byte // HMAC key for the session cookie + CookieDomain string // e.g. ".mxcli.org" for SSO across subdomains + HubHost string // the hub host (for the OAuth callback URL) + RequireAuth bool // gate previews + register on a valid session + SessionTTL time.Duration // 0 → defaultSessionTTL + + Audit audit.Sink // where auth events go; nil → audit.NoOp() + + // Overridable for tests (default to the real GitHub endpoints / clock). + githubAPIBase string // default "https://api.github.com" + githubOAuthBase string // default "https://github.com" + httpClient *http.Client // default http.DefaultClient + now func() time.Time +} + +// enabled reports whether auth is configured (a GitHub client id is present). +// When false the hub runs in open mode. +func (a *AuthConfig) enabled() bool { + return a != nil && a.GitHubClientID != "" +} + +func (a *AuthConfig) clock() time.Time { + if a != nil && a.now != nil { + return a.now() + } + return time.Now() +} + +func (a *AuthConfig) ttl() time.Duration { + if a != nil && a.SessionTTL > 0 { + return a.SessionTTL + } + return defaultSessionTTL +} + +func (a *AuthConfig) auditSink() audit.Sink { + if a != nil && a.Audit != nil { + return a.Audit + } + return audit.NoOp() +} + +// signSession produces an HMAC-signed cookie value carrying {login, exp}. Format: +// base64url(payload) "." base64url(HMAC-SHA256(payload)), payload = "login|expUnix". +func signSession(secret []byte, login string, exp time.Time) string { + payload := login + "|" + strconv.FormatInt(exp.Unix(), 10) + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(payload)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig +} + +// verifySession validates a cookie value and returns the login when the signature +// is valid and the session has not expired. Constant-time signature comparison. +func verifySession(secret []byte, value string, now time.Time) (login string, ok bool) { + dot := strings.LastIndexByte(value, '.') + if dot <= 0 { + return "", false + } + payloadB64, sigB64 := value[:dot], value[dot+1:] + payload, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return "", false + } + mac := hmac.New(sha256.New, secret) + mac.Write(payload) + want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(want), []byte(sigB64)) { + return "", false + } + // payload = "login|expUnix"; login (GitHub) never contains '|'. + sep := strings.LastIndexByte(string(payload), '|') + if sep <= 0 { + return "", false + } + expUnix, err := strconv.ParseInt(string(payload[sep+1:]), 10, 64) + if err != nil { + return "", false + } + if now.Unix() > expUnix { + return "", false // expired + } + return string(payload[:sep]), true +} + +// setSessionCookie writes a fresh signed session cookie for login. +func (a *AuthConfig) setSessionCookie(w http.ResponseWriter, login string) { + exp := a.clock().Add(a.ttl()) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: signSession(a.SessionSecret, login, exp), + Path: "/", + Domain: a.CookieDomain, + Expires: exp, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// clearSessionCookie expires the session cookie (logout). +func (a *AuthConfig) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + Domain: a.CookieDomain, + MaxAge: -1, + HttpOnly: true, + Secure: true, + }) +} + +// sessionLogin returns the authenticated GitHub login from the request's session +// cookie, or "" when auth is off or the cookie is absent/invalid/expired. +func (a *AuthConfig) sessionLogin(r *http.Request) string { + if !a.enabled() { + return "" + } + c, err := r.Cookie(sessionCookieName) + if err != nil || c.Value == "" { + return "" + } + login, ok := verifySession(a.SessionSecret, c.Value, a.clock()) + if !ok { + return "" + } + return login +} + +func (a *AuthConfig) client() *http.Client { + if a != nil && a.httpClient != nil { + return a.httpClient + } + return http.DefaultClient +} + +func (a *AuthConfig) oauthBase() string { + if a != nil && a.githubOAuthBase != "" { + return a.githubOAuthBase + } + return "https://github.com" +} + +func (a *AuthConfig) apiBase() string { + if a != nil && a.githubAPIBase != "" { + return a.githubAPIBase + } + return "https://api.github.com" +} + +func (a *AuthConfig) callbackURL() string { + return "https://" + a.HubHost + "/auth/github/callback" +} + +// signState signs an OAuth `state` carrying the post-login return URL (base64 so +// no delimiter collides), reusing the session HMAC scheme with a short TTL. +func (a *AuthConfig) signState(returnURL string) string { + enc := base64.RawURLEncoding.EncodeToString([]byte(returnURL)) + return signSession(a.SessionSecret, enc, a.clock().Add(10*time.Minute)) +} + +func (a *AuthConfig) verifyState(state string) (returnURL string, ok bool) { + enc, ok := verifySession(a.SessionSecret, state, a.clock()) + if !ok { + return "", false + } + b, err := base64.RawURLEncoding.DecodeString(enc) + if err != nil { + return "", false + } + return string(b), true +} + +// clientIP returns the source address for audit, honouring the 443 front's +// X-Forwarded-For (first hop) when present. +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + return stripPort(r.RemoteAddr) +} + +// authHandler returns the /auth/* mux (login, callback, logout). Only mounted +// when auth is enabled. +func (a *AuthConfig) authHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/auth/github/login", a.handleLogin) + mux.HandleFunc("/auth/github/callback", a.handleCallback) + mux.HandleFunc("/auth/logout", a.handleLogout) + return mux +} + +// handleLogin (GET /auth/github/login?return=) 302s to GitHub's authorize +// endpoint with a signed state carrying the return URL. +func (a *AuthConfig) handleLogin(w http.ResponseWriter, r *http.Request) { + ret := r.URL.Query().Get("return") + if ret == "" { + ret = "https://" + a.HubHost + "/" + } + q := url.Values{ + "client_id": {a.GitHubClientID}, + "redirect_uri": {a.callbackURL()}, + "scope": {"read:user"}, + "state": {a.signState(ret)}, + } + http.Redirect(w, r, a.oauthBase()+"/login/oauth/authorize?"+q.Encode(), http.StatusFound) +} + +// handleCallback (GET /auth/github/callback) validates state, exchanges the code +// for a token, learns the login, sets the SSO session cookie, and 302s back. +func (a *AuthConfig) handleCallback(w http.ResponseWriter, r *http.Request) { + audit := a.auditSink() + ret, ok := a.verifyState(r.URL.Query().Get("state")) + if !ok { + audit.Log(auditEventFail(r, "", "invalid state")) + http.Error(w, "invalid or expired state", http.StatusBadRequest) + return + } + code := r.URL.Query().Get("code") + if code == "" { + audit.Log(auditEventFail(r, "", "missing code")) + http.Error(w, "missing code", http.StatusBadRequest) + return + } + token, err := a.exchangeCode(code) + if err != nil { + audit.Log(auditEventFail(r, "", "code exchange failed")) + http.Error(w, "authentication failed", http.StatusBadGateway) + return + } + login, err := a.fetchLogin(token) + if err != nil || login == "" { + audit.Log(auditEventFail(r, "", "user lookup failed")) + http.Error(w, "authentication failed", http.StatusBadGateway) + return + } + a.setSessionCookie(w, login) + audit.Log(auditEvent(r, auditLoginOK, login)) + // Only redirect back to our own hosts (defence against open-redirect). + if !a.safeReturn(ret) { + ret = "https://" + a.HubHost + "/" + } + http.Redirect(w, r, ret, http.StatusFound) +} + +// handleLogout (POST /auth/logout) clears the session cookie. +func (a *AuthConfig) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + login := a.sessionLogin(r) + a.clearSessionCookie(w) + a.auditSink().Log(auditEvent(r, audit.EventLogout, login)) + w.WriteHeader(http.StatusNoContent) +} + +// safeReturn allows redirecting only to the hub host or a subdomain of the cookie +// domain, so a crafted `return` can't bounce the browser off-site after login. +func (a *AuthConfig) safeReturn(ret string) bool { + u, err := url.Parse(ret) + if err != nil || u.Scheme != "https" { + return false + } + h := stripPort(u.Host) + if h == a.HubHost { + return true + } + return a.CookieDomain != "" && strings.HasSuffix(h, a.CookieDomain) +} + +func (a *AuthConfig) exchangeCode(code string) (string, error) { + q := url.Values{ + "client_id": {a.GitHubClientID}, + "client_secret": {a.GitHubClientSecret}, + "code": {code}, + "redirect_uri": {a.callbackURL()}, + } + req, _ := http.NewRequest(http.MethodPost, a.oauthBase()+"/login/oauth/access_token", strings.NewReader(q.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := a.client().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var body struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", err + } + return body.AccessToken, nil +} + +func (a *AuthConfig) fetchLogin(token string) (string, error) { + req, _ := http.NewRequest(http.MethodGet, a.apiBase()+"/user", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := a.client().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var body struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", err + } + return body.Login, nil +} + +// audit event constants aliased for brevity in this file. +const ( + auditLoginOK = audit.EventLoginOK +) + +func auditEvent(r *http.Request, event, login string) audit.Event { + return audit.Event{Event: event, Login: login, IP: clientIP(r), Outcome: "ok"} +} + +func auditEventFail(r *http.Request, login, detail string) audit.Event { + return audit.Event{Event: audit.EventCallbackFail, Login: login, IP: clientIP(r), Outcome: "fail", Detail: detail} +} + +// authorizePreview enforces the owner check on a preview request. It returns true +// when the request may proceed; otherwise it has already written the response +// (302 to login when unauthenticated, 403 when the viewer isn't the owner) and +// the caller must stop. In open mode (auth disabled) or for an unowned backend it +// always allows. +func (a *AuthConfig) authorizePreview(w http.ResponseWriter, r *http.Request, b *Backend, publicHost string) bool { + if !a.enabled() || b.Owner == "" { + return true + } + login := a.sessionLogin(r) + if login == "" { + // Send the browser through GitHub, returning to the originating URL. + ret := "https://" + publicHost + r.URL.RequestURI() + loginURL := "https://" + a.HubHost + "/auth/github/login?return=" + url.QueryEscape(ret) + http.Redirect(w, r, loginURL, http.StatusFound) + return false + } + if login == b.Owner { + return true + } + a.auditSink().Log(audit.Event{ + Event: audit.EventAccessDeny, Login: login, IP: clientIP(r), + Subdomain: b.Subdomain, Owner: b.Owner, Outcome: "deny", Detail: "not owner", + }) + http.Error(w, "403 — this preview belongs to another user", http.StatusForbidden) + return false +} diff --git a/cmd/mxcli/tunnelhub/auth_test.go b/cmd/mxcli/tunnelhub/auth_test.go new file mode 100644 index 000000000..54ca844cb --- /dev/null +++ b/cmd/mxcli/tunnelhub/auth_test.go @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" +) + +// testAuth builds an AuthConfig with a fixed clock and no real GitHub endpoints. +func testAuth(now time.Time) *AuthConfig { + return &AuthConfig{ + GitHubClientID: "cid", + GitHubClientSecret: "csecret", + SessionSecret: []byte("test-session-secret-0123456789ab"), + CookieDomain: ".mxcli.org", + HubHost: "hub.mxcli.org", + now: func() time.Time { return now }, + } +} + +func TestSignVerifySession_RoundTrip(t *testing.T) { + secret := []byte("k") + now := time.Unix(1_700_000_000, 0) + val := signSession(secret, "alice", now.Add(time.Hour)) + + login, ok := verifySession(secret, val, now) + if !ok || login != "alice" { + t.Fatalf("round-trip = %q, %v; want alice, true", login, ok) + } +} + +func TestVerifySession_RejectsTamperedSignature(t *testing.T) { + secret := []byte("k") + now := time.Unix(1_700_000_000, 0) + val := signSession(secret, "alice", now.Add(time.Hour)) + + // Flip the last character of the signature. + tampered := val[:len(val)-1] + flip(val[len(val)-1]) + if _, ok := verifySession(secret, tampered, now); ok { + t.Error("tampered signature must not verify") + } + // A different secret must not verify either. + if _, ok := verifySession([]byte("other"), val, now); ok { + t.Error("wrong secret must not verify") + } + // Garbage without a dot separator. + if _, ok := verifySession(secret, "nonsense", now); ok { + t.Error("malformed cookie must not verify") + } +} + +func TestVerifySession_RejectsExpired(t *testing.T) { + secret := []byte("k") + issued := time.Unix(1_700_000_000, 0) + val := signSession(secret, "alice", issued.Add(time.Hour)) + + // One second past expiry. + if _, ok := verifySession(secret, val, issued.Add(time.Hour+time.Second)); ok { + t.Error("expired session must not verify") + } +} + +func flip(b byte) string { + if b == 'A' { + return "B" + } + return "A" +} + +func TestSignVerifyState_RoundTrip(t *testing.T) { + a := testAuth(time.Unix(1_700_000_000, 0)) + ret := "https://app.mxcli.org/some/path?q=1" + state := a.signState(ret) + + got, ok := a.verifyState(state) + if !ok || got != ret { + t.Fatalf("state round-trip = %q, %v; want %q, true", got, ok, ret) + } +} + +func TestVerifyState_RejectsExpired(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + a := testAuth(now) + state := a.signState("https://app.mxcli.org/") + + // Advance the clock past the 10-minute state TTL. + a.now = func() time.Time { return now.Add(11 * time.Minute) } + if _, ok := a.verifyState(state); ok { + t.Error("expired state must not verify") + } +} + +func TestSessionLogin_OpenModeAndInvalid(t *testing.T) { + // Open mode (no client id): always "". + open := &AuthConfig{} + r := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) + if got := open.sessionLogin(r); got != "" { + t.Errorf("open-mode sessionLogin = %q, want empty", got) + } + + // Enabled but no cookie: "". + a := testAuth(time.Unix(1_700_000_000, 0)) + if got := a.sessionLogin(r); got != "" { + t.Errorf("no-cookie sessionLogin = %q, want empty", got) + } + + // Enabled, valid cookie: the login. + w := httptest.NewRecorder() + a.setSessionCookie(w, "alice") + r2 := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) + for _, c := range w.Result().Cookies() { + r2.AddCookie(c) + } + if got := a.sessionLogin(r2); got != "alice" { + t.Errorf("valid-cookie sessionLogin = %q, want alice", got) + } +} + +// stubGitHub stands in for github.com (OAuth) and api.github.com (user), returning +// a fixed access token and login. +func stubGitHub(t *testing.T, login string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"gho_test","token_type":"bearer"}`)) + }) + mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer gho_test" { + t.Errorf("user request auth = %q, want Bearer gho_test", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"login":"` + login + `"}`)) + }) + return httptest.NewServer(mux) +} + +func TestCallback_SetsSessionAndAudits(t *testing.T) { + gh := stubGitHub(t, "alice") + defer gh.Close() + + var buf bytes.Buffer + now := time.Unix(1_700_000_000, 0) + a := testAuth(now) + a.githubOAuthBase = gh.URL + a.githubAPIBase = gh.URL + a.httpClient = gh.Client() + a.Audit = audit.NewWriter(&buf) + + ret := "https://app.mxcli.org/dashboard" + state := a.signState(ret) + r := httptest.NewRequest(http.MethodGet, "https://hub.mxcli.org/auth/github/callback?code=abc&state="+url.QueryEscape(state), nil) + w := httptest.NewRecorder() + a.handleCallback(w, r) + + resp := w.Result() + if resp.StatusCode != http.StatusFound { + t.Fatalf("callback status = %d, want 302", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != ret { + t.Errorf("redirect = %q, want %q", loc, ret) + } + // A valid session cookie was set for alice. + var found bool + for _, c := range resp.Cookies() { + if c.Name == sessionCookieName { + found = true + if login, ok := verifySession(a.SessionSecret, c.Value, now); !ok || login != "alice" { + t.Errorf("session cookie login = %q, ok=%v; want alice", login, ok) + } + } + } + if !found { + t.Error("no session cookie set on successful callback") + } + if !strings.Contains(buf.String(), `"event":"login_ok"`) || !strings.Contains(buf.String(), `"login":"alice"`) { + t.Errorf("expected login_ok audit line, got: %s", buf.String()) + } +} + +func TestCallback_InvalidStateIsRejected(t *testing.T) { + var buf bytes.Buffer + a := testAuth(time.Unix(1_700_000_000, 0)) + a.Audit = audit.NewWriter(&buf) + + r := httptest.NewRequest(http.MethodGet, "https://hub.mxcli.org/auth/github/callback?code=abc&state=forged", nil) + w := httptest.NewRecorder() + a.handleCallback(w, r) + + if w.Result().StatusCode != http.StatusBadRequest { + t.Errorf("forged state status = %d, want 400", w.Result().StatusCode) + } + if !strings.Contains(buf.String(), `"event":"callback_fail"`) { + t.Errorf("expected callback_fail audit line, got: %s", buf.String()) + } +} + +func TestAuthorizePreview_OpenModeAllows(t *testing.T) { + open := &AuthConfig{} // disabled + b := &Backend{Subdomain: "app", Owner: "alice"} + r := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) + w := httptest.NewRecorder() + if !open.authorizePreview(w, r, b, "app.mxcli.org") { + t.Error("open mode must always allow") + } +} + +func TestAuthorizePreview_UnownedAllows(t *testing.T) { + a := testAuth(time.Unix(1_700_000_000, 0)) + b := &Backend{Subdomain: "app"} // no Owner + r := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) + w := httptest.NewRecorder() + if !a.authorizePreview(w, r, b, "app.mxcli.org") { + t.Error("an unowned backend must be public even when auth is on") + } +} + +func TestAuthorizePreview_NoSessionRedirectsToLogin(t *testing.T) { + a := testAuth(time.Unix(1_700_000_000, 0)) + b := &Backend{Subdomain: "app", Owner: "alice"} + r := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/dashboard?x=1", nil) + w := httptest.NewRecorder() + + if a.authorizePreview(w, r, b, "app.mxcli.org") { + t.Fatal("unauthenticated request must not be allowed") + } + resp := w.Result() + if resp.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want 302", resp.StatusCode) + } + loc := resp.Header.Get("Location") + if !strings.HasPrefix(loc, "https://hub.mxcli.org/auth/github/login?return=") { + t.Errorf("redirect = %q, want a hub login URL", loc) + } + if !strings.Contains(loc, url.QueryEscape("https://app.mxcli.org/dashboard?x=1")) { + t.Errorf("login redirect must carry the original URL as return, got %q", loc) + } +} + +func TestAuthorizePreview_OwnerAllowsOtherDenies(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + b := &Backend{Subdomain: "app", Owner: "alice"} + + // Owner alice: allowed. + a := testAuth(now) + w := httptest.NewRecorder() + a.setSessionCookie(w, "alice") + r := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) + for _, c := range w.Result().Cookies() { + r.AddCookie(c) + } + if !a.authorizePreview(httptest.NewRecorder(), r, b, "app.mxcli.org") { + t.Error("the owner must be allowed") + } + + // Non-owner bob: 403 + access_deny audit line. + var buf bytes.Buffer + a2 := testAuth(now) + a2.Audit = audit.NewWriter(&buf) + w2 := httptest.NewRecorder() + a2.setSessionCookie(w2, "bob") + r2 := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) + for _, c := range w2.Result().Cookies() { + r2.AddCookie(c) + } + deny := httptest.NewRecorder() + if a2.authorizePreview(deny, r2, b, "app.mxcli.org") { + t.Fatal("a non-owner must be denied") + } + if deny.Result().StatusCode != http.StatusForbidden { + t.Errorf("non-owner status = %d, want 403", deny.Result().StatusCode) + } + if !strings.Contains(buf.String(), `"event":"access_deny"`) || + !strings.Contains(buf.String(), `"login":"bob"`) || + !strings.Contains(buf.String(), `"owner":"alice"`) { + t.Errorf("expected access_deny audit line for bob→alice, got: %s", buf.String()) + } +} + +func TestClientIP_PrefersForwardedFor(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) + r.RemoteAddr = "10.0.0.1:5555" + r.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1") + if got := clientIP(r); got != "203.0.113.7" { + t.Errorf("clientIP = %q, want 203.0.113.7", got) + } + r.Header.Del("X-Forwarded-For") + if got := clientIP(r); got != "10.0.0.1" { + t.Errorf("clientIP fallback = %q, want 10.0.0.1", got) + } +} + +func TestLogout_ClearsCookieAndAudits(t *testing.T) { + var buf bytes.Buffer + a := testAuth(time.Unix(1_700_000_000, 0)) + a.Audit = audit.NewWriter(&buf) + + // GET is rejected. + rGet := httptest.NewRequest(http.MethodGet, "https://hub.mxcli.org/auth/logout", nil) + wGet := httptest.NewRecorder() + a.handleLogout(wGet, rGet) + if wGet.Result().StatusCode != http.StatusMethodNotAllowed { + t.Errorf("GET logout status = %d, want 405", wGet.Result().StatusCode) + } + + // POST clears the cookie and records logout. + setW := httptest.NewRecorder() + a.setSessionCookie(setW, "alice") + r := httptest.NewRequest(http.MethodPost, "https://hub.mxcli.org/auth/logout", nil) + for _, c := range setW.Result().Cookies() { + r.AddCookie(c) + } + w := httptest.NewRecorder() + a.handleLogout(w, r) + if w.Result().StatusCode != http.StatusNoContent { + t.Errorf("POST logout status = %d, want 204", w.Result().StatusCode) + } + var cleared bool + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName && c.MaxAge < 0 { + cleared = true + } + } + if !cleared { + t.Error("logout must expire the session cookie") + } + if !strings.Contains(buf.String(), `"event":"logout"`) { + t.Errorf("expected logout audit line, got: %s", buf.String()) + } +} From 3154597aeade8577749ac0936ca7cc564274af81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:38:06 +0000 Subject: [PATCH 05/17] hub-auth slice 2: wire OAuth auth into the hub server + CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/cmd_tunnelhub.go | 83 ++++++++++++++++++- cmd/mxcli/tunnelhub/api.go | 16 ++-- cmd/mxcli/tunnelhub/auth.go | 7 +- cmd/mxcli/tunnelhub/auth_test.go | 15 ++++ cmd/mxcli/tunnelhub/server.go | 17 ++++ .../PROPOSAL_hub_authentication.md | 23 +++-- 6 files changed, 142 insertions(+), 19 deletions(-) diff --git a/cmd/mxcli/cmd_tunnelhub.go b/cmd/mxcli/cmd_tunnelhub.go index 0fced4aec..7de349c58 100644 --- a/cmd/mxcli/cmd_tunnelhub.go +++ b/cmd/mxcli/cmd_tunnelhub.go @@ -4,6 +4,7 @@ package main import ( "context" + "crypto/rand" "fmt" "os" "os/signal" @@ -11,6 +12,7 @@ import ( "syscall" "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub" + "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" "github.com/spf13/cobra" ) @@ -37,13 +39,21 @@ control (a small VPS with a domain). DNS: point a wildcard '*.' A record (and 'hub.') at this host. TLS is issued per subdomain via Let's Encrypt on demand (needs inbound 80+443). -Security: set --secret and keep the hub to people you trust — this version uses a -single shared secret and open registration, so anyone with it can register a -preview (per-tenant auth is a follow-up). Don't expose it to the public. +Security: set --secret and keep the hub to people you trust — the shared secret +gates registration. For a shared, internet-facing hub add GitHub OAuth with +--github-oauth-client-id / --github-oauth-client-secret: viewers sign in with +GitHub, each preview is owned by its registrar, the admin listing is filtered to +the viewer's own previews, and (with --require-auth, on by default) a non-owner +gets a 403. --audit-log records who signed in and who was denied. Example (on your own VPS; *.example.com -> this host, ports 80+443 open): mxcli tunnel-hub --domain example.com --secret alice:s3cret +With GitHub OAuth (create an OAuth App with callback https://hub.example.com/auth/github/callback): + mxcli tunnel-hub --domain example.com --secret alice:s3cret \ + --github-oauth-client-id --github-oauth-client-secret \ + --session-secret --audit-log ~/.mxcli/hub-audit.jsonl + Then, in each app's environment: mxcli run --hub https://hub.example.com --hub-secret alice:s3cret \ --hub-solution CustomerPortal -p app.mpr @@ -55,6 +65,12 @@ Then, in each app's environment: httpsPort, _ := cmd.Flags().GetInt("port") httpPort, _ := cmd.Flags().GetInt("http-port") certCache, _ := cmd.Flags().GetString("cert-cache") + ghClientID, _ := cmd.Flags().GetString("github-oauth-client-id") + ghClientSecret, _ := cmd.Flags().GetString("github-oauth-client-secret") + sessionSecret, _ := cmd.Flags().GetString("session-secret") + cookieDomain, _ := cmd.Flags().GetString("cookie-domain") + requireAuth, _ := cmd.Flags().GetBool("require-auth") + auditLog, _ := cmd.Flags().GetString("audit-log") if domain == "" { fmt.Fprintln(os.Stderr, "Error: --domain is required (the wildcard base, e.g. example.com)") @@ -64,6 +80,21 @@ Then, in each app's environment: home, _ := os.UserHomeDir() certCache = filepath.Join(home, ".mxcli", "hub-certs") } + // Env fallbacks so secrets need not appear in the process table. + if ghClientSecret == "" { + ghClientSecret = os.Getenv("MXCLI_HUB_GH_SECRET") + } + if sessionSecret == "" { + sessionSecret = os.Getenv("MXCLI_HUB_SESSION_SECRET") + } + + auditSink, err := audit.New(auditLog) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: opening --audit-log %q: %v\n", auditLog, err) + os.Exit(1) + } + + auth := buildHubAuth(hubHost, domain, cookieDomain, ghClientID, ghClientSecret, sessionSecret, requireAuth, auditSink) reg := tunnelhub.NewRegistry(tunnelhub.RegistryOptions{Domain: domain}) srv, err := tunnelhub.NewServer(tunnelhub.ServerOptions{ @@ -73,11 +104,19 @@ Then, in each app's environment: TunnelAuth: secret, RegisterSecret: secret, CertCacheDir: certCache, + Auth: auth, }) if err != nil { fmt.Fprintf(os.Stderr, "Error: configuring tunnel-hub: %v\n", err) os.Exit(1) } + if auth != nil { + mode := "soft (owner-filtered listing, previews open)" + if requireAuth { + mode = "required (owner check enforced on previews)" + } + fmt.Printf(" auth: GitHub OAuth enabled, %s\n", mode) + } host := hubHost if host == "" { @@ -96,6 +135,38 @@ Then, in each app's environment: }, } +// buildHubAuth assembles the AuthConfig from the CLI flags, or returns nil (open +// mode) when no GitHub OAuth client id is configured. When auth is on but no +// --session-secret is given, it generates an ephemeral key and warns that +// sessions won't survive a hub restart. +func buildHubAuth(hubHost, domain, cookieDomain, clientID, clientSecret, sessionSecret string, requireAuth bool, sink audit.Sink) *tunnelhub.AuthConfig { + if clientID == "" { + return nil // open mode + } + if hubHost == "" { + hubHost = "hub." + domain + } + if cookieDomain == "" { + cookieDomain = "." + domain // SSO across every *. preview + } + secretBytes := []byte(sessionSecret) + if len(secretBytes) == 0 { + secretBytes = make([]byte, 32) + _, _ = rand.Read(secretBytes) + fmt.Fprintln(os.Stderr, "Warning: no --session-secret (or MXCLI_HUB_SESSION_SECRET) set; "+ + "using an ephemeral key — existing sessions are invalidated on every hub restart.") + } + return &tunnelhub.AuthConfig{ + GitHubClientID: clientID, + GitHubClientSecret: clientSecret, + SessionSecret: secretBytes, + CookieDomain: cookieDomain, + HubHost: hubHost, + RequireAuth: requireAuth, + Audit: sink, + } +} + func init() { tunnelHubCmd.Flags().String("domain", "", "Wildcard base domain you control, e.g. example.com (previews served at .)") tunnelHubCmd.Flags().String("hub-host", "", "Control/admin host (default hub.)") @@ -103,5 +174,11 @@ func init() { tunnelHubCmd.Flags().Int("port", 443, "HTTPS port to listen on") tunnelHubCmd.Flags().Int("http-port", 80, "HTTP port for ACME challenges + http->https redirect") tunnelHubCmd.Flags().String("cert-cache", "", "Directory for Let's Encrypt certificates (default ~/.mxcli/hub-certs)") + tunnelHubCmd.Flags().String("github-oauth-client-id", "", "GitHub OAuth App client id; enables the viewer auth plane (empty = open mode)") + tunnelHubCmd.Flags().String("github-oauth-client-secret", "", "GitHub OAuth App client secret (or env MXCLI_HUB_GH_SECRET)") + tunnelHubCmd.Flags().String("session-secret", "", "HMAC key for the SSO session cookie (or env MXCLI_HUB_SESSION_SECRET); random if unset") + tunnelHubCmd.Flags().String("cookie-domain", "", "Session cookie domain for SSO across previews (default .)") + tunnelHubCmd.Flags().Bool("require-auth", true, "Enforce the owner check on previews (deny non-owners); --require-auth=false leaves owned previews open but still filters the listing") + tunnelHubCmd.Flags().String("audit-log", "", "Append-only JSONL audit trail path (\"stdout\" for stdout; empty = off)") rootCmd.AddCommand(tunnelHubCmd) } diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 25c30f8eb..f557d46f9 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -22,6 +22,10 @@ type APIOptions struct { RegisterSecret string // HeartbeatIntervalSec is how often the client should heartbeat (default 20). HeartbeatIntervalSec int + // Auth, when enabled, resolves the viewer's GitHub login from the session + // cookie so /api/backends is filtered to the caller's own previews. Nil or + // open-mode → "" (list everything, today's behaviour). + Auth *AuthConfig } // API serves the hub's registration + query endpoints over the registry. @@ -127,16 +131,12 @@ func (a *API) byToken(w http.ResponseWriter, r *http.Request, fn func(token stri } func (a *API) handleBackends(w http.ResponseWriter, r *http.Request) { - // viewerLogin is "" until the session-cookie middleware (slice 2) supplies the - // authenticated GitHub login; "" lists all backends (self-hosted / auth off). - writeJSON(w, http.StatusOK, a.opts.Registry.List(r.URL.Query().Get("sort"), viewerLogin(r))) + // In open mode Auth.sessionLogin returns "" and every backend is listed; + // with auth on it returns the viewer's GitHub login so the list is filtered + // to the caller's own previews. + writeJSON(w, http.StatusOK, a.opts.Registry.List(r.URL.Query().Get("sort"), a.opts.Auth.sessionLogin(r))) } -// viewerLogin returns the authenticated GitHub login for a request, or "" when -// auth is off (self-hosted) or the viewer is anonymous. Slice 1 always returns -// "" (no auth yet); slice 2's session-cookie middleware fills this in. -func viewerLogin(_ *http.Request) string { return "" } - func bearerToken(r *http.Request) string { if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) diff --git a/cmd/mxcli/tunnelhub/auth.go b/cmd/mxcli/tunnelhub/auth.go index 454d09b47..ecb9d9acf 100644 --- a/cmd/mxcli/tunnelhub/auth.go +++ b/cmd/mxcli/tunnelhub/auth.go @@ -363,10 +363,11 @@ func auditEventFail(r *http.Request, login, detail string) audit.Event { // authorizePreview enforces the owner check on a preview request. It returns true // when the request may proceed; otherwise it has already written the response // (302 to login when unauthenticated, 403 when the viewer isn't the owner) and -// the caller must stop. In open mode (auth disabled) or for an unowned backend it -// always allows. +// the caller must stop. It only enforces when auth is enabled AND RequireAuth is +// set: in open mode, soft mode (auth on for listing but RequireAuth off), or for +// an unowned backend it always allows. func (a *AuthConfig) authorizePreview(w http.ResponseWriter, r *http.Request, b *Backend, publicHost string) bool { - if !a.enabled() || b.Owner == "" { + if !a.enabled() || !a.RequireAuth || b.Owner == "" { return true } login := a.sessionLogin(r) diff --git a/cmd/mxcli/tunnelhub/auth_test.go b/cmd/mxcli/tunnelhub/auth_test.go index 54ca844cb..584f79b85 100644 --- a/cmd/mxcli/tunnelhub/auth_test.go +++ b/cmd/mxcli/tunnelhub/auth_test.go @@ -15,6 +15,7 @@ import ( ) // testAuth builds an AuthConfig with a fixed clock and no real GitHub endpoints. +// RequireAuth is on so the preview owner-check tests exercise enforcement. func testAuth(now time.Time) *AuthConfig { return &AuthConfig{ GitHubClientID: "cid", @@ -22,6 +23,7 @@ func testAuth(now time.Time) *AuthConfig { SessionSecret: []byte("test-session-secret-0123456789ab"), CookieDomain: ".mxcli.org", HubHost: "hub.mxcli.org", + RequireAuth: true, now: func() time.Time { return now }, } } @@ -223,6 +225,19 @@ func TestAuthorizePreview_UnownedAllows(t *testing.T) { } } +func TestAuthorizePreview_SoftModeLeavesPreviewsOpen(t *testing.T) { + // Auth enabled for listing, but RequireAuth off: an owned preview is still + // reachable by anyone (the listing is filtered, the preview itself is open). + a := testAuth(time.Unix(1_700_000_000, 0)) + a.RequireAuth = false + b := &Backend{Subdomain: "app", Owner: "alice"} + r := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) + w := httptest.NewRecorder() + if !a.authorizePreview(w, r, b, "app.mxcli.org") { + t.Error("soft mode (RequireAuth off) must leave previews open") + } +} + func TestAuthorizePreview_NoSessionRedirectsToLogin(t *testing.T) { a := testAuth(time.Unix(1_700_000_000, 0)) b := &Backend{Subdomain: "app", Owner: "alice"} diff --git a/cmd/mxcli/tunnelhub/server.go b/cmd/mxcli/tunnelhub/server.go index fc8c39e67..a1fbe5436 100644 --- a/cmd/mxcli/tunnelhub/server.go +++ b/cmd/mxcli/tunnelhub/server.go @@ -44,6 +44,11 @@ type ServerOptions struct { // chiselAddr is the internal address the embedded chisel control server binds // (default 127.0.0.1:8100). Not public — the front proxies the WS here. ChiselAddr string + // Auth, when enabled, adds the GitHub OAuth viewer plane: /auth/* on the hub + // host, a session cookie, backend-list filtering, and (when RequireAuth) an + // owner check on preview + admin access. Nil / open mode preserves today's + // behaviour. + Auth *AuthConfig } // Server is the running multi-tenant hub: one embedded chisel reverse server @@ -89,6 +94,7 @@ func NewServer(o ServerOptions) (*Server, error) { ControlURL: "https://" + o.HubHost, TunnelAuth: o.TunnelAuth, RegisterSecret: o.RegisterSecret, + Auth: o.Auth, }) apiMux := http.NewServeMux() api.Mount(apiMux) @@ -175,6 +181,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.apiMux.ServeHTTP(w, r) return } + // The OAuth login/callback/logout endpoints live on the hub host so the + // SSO cookie is issued for the whole cookie domain. + if s.opts.Auth.enabled() && strings.HasPrefix(r.URL.Path, "/auth/") { + s.opts.Auth.authHandler().ServeHTTP(w, r) + return + } s.admin.ServeHTTP(w, r) default: sub, ok := s.subOf(host) @@ -187,6 +199,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeNoSuchPreview(w, host) return } + // Enforce the owner check before proxying (no-op in open mode / for an + // unowned backend). On deny it has already written 302/403. + if !s.opts.Auth.authorizePreview(w, r, b, host) { + return + } s.reg.TouchUsed(sub) ctx := context.WithValue(r.Context(), targetPortKey, b.ReversePort) ctx = context.WithValue(ctx, publicHostKey, host) diff --git a/docs/11-proposals/PROPOSAL_hub_authentication.md b/docs/11-proposals/PROPOSAL_hub_authentication.md index 2423584e4..4ad42a48a 100644 --- a/docs/11-proposals/PROPOSAL_hub_authentication.md +++ b/docs/11-proposals/PROPOSAL_hub_authentication.md @@ -270,11 +270,24 @@ lines (no login). See *Open questions* on retention default and JSONL-vs-SQLite. `viewerLogin(r)` seam (returns `""` until slice 2's cookie) threads through `/api/backends` + the reaper. Tests: `TestList_FiltersByOwner`, `TestIdentity_OwnerDisambiguates`. -2. **GitHub OAuth web flow + session cookie**: `/auth/github/*`, signing, middleware on - preview + admin; skip WS/ACME. **Introduces the audit sink** (`audit` package + - `--audit-log`) and emits `login_ok`/`logout`/`callback_fail`/`access_deny`. Tests: - middleware allow/deny, cookie round-trip (httptest, GitHub stubbed), and an - `access_deny` line is written on a 403. +2. **GitHub OAuth web flow + session cookie** — ✅ **done**: `AuthConfig` (zero value = + open mode), `/auth/github/{login,callback,logout}` on the hub host, an HMAC-signed + SSO session cookie (`signSession`/`verifySession`, `Domain=.`), a signed + OAuth `state` carrying the return URL with an open-redirect guard, and + `authorizePreview` on the preview path (enforced when `--require-auth`, default on; + soft mode filters the listing but leaves owned previews open). Wired into + `server.go` (mounts `/auth/*`, gates previews) and `api.go` (`/api/backends` filters + by `Auth.sessionLogin(r)`, replacing the slice-1 `viewerLogin` stub). **Introduced the + audit sink** (`cmd/mxcli/tunnelhub/audit` package + `--audit-log`, JSONL mode `0600`, + `Event` has no secret field) emitting `login_ok`/`logout`/`callback_fail`/`access_deny`. + Flags: `--github-oauth-client-id`, `--github-oauth-client-secret` + (env `MXCLI_HUB_GH_SECRET`), `--session-secret` (env `MXCLI_HUB_SESSION_SECRET`), + `--cookie-domain`, `--require-auth`, `--audit-log`. Tests (`auth_test.go`, + `audit_test.go`): cookie + state sign/verify round-trip (valid/tampered/expired), + open-mode no-op, stubbed-GitHub callback sets the session + writes `login_ok`, forged + state → 400 + `callback_fail`, preview authorize allow/redirect/deny with an + `access_deny` line on the 403, soft-mode leaves previews open, logout clears + audits, + and the audit `Event` carries no secret field. 3. **Hub API keys + registration by key**: `POST/DELETE /api/keys`, `X-Hub-Key` on `/api/register` → stamp `Owner`; keep `X-Hub-Secret` for open mode. Emits `key_mint`/`key_revoke`/`register_ok`/`register_deny`. Tests: mint/resolve/revoke, From fb6b26107ef67052e7bb6f8e349ee84fbdc70091 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:35:57 +0000 Subject: [PATCH 06/17] hub-auth slice 3: hub API keys + register-by-key (stamp Owner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/cmd_tunnelhub.go | 1 + cmd/mxcli/tunnelhub/api.go | 115 +++++++++++++++++- cmd/mxcli/tunnelhub/api_keys_test.go | 174 +++++++++++++++++++++++++++ cmd/mxcli/tunnelhub/keys.go | 79 ++++++++++++ cmd/mxcli/tunnelhub/keys_test.go | 69 +++++++++++ cmd/mxcli/tunnelhub/registry.go | 4 + cmd/mxcli/tunnelhub/server.go | 10 ++ 7 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 cmd/mxcli/tunnelhub/api_keys_test.go create mode 100644 cmd/mxcli/tunnelhub/keys.go create mode 100644 cmd/mxcli/tunnelhub/keys_test.go diff --git a/cmd/mxcli/cmd_tunnelhub.go b/cmd/mxcli/cmd_tunnelhub.go index 7de349c58..82813fa4a 100644 --- a/cmd/mxcli/cmd_tunnelhub.go +++ b/cmd/mxcli/cmd_tunnelhub.go @@ -105,6 +105,7 @@ Then, in each app's environment: RegisterSecret: secret, CertCacheDir: certCache, Auth: auth, + Audit: auditSink, }) if err != nil { fmt.Fprintf(os.Stderr, "Error: configuring tunnel-hub: %v\n", err) diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index f557d46f9..6e66921df 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -6,6 +6,8 @@ import ( "encoding/json" "net/http" "strings" + + "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" ) // APIOptions configures the registration API. @@ -26,6 +28,11 @@ type APIOptions struct { // cookie so /api/backends is filtered to the caller's own previews. Nil or // open-mode → "" (list everything, today's behaviour). Auth *AuthConfig + // Keys is the hub API-key store backing /api/keys and X-Hub-Key registration. + // Nil disables the key endpoints (open mode uses the legacy X-Hub-Secret). + Keys *KeyStore + // Audit receives key/registration events. Nil → audit.NoOp(). + Audit audit.Sink } // API serves the hub's registration + query endpoints over the registry. @@ -33,6 +40,13 @@ type API struct { opts APIOptions } +// KeyResponse is returned by POST /api/keys after a successful mint. The key is +// shown exactly once; the client caches it in ~/.mxcli/auth.json. +type KeyResponse struct { + Key string `json:"key"` + Login string `json:"login"` +} + // RegisterResponse is returned to `mxcli run --hub` after registration. type RegisterResponse struct { Subdomain string `json:"subdomain"` @@ -49,6 +63,9 @@ func NewAPI(o APIOptions) *API { if o.HeartbeatIntervalSec == 0 { o.HeartbeatIntervalSec = 20 } + if o.Audit == nil { + o.Audit = audit.NoOp() + } return &API{opts: o} } @@ -58,6 +75,7 @@ func (a *API) Mount(mux *http.ServeMux) { mux.HandleFunc("/api/status", a.handleStatus) mux.HandleFunc("/api/deregister", a.handleDeregister) mux.HandleFunc("/api/backends", a.handleBackends) + mux.HandleFunc("/api/keys", a.handleKeys) } func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { @@ -65,9 +83,10 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if a.opts.RegisterSecret != "" && r.Header.Get("X-Hub-Secret") != a.opts.RegisterSecret { - http.Error(w, "invalid or missing hub secret", http.StatusUnauthorized) - return + // Authenticate the registrant and (when auth is on) learn the owner login. + owner, ok := a.authorizeRegister(w, r) + if !ok { + return // authorizeRegister has written the 401 + a register_deny audit line } var req RegisterRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil { @@ -78,11 +97,16 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { http.Error(w, "project is required", http.StatusBadRequest) return } + req.Owner = owner // server-derived; never trusted from the body b, err := a.opts.Registry.Register(req) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return } + a.opts.Audit.Log(audit.Event{ + Event: audit.EventRegisterOK, Login: owner, IP: clientIP(r), + Subdomain: b.Subdomain, Owner: owner, Outcome: "ok", + }) host := b.Subdomain if d := a.opts.Registry.domain; d != "" { host = b.Subdomain + "." + d @@ -98,6 +122,91 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { }) } +// authorizeRegister decides whether a /api/register call may proceed and, when +// auth is on, resolves the X-Hub-Key to the owner login. It writes the 401 + +// register_deny audit line itself on rejection and returns ok=false. +// +// - Open mode (auth off): the legacy X-Hub-Secret gate; owner is "". +// - Auth on: an X-Hub-Key is resolved to a login and stamped as owner. With +// --require-auth (default) a missing/invalid key is rejected; in soft mode a +// keyless registration is allowed but stays owner-less. +func (a *API) authorizeRegister(w http.ResponseWriter, r *http.Request) (owner string, ok bool) { + if !a.opts.Auth.enabled() { + if a.opts.RegisterSecret != "" && r.Header.Get("X-Hub-Secret") != a.opts.RegisterSecret { + http.Error(w, "invalid or missing hub secret", http.StatusUnauthorized) + return "", false + } + return "", true + } + key := strings.TrimSpace(r.Header.Get("X-Hub-Key")) + if a.opts.Keys != nil { + if login, found := a.opts.Keys.Resolve(key); found { + return login, true + } + } + // No valid key. Enforce only when required; soft mode registers anonymously. + if a.opts.Auth.RequireAuth { + a.opts.Audit.Log(audit.Event{ + Event: audit.EventRegisterDeny, IP: clientIP(r), Outcome: "deny", + Detail: "missing or invalid X-Hub-Key", + }) + http.Error(w, "missing or invalid X-Hub-Key (run 'mxcli auth hub login')", http.StatusUnauthorized) + return "", false + } + return "", true +} + +// handleKeys mints (POST) or revokes (DELETE) a hub API key. Only available when +// auth is configured; the key store is the registration credential that replaces +// the shared secret for a hosted hub. +func (a *API) handleKeys(w http.ResponseWriter, r *http.Request) { + if !a.opts.Auth.enabled() || a.opts.Keys == nil { + http.Error(w, "hub keys are not enabled on this hub", http.StatusNotFound) + return + } + switch r.Method { + case http.MethodPost: + a.handleKeyMint(w, r) + case http.MethodDelete: + a.handleKeyRevoke(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// handleKeyMint validates the caller's GitHub token (Bearer) against GitHub, then +// issues an opaque hub key bound to that login. The GitHub token is used once and +// discarded — never stored. +func (a *API) handleKeyMint(w http.ResponseWriter, r *http.Request) { + token := bearerToken(r) + if token == "" { + http.Error(w, "missing GitHub token (Authorization: Bearer )", http.StatusUnauthorized) + return + } + login, err := a.opts.Auth.fetchLogin(token) + if err != nil || login == "" { + http.Error(w, "could not verify GitHub identity", http.StatusUnauthorized) + return + } + key := a.opts.Keys.Mint(login) + a.opts.Audit.Log(audit.Event{ + Event: audit.EventKeyMint, Login: login, IP: clientIP(r), Outcome: "ok", + }) + writeJSON(w, http.StatusOK, KeyResponse{Key: key, Login: login}) +} + +// handleKeyRevoke removes the presented key (X-Hub-Key). Idempotent: revoking an +// unknown key still returns 204. +func (a *API) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { + key := strings.TrimSpace(r.Header.Get("X-Hub-Key")) + if login, ok := a.opts.Keys.Revoke(key); ok { + a.opts.Audit.Log(audit.Event{ + Event: audit.EventKeyRevoke, Login: login, IP: clientIP(r), Outcome: "ok", + }) + } + w.WriteHeader(http.StatusNoContent) +} + func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) { a.byToken(w, r, func(token string) { if !a.opts.Registry.Heartbeat(token) { diff --git a/cmd/mxcli/tunnelhub/api_keys_test.go b/cmd/mxcli/tunnelhub/api_keys_test.go new file mode 100644 index 000000000..d5fbdb866 --- /dev/null +++ b/cmd/mxcli/tunnelhub/api_keys_test.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "bytes" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" +) + +// newAuthedAPI builds an API with auth enabled, a key store, an audit buffer, and +// GitHub stubbed (for the key-mint /user lookup). +func newAuthedAPI(t *testing.T, requireAuth bool) (*API, *bytes.Buffer, func()) { + t.Helper() + gh := stubGitHub(t, "alice") + var buf bytes.Buffer + sink := audit.NewWriter(&buf) + + auth := testAuth(time.Unix(1_700_000_000, 0)) + auth.RequireAuth = requireAuth + auth.githubAPIBase = gh.URL + auth.httpClient = gh.Client() + auth.Audit = sink + + clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} + api := NewAPI(APIOptions{ + Registry: newTestRegistry(clk), + ControlURL: "https://hub.mxcli.org", + Auth: auth, + Keys: NewKeyStore(), + Audit: sink, + }) + return api, &buf, gh.Close +} + +func mintKey(t *testing.T, api *API, githubToken string) string { + t.Helper() + rec := doJSON(t, api, http.MethodPost, "/api/keys", "", + map[string]string{"Authorization": "Bearer " + githubToken}) + if rec.Code != http.StatusOK { + t.Fatalf("mint status = %d, body %s", rec.Code, rec.Body) + } + var kr KeyResponse + if err := json.Unmarshal(rec.Body.Bytes(), &kr); err != nil { + t.Fatal(err) + } + if kr.Key == "" || kr.Login != "alice" { + t.Fatalf("mint response = %+v", kr) + } + return kr.Key +} + +func TestAPI_MintKeyThenRegisterStampsOwner(t *testing.T) { + api, buf, done := newAuthedAPI(t, true) + defer done() + + key := mintKey(t, api, "gho_test") + + rec := doJSON(t, api, http.MethodPost, "/api/register", + `{"project":"MyApp","branch":"main"}`, map[string]string{"X-Hub-Key": key}) + if rec.Code != http.StatusOK { + t.Fatalf("register status = %d, body %s", rec.Code, rec.Body) + } + + // The backend is stamped with the owner and the list filters to alice. + views := api.opts.Registry.List("project", "alice") + if len(views) != 1 || views[0].Owner != "alice" { + t.Fatalf("registry after register = %+v", views) + } + if got := api.opts.Registry.List("project", "bob"); len(got) != 0 { + t.Errorf("bob should see none of alice's previews, got %d", len(got)) + } + + out := buf.String() + if !strings.Contains(out, `"event":"key_mint"`) || !strings.Contains(out, `"event":"register_ok"`) { + t.Errorf("expected key_mint + register_ok audit lines, got: %s", out) + } + if !strings.Contains(out, `"owner":"alice"`) { + t.Errorf("register_ok should record owner=alice, got: %s", out) + } +} + +func TestAPI_RegisterRequiresKeyWhenRequireAuth(t *testing.T) { + api, buf, done := newAuthedAPI(t, true) + defer done() + + // No X-Hub-Key under --require-auth → 401 + register_deny. + rec := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"A","branch":"main"}`, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("keyless register status = %d, want 401", rec.Code) + } + // An unknown key is likewise rejected. + bad := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"A","branch":"main"}`, + map[string]string{"X-Hub-Key": "not-a-real-key"}) + if bad.Code != http.StatusUnauthorized { + t.Errorf("bogus-key register status = %d, want 401", bad.Code) + } + if !strings.Contains(buf.String(), `"event":"register_deny"`) { + t.Errorf("expected register_deny audit line, got: %s", buf.String()) + } +} + +func TestAPI_SoftModeAllowsAnonymousRegister(t *testing.T) { + api, _, done := newAuthedAPI(t, false) // auth on, require-auth off + defer done() + + rec := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"A","branch":"main"}`, nil) + if rec.Code != http.StatusOK { + t.Fatalf("soft-mode keyless register status = %d, want 200", rec.Code) + } + // It registered without an owner (still visible to everyone). + if got := api.opts.Registry.List("project", ""); len(got) != 1 || got[0].Owner != "" { + t.Errorf("soft-mode register should be owner-less, got %+v", got) + } +} + +func TestAPI_RevokedKeyCannotRegister(t *testing.T) { + api, buf, done := newAuthedAPI(t, true) + defer done() + + key := mintKey(t, api, "gho_test") + // Revoke it. + rev := doJSON(t, api, http.MethodDelete, "/api/keys", "", map[string]string{"X-Hub-Key": key}) + if rev.Code != http.StatusNoContent { + t.Fatalf("revoke status = %d, want 204", rev.Code) + } + // The revoked key no longer authorizes registration. + rec := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"A","branch":"main"}`, + map[string]string{"X-Hub-Key": key}) + if rec.Code != http.StatusUnauthorized { + t.Errorf("register with revoked key status = %d, want 401", rec.Code) + } + if !strings.Contains(buf.String(), `"event":"key_revoke"`) { + t.Errorf("expected key_revoke audit line, got: %s", buf.String()) + } +} + +func TestAPI_MintKeyRejectsBadGitHubToken(t *testing.T) { + // A stub that fails the /user lookup (no token match) → fetchLogin errors. + api, _, done := newAuthedAPI(t, true) + defer done() + + // Missing bearer → 401. + if rec := doJSON(t, api, http.MethodPost, "/api/keys", "", nil); rec.Code != http.StatusUnauthorized { + t.Errorf("mint without token status = %d, want 401", rec.Code) + } +} + +func TestAPI_KeysDisabledInOpenMode(t *testing.T) { + // Open mode: no Auth → /api/keys is 404 (keys only exist with GitHub OAuth). + api, _ := newTestAPI(t, "") + if rec := doJSON(t, api, http.MethodPost, "/api/keys", "", + map[string]string{"Authorization": "Bearer x"}); rec.Code != http.StatusNotFound { + t.Errorf("open-mode mint status = %d, want 404", rec.Code) + } +} + +func TestAPI_OpenModeRegisterUnchanged(t *testing.T) { + // With no Auth, registration still works with (or without) the legacy secret + // and stamps no owner — byte-for-byte the pre-auth behaviour. + api, _ := newTestAPI(t, "") + rec := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"A","branch":"main"}`, nil) + if rec.Code != http.StatusOK { + t.Fatalf("open-mode register status = %d, want 200", rec.Code) + } + if got := api.opts.Registry.List("project", ""); len(got) != 1 || got[0].Owner != "" { + t.Errorf("open-mode register should be owner-less, got %+v", got) + } +} diff --git a/cmd/mxcli/tunnelhub/keys.go b/cmd/mxcli/tunnelhub/keys.go new file mode 100644 index 000000000..8f9e4473b --- /dev/null +++ b/cmd/mxcli/tunnelhub/keys.go @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "crypto/sha256" + "encoding/hex" + "sync" +) + +// KeyStore maps opaque hub API keys to the GitHub login they were minted for. +// Registration presents a key (X-Hub-Key) instead of a GitHub token, so the +// GitHub token never has to reach the hub on the hot path — only once, at mint. +// +// Keys are stored hashed (SHA-256): a dump of the store never yields a usable +// credential, mirroring how the audit trail refuses to carry secrets. The plain +// key is returned to the caller exactly once, at Mint. +// +// In-memory for the first cut (keys are lost on hub restart; a user re-runs +// `mxcli auth hub login`). All methods are safe for concurrent use. +type KeyStore struct { + mu sync.RWMutex + byHash map[string]string // sha256(key) -> login + newToken func() string // overridable for tests; default newToken() +} + +// NewKeyStore returns an empty key store. +func NewKeyStore() *KeyStore { + return &KeyStore{byHash: map[string]string{}} +} + +func hashKey(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +func (s *KeyStore) mintToken() string { + if s.newToken != nil { + return s.newToken() + } + return newToken() +} + +// Mint issues a fresh opaque key bound to login and returns the plain key (shown +// to the caller once — only its hash is retained). +func (s *KeyStore) Mint(login string) string { + key := s.mintToken() + s.mu.Lock() + s.byHash[hashKey(key)] = login + s.mu.Unlock() + return key +} + +// Resolve returns the login a key was minted for, and whether it is known. +func (s *KeyStore) Resolve(key string) (login string, ok bool) { + if key == "" { + return "", false + } + s.mu.RLock() + defer s.mu.RUnlock() + login, ok = s.byHash[hashKey(key)] + return login, ok +} + +// Revoke removes a key. It returns the login it was bound to (for auditing) and +// whether it existed. +func (s *KeyStore) Revoke(key string) (login string, ok bool) { + if key == "" { + return "", false + } + h := hashKey(key) + s.mu.Lock() + defer s.mu.Unlock() + login, ok = s.byHash[h] + if ok { + delete(s.byHash, h) + } + return login, ok +} diff --git a/cmd/mxcli/tunnelhub/keys_test.go b/cmd/mxcli/tunnelhub/keys_test.go new file mode 100644 index 000000000..cd71a7b05 --- /dev/null +++ b/cmd/mxcli/tunnelhub/keys_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import "testing" + +func TestKeyStore_MintResolveRevoke(t *testing.T) { + ks := NewKeyStore() + + key := ks.Mint("alice") + if key == "" { + t.Fatal("Mint returned an empty key") + } + if login, ok := ks.Resolve(key); !ok || login != "alice" { + t.Errorf("Resolve = %q, %v; want alice, true", login, ok) + } + + // Revoke returns the bound login and removes it. + if login, ok := ks.Revoke(key); !ok || login != "alice" { + t.Errorf("Revoke = %q, %v; want alice, true", login, ok) + } + if _, ok := ks.Resolve(key); ok { + t.Error("key still resolves after revoke") + } + // Revoking again is a no-op (idempotent). + if _, ok := ks.Revoke(key); ok { + t.Error("second revoke should report not-found") + } +} + +func TestKeyStore_UnknownAndEmpty(t *testing.T) { + ks := NewKeyStore() + if _, ok := ks.Resolve(""); ok { + t.Error("empty key must not resolve") + } + if _, ok := ks.Resolve("bogus"); ok { + t.Error("unknown key must not resolve") + } +} + +func TestKeyStore_DistinctKeysPerMint(t *testing.T) { + ks := NewKeyStore() + k1 := ks.Mint("alice") + k2 := ks.Mint("alice") + if k1 == k2 { + t.Error("each Mint must return a distinct key") + } + // Both are valid until individually revoked. + if _, ok := ks.Resolve(k1); !ok { + t.Error("k1 should resolve") + } + ks.Revoke(k1) + if _, ok := ks.Resolve(k2); !ok { + t.Error("revoking k1 must not affect k2") + } +} + +// TestKeyStore_StoresHashedNotPlaintext guards the "a store dump yields no usable +// credential" property: the plain key must not appear as a map key. +func TestKeyStore_StoresHashedNotPlaintext(t *testing.T) { + ks := NewKeyStore() + key := ks.Mint("alice") + if _, present := ks.byHash[key]; present { + t.Error("plain key must not be a map key — keys are stored hashed") + } + if _, present := ks.byHash[hashKey(key)]; !present { + t.Error("hashed key should be the map key") + } +} diff --git a/cmd/mxcli/tunnelhub/registry.go b/cmd/mxcli/tunnelhub/registry.go index 8f99e384f..ba4ded4fb 100644 --- a/cmd/mxcli/tunnelhub/registry.go +++ b/cmd/mxcli/tunnelhub/registry.go @@ -66,6 +66,9 @@ type RegisterRequest struct { Branch string `json:"branch"` Worktree string `json:"worktree"` AppPort int `json:"appPort"` + // Owner is set server-side from the X-Hub-Key → login lookup, never trusted + // from the client body (json:"-" keeps it off the wire). + Owner string `json:"-"` } // Registry is the in-memory store of registered backends. All methods are safe @@ -142,6 +145,7 @@ func (r *Registry) Register(req RegisterRequest) (*Backend, error) { Solution: strings.TrimSpace(req.Solution), Branch: strings.TrimSpace(req.Branch), Worktree: strings.TrimSpace(req.Worktree), + Owner: strings.TrimSpace(req.Owner), AppPort: req.AppPort, } if existing, ok := r.byIdentity[b.identity()]; ok { diff --git a/cmd/mxcli/tunnelhub/server.go b/cmd/mxcli/tunnelhub/server.go index a1fbe5436..359c7b58f 100644 --- a/cmd/mxcli/tunnelhub/server.go +++ b/cmd/mxcli/tunnelhub/server.go @@ -14,6 +14,8 @@ import ( chserver "github.com/jpillora/chisel/server" "golang.org/x/crypto/acme/autocert" + + "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" ) // ctxKey is the type for request-context values the front handler passes to the @@ -49,6 +51,9 @@ type ServerOptions struct { // owner check on preview + admin access. Nil / open mode preserves today's // behaviour. Auth *AuthConfig + // Audit receives auth + registration events (login/deny/register/key). Nil → + // audit.NoOp(). The same sink is shared with Auth. + Audit audit.Sink } // Server is the running multi-tenant hub: one embedded chisel reverse server @@ -89,12 +94,17 @@ func NewServer(o ServerOptions) (*Server, error) { return nil, fmt.Errorf("chisel server: %w", err) } + // A shared key store backs /api/keys + X-Hub-Key registration (only reachable + // when Auth is enabled; harmless otherwise). + keys := NewKeyStore() api := NewAPI(APIOptions{ Registry: o.Registry, ControlURL: "https://" + o.HubHost, TunnelAuth: o.TunnelAuth, RegisterSecret: o.RegisterSecret, Auth: o.Auth, + Keys: keys, + Audit: o.Audit, }) apiMux := http.NewServeMux() api.Mount(apiMux) From 853a86e64558f7c2e21a813819785638a73ff0c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:36:18 +0000 Subject: [PATCH 07/17] docs(hub-auth): mark slice 3 done in the proposal Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .../PROPOSAL_hub_authentication.md | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/11-proposals/PROPOSAL_hub_authentication.md b/docs/11-proposals/PROPOSAL_hub_authentication.md index 4ad42a48a..88b1a3074 100644 --- a/docs/11-proposals/PROPOSAL_hub_authentication.md +++ b/docs/11-proposals/PROPOSAL_hub_authentication.md @@ -288,10 +288,22 @@ lines (no login). See *Open questions* on retention default and JSONL-vs-SQLite. state → 400 + `callback_fail`, preview authorize allow/redirect/deny with an `access_deny` line on the 403, soft-mode leaves previews open, logout clears + audits, and the audit `Event` carries no secret field. -3. **Hub API keys + registration by key**: `POST/DELETE /api/keys`, `X-Hub-Key` on - `/api/register` → stamp `Owner`; keep `X-Hub-Secret` for open mode. Emits - `key_mint`/`key_revoke`/`register_ok`/`register_deny`. Tests: mint/resolve/revoke, - register stamps owner, open-mode fallback, audit lines on register accept/reject. +3. **Hub API keys + registration by key** — ✅ **done**: `KeyStore` (mint/resolve/revoke, + keys stored SHA-256-hashed, plain key returned once); `POST /api/keys` validates the + caller's GitHub token via `GET /user` and mints a login-bound key (token discarded, + never stored), `DELETE /api/keys` revokes the presented `X-Hub-Key` (both `404` in open + mode). `/api/register` now runs through `authorizeRegister`: open mode keeps the legacy + `X-Hub-Secret` (owner `""`); auth-on resolves `X-Hub-Key` → login and stamps + `Backend.Owner` (`RegisterRequest.Owner` is `json:"-"`, server-derived only); + `--require-auth` (default) rejects a missing/invalid key, soft mode registers + anonymously. Emits `key_mint`/`key_revoke`/`register_ok`/`register_deny` through + `ServerOptions.Audit` → `APIOptions.Audit` (also in open mode when `--audit-log` is + set). Tests (`keys_test.go`, `api_keys_test.go`): mint/resolve/revoke, + distinct-key-per-mint, hashed-not-plaintext, mint→register stamps owner + filters the + listing, `--require-auth` rejects keyless/bogus with `register_deny`, soft-mode + anonymous register, revoked key can't register, open-mode keys `404` and register + unchanged. **Client is slice 4** (`mxcli run --hub` still sends `X-Hub-Secret` until + then, so enabling `--require-auth` on a hosted hub waits for slice 4). 4. **Client**: `mxcli auth hub login/status/logout` (device flow), `run --hub` sends `X-Hub-Key` (env → auth.json → legacy secret). Tests: auth.json round-trip, header selection. From ddcd8538bfaf2277faffc8941f9bafe272e7ce02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:47:42 +0000 Subject: [PATCH 08/17] hub-auth slice 4: client device-flow login + X-Hub-Key registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:"); 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/cmd_auth_hub.go | 112 +++++++ cmd/mxcli/cmd_run.go | 8 + cmd/mxcli/docker/hubclient.go | 15 +- cmd/mxcli/docker/hubclient_reregister_test.go | 2 +- cmd/mxcli/docker/runlocal.go | 6 +- cmd/mxcli/hubauth/deviceflow.go | 273 ++++++++++++++++++ cmd/mxcli/hubauth/deviceflow_test.go | 151 ++++++++++ cmd/mxcli/hubauth/hubauth.go | 104 +++++++ cmd/mxcli/hubauth/hubauth_test.go | 86 ++++++ cmd/mxcli/tunnelhub/api.go | 27 ++ cmd/mxcli/tunnelhub/api_keys_test.go | 24 ++ internal/auth/credential.go | 4 + 12 files changed, 808 insertions(+), 4 deletions(-) create mode 100644 cmd/mxcli/cmd_auth_hub.go create mode 100644 cmd/mxcli/hubauth/deviceflow.go create mode 100644 cmd/mxcli/hubauth/deviceflow_test.go create mode 100644 cmd/mxcli/hubauth/hubauth.go create mode 100644 cmd/mxcli/hubauth/hubauth_test.go diff --git a/cmd/mxcli/cmd_auth_hub.go b/cmd/mxcli/cmd_auth_hub.go new file mode 100644 index 000000000..5ad41a67a --- /dev/null +++ b/cmd/mxcli/cmd_auth_hub.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + + "github.com/mendixlabs/mxcli/cmd/mxcli/hubauth" + "github.com/spf13/cobra" +) + +// authHubCmd groups the tunnel-hub credential commands under `mxcli auth hub`. +// A hub API key authorizes `mxcli run --hub` to register a preview as you; it is +// minted from your GitHub identity via the device flow and stored per hub host. +var authHubCmd = &cobra.Command{ + Use: "hub", + Short: "Manage tunnel-hub API keys (mxcli run --hub)", + Long: `Authenticate to a tunnel-hub so 'mxcli run --hub' can register previews +under your GitHub identity. + +'mxcli auth hub login' runs the GitHub device flow, exchanges your identity for a +hub-minted API key, and caches it in ~/.mxcli/auth.json keyed by hub host. Your +GitHub token stays on this machine except for the single mint request to the hub. + +For an unattended environment (e.g. Claude Code on the web, where the container is +reaped), set the key once as an environment/repo secret instead: + + MXCLI_HUB_KEY= # takes precedence over the stored key + +Open self-hosted hubs need no key — 'run --hub' falls back to the shared +--hub-secret.`, +} + +var authHubLoginCmd = &cobra.Command{ + Use: "login", + Short: "Mint and store a hub API key via GitHub device flow", + RunE: runAuthHubLogin, +} + +var authHubStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show whether a hub API key is configured", + RunE: runAuthHubStatus, +} + +var authHubLogoutCmd = &cobra.Command{ + Use: "logout", + Short: "Revoke and remove the stored hub API key", + RunE: runAuthHubLogout, +} + +func init() { + for _, c := range []*cobra.Command{authHubLoginCmd, authHubStatusCmd, authHubLogoutCmd} { + c.Flags().String("hub", hubauth.DefaultHubURL, "hub base URL") + } + authHubCmd.AddCommand(authHubLoginCmd, authHubStatusCmd, authHubLogoutCmd) + authCmd.AddCommand(authHubCmd) +} + +func runAuthHubLogin(cmd *cobra.Command, _ []string) error { + hubURL, _ := cmd.Flags().GetString("hub") + client := &hubauth.Client{HubURL: hubURL} + + login, err := client.Login(cmd.Context(), cmd.OutOrStdout()) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "\n✓ Logged in as %s. Hub key saved for %s.\n", login, hubauth.HostOf(hubURL)) + fmt.Fprintf(cmd.OutOrStdout(), " 'mxcli run --hub %s' will now register previews as %s.\n", hubURL, login) + return nil +} + +func runAuthHubStatus(cmd *cobra.Command, _ []string) error { + hubURL, _ := cmd.Flags().GetString("hub") + host := hubauth.HostOf(hubURL) + out := cmd.OutOrStdout() + + if envKey := os.Getenv(hubauth.EnvHubKey); envKey != "" { + fmt.Fprintf(out, "Hub: %s\n", host) + fmt.Fprintf(out, "Source: env (%s)\n", hubauth.EnvHubKey) + fmt.Fprintln(out, "Status: a key is set via the environment (overrides any stored key).") + return nil + } + if _, ok := hubauth.StoredKey(hubURL); ok { + fmt.Fprintf(out, "Hub: %s\n", host) + fmt.Fprintln(out, "Source: file (~/.mxcli/auth.json)") + fmt.Fprintln(out, "Status: a hub key is stored for this host.") + return nil + } + fmt.Fprintf(out, "Hub: %s\n", host) + fmt.Fprintln(out, "Status: no hub key configured. Run: mxcli auth hub login") + return nil +} + +func runAuthHubLogout(cmd *cobra.Command, _ []string) error { + hubURL, _ := cmd.Flags().GetString("hub") + out := cmd.OutOrStdout() + + // Best-effort revoke on the hub before dropping the local copy. + if key, ok := hubauth.StoredKey(hubURL); ok { + client := &hubauth.Client{HubURL: hubURL} + if err := client.RevokeHubKey(cmd.Context(), key); err != nil { + fmt.Fprintf(out, "warning: could not revoke on the hub (%v); removing local copy anyway\n", err) + } + } + if err := hubauth.DeleteKey(hubURL); err != nil { + return fmt.Errorf("removing stored hub key: %w", err) + } + fmt.Fprintf(out, "Removed hub key for %s.\n", hubauth.HostOf(hubURL)) + return nil +} diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index a7c9823da..7fd19364c 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -8,6 +8,7 @@ import ( "path/filepath" "github.com/mendixlabs/mxcli/cmd/mxcli/docker" + "github.com/mendixlabs/mxcli/cmd/mxcli/hubauth" "github.com/spf13/cobra" ) @@ -69,6 +70,7 @@ Examples: mxcli run --local -p app.mpr --app-port 8081 --db-name myapp mxcli run --hub https://hub.example.com -p app.mpr # browser preview mxcli run --hub https://hub.example.com --hub-secret u:pass -p app.mpr --watch + mxcli auth hub login && mxcli run --hub https://hub.mxcli.org -p app.mpr # authed hub `, Run: func(cmd *cobra.Command, args []string) { local, _ := cmd.Flags().GetBool("local") @@ -81,8 +83,13 @@ Examples: hubWorktree, _ := cmd.Flags().GetString("hub-worktree") // --hub is a cross-cutting ingress and implies the local serving path (the // only serving mode wired today; a future PAD path will accept --hub too). + hubKey := "" if hub != "" { local = true + // Present a per-user hub API key to an authenticated hub (MXCLI_HUB_KEY + // env → ~/.mxcli/auth.json). Empty for open hubs; the shared --hub-secret + // still applies. + hubKey = hubauth.ResolveKey(hub) } if !local { fmt.Fprintln(os.Stderr, "Error: only --local is supported for now (use 'mxcli docker run' for the container workflow)") @@ -131,6 +138,7 @@ Examples: ProjectPath: projectPath, Hub: hub, HubSecret: hubSecret, + HubKey: hubKey, HubPrefix: hubPrefix, HubProject: hubProject, HubSolution: hubSolution, diff --git a/cmd/mxcli/docker/hubclient.go b/cmd/mxcli/docker/hubclient.go index 08c13ac2e..9a8ece717 100644 --- a/cmd/mxcli/docker/hubclient.go +++ b/cmd/mxcli/docker/hubclient.go @@ -41,6 +41,7 @@ type HubRegistration struct { // Inputs kept so the heartbeat can re-register if the hub forgets us (e.g. the // hub restarted and lost its in-memory registry — /api/status then 404s). secret string + key string meta HubMeta appPort int } @@ -60,7 +61,12 @@ type registerResponse struct { // at the hub URL on the default reverse port. The HTTP client uses the standard // proxy environment (honouring NO_PROXY), so an external hub goes through the // egress proxy. -func RegisterWithHub(hubURL, secret string, meta HubMeta, appPort int) (*HubRegistration, error) { +// +// An authenticated hub (GitHub OAuth) is registered with a per-user X-Hub-Key +// (from `mxcli auth hub login` / MXCLI_HUB_KEY), which stamps the preview's owner. +// An open self-hosted hub uses the shared X-Hub-Secret. Both are sent when present +// so the hub picks the one matching its own mode. +func RegisterWithHub(hubURL, secret, key string, meta HubMeta, appPort int) (*HubRegistration, error) { body, _ := json.Marshal(map[string]any{ "prefix": meta.Prefix, "project": meta.Project, @@ -77,6 +83,9 @@ func RegisterWithHub(hubURL, secret string, meta HubMeta, appPort int) (*HubRegi if secret != "" { req.Header.Set("X-Hub-Secret", secret) } + if key != "" { + req.Header.Set("X-Hub-Key", key) + } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("contacting hub: %w", err) @@ -100,6 +109,7 @@ func RegisterWithHub(hubURL, secret string, meta HubMeta, appPort int) (*HubRegi MultiTenant: true, hubURL: hubURL, secret: secret, + key: key, meta: meta, appPort: appPort, }, nil @@ -112,6 +122,7 @@ func RegisterWithHub(hubURL, secret string, meta HubMeta, appPort int) (*HubRegi TunnelAuth: secret, MultiTenant: false, hubURL: hubURL, + key: key, }, nil default: msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) @@ -174,7 +185,7 @@ func StartHeartbeat(reg *HubRegistration, onReRegister func(*HubRegistration)) * // forgotten this preview, updating the registration in place. Returns whether the // assigned reverse port changed (the caller must then restart the tunnel). func (reg *HubRegistration) reRegister() (portChanged bool, err error) { - fresh, err := RegisterWithHub(reg.hubURL, reg.secret, reg.meta, reg.appPort) + fresh, err := RegisterWithHub(reg.hubURL, reg.secret, reg.key, reg.meta, reg.appPort) if err != nil { return false, err } diff --git a/cmd/mxcli/docker/hubclient_reregister_test.go b/cmd/mxcli/docker/hubclient_reregister_test.go index 293925e57..d0cde17d5 100644 --- a/cmd/mxcli/docker/hubclient_reregister_test.go +++ b/cmd/mxcli/docker/hubclient_reregister_test.go @@ -42,7 +42,7 @@ func TestHeartbeatReRegistersAfterHubForgets(t *testing.T) { })) defer srv.Close() - reg, err := RegisterWithHub(srv.URL, "", HubMeta{Project: "p"}, 8080) + reg, err := RegisterWithHub(srv.URL, "", "", HubMeta{Project: "p"}, 8080) if err != nil { t.Fatalf("initial register: %v", err) } diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index c1d622b9c..2388987cf 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -51,6 +51,10 @@ type LocalRunOptions struct { // HubSecret is the shared auth secret for the hub ("user:pass"), matching the // hub's --secret. Optional but recommended. HubSecret string + // HubKey is a per-user tunnel-hub API key (from `mxcli auth hub login` or + // MXCLI_HUB_KEY) presented to an authenticated hub as X-Hub-Key; it stamps the + // preview's owner. Empty for open self-hosted hubs. + HubKey string // Hub identity (multi-tenant hub): these drive the assigned subdomain and the // hub overview grouping. Blank Project/Branch are auto-detected from the .mpr // name and git. @@ -538,7 +542,7 @@ func RunLocal(opts LocalRunOptions) error { Branch: opts.HubBranch, Worktree: opts.HubWorktree, }) fmt.Fprintf(w, "Registering with hub %s...\n", opts.Hub) - hubReg, err = RegisterWithHub(opts.Hub, opts.HubSecret, meta, opts.AppPort) + hubReg, err = RegisterWithHub(opts.Hub, opts.HubSecret, opts.HubKey, meta, opts.AppPort) if err != nil { return fmt.Errorf("hub registration: %w", err) } diff --git a/cmd/mxcli/hubauth/deviceflow.go b/cmd/mxcli/hubauth/deviceflow.go new file mode 100644 index 000000000..aee8b8728 --- /dev/null +++ b/cmd/mxcli/hubauth/deviceflow.go @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hubauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client carries the transport + endpoints for a login. Zero value is production +// (real GitHub, http.DefaultClient); tests override the bases + clock. +type Client struct { + HubURL string // e.g. https://hub.mxcli.org + HTTP *http.Client // default http.DefaultClient + GitHubBase string // default https://github.com (device code + token poll) + + // now/sleep are injectable so tests don't wall-clock through the poll loop. + now func() time.Time + sleep func(context.Context, time.Duration) +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return http.DefaultClient +} + +func (c *Client) githubBase() string { + if c.GitHubBase != "" { + return strings.TrimRight(c.GitHubBase, "/") + } + return "https://github.com" +} + +func (c *Client) sleepFor(ctx context.Context, d time.Duration) { + if c.sleep != nil { + c.sleep(ctx, d) + return + } + select { + case <-ctx.Done(): + case <-time.After(d): + } +} + +// HubAuthConfig is the client-visible slice of GET /api/auth-config. +type HubAuthConfig struct { + AuthEnabled bool `json:"authEnabled"` + RequireAuth bool `json:"requireAuth"` + GitHubClientID string `json:"githubClientId"` +} + +// FetchAuthConfig asks the hub whether GitHub auth is required and, if so, which +// OAuth App client id to use for the device flow. +func (c *Client) FetchAuthConfig(ctx context.Context) (HubAuthConfig, error) { + var cfg HubAuthConfig + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.hub("/api/auth-config"), nil) + if err != nil { + return cfg, err + } + resp, err := c.httpClient().Do(req) + if err != nil { + return cfg, fmt.Errorf("contacting hub: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return cfg, fmt.Errorf("hub auth-config returned HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&cfg); err != nil { + return cfg, fmt.Errorf("decoding hub auth-config: %w", err) + } + return cfg, nil +} + +func (c *Client) hub(path string) string { + return strings.TrimRight(c.HubURL, "/") + path +} + +// DeviceCode is GitHub's device-flow authorization prompt. +type DeviceCode struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// RequestDeviceCode begins the GitHub device flow for the given OAuth client id. +func (c *Client) RequestDeviceCode(ctx context.Context, clientID string) (DeviceCode, error) { + var dc DeviceCode + form := url.Values{"client_id": {clientID}, "scope": {"read:user"}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.githubBase()+"/login/device/code", strings.NewReader(form.Encode())) + if err != nil { + return dc, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient().Do(req) + if err != nil { + return dc, fmt.Errorf("requesting device code: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return dc, fmt.Errorf("device-code request returned HTTP %d", resp.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&dc); err != nil { + return dc, fmt.Errorf("decoding device code: %w", err) + } + if dc.Interval <= 0 { + dc.Interval = 5 + } + return dc, nil +} + +// tokenPollResponse is GitHub's device-token endpoint reply (success or a +// pending/slow-down/error signal in `error`). +type tokenPollResponse struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` +} + +// PollForToken polls GitHub until the user authorizes, honouring the +// authorization_pending / slow_down backoff. Returns the GitHub access token. +func (c *Client) PollForToken(ctx context.Context, clientID string, dc DeviceCode) (string, error) { + interval := time.Duration(dc.Interval) * time.Second + deadline := time.Now().Add(time.Duration(max(dc.ExpiresIn, 300)) * time.Second) + if c.now != nil { + deadline = c.now().Add(time.Duration(max(dc.ExpiresIn, 300)) * time.Second) + } + for { + c.sleepFor(ctx, interval) + if ctx.Err() != nil { + return "", ctx.Err() + } + tok, e, err := c.pollOnce(ctx, clientID, dc.DeviceCode) + if err != nil { + return "", err + } + switch e { + case "": + if tok != "" { + return tok, nil + } + case "authorization_pending": + // keep polling at the current interval + case "slow_down": + interval += 5 * time.Second + case "expired_token": + return "", fmt.Errorf("the device code expired before authorization; run 'mxcli auth hub login' again") + case "access_denied": + return "", fmt.Errorf("authorization was denied") + default: + return "", fmt.Errorf("device authorization failed: %s", e) + } + nowT := time.Now() + if c.now != nil { + nowT = c.now() + } + if nowT.After(deadline) { + return "", fmt.Errorf("timed out waiting for device authorization") + } + } +} + +func (c *Client) pollOnce(ctx context.Context, clientID, deviceCode string) (token, errCode string, err error) { + form := url.Values{ + "client_id": {clientID}, + "device_code": {deviceCode}, + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.githubBase()+"/login/oauth/access_token", strings.NewReader(form.Encode())) + if err != nil { + return "", "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := c.httpClient().Do(req) + if err != nil { + return "", "", fmt.Errorf("polling for token: %w", err) + } + defer resp.Body.Close() + var body tokenPollResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body); err != nil { + return "", "", fmt.Errorf("decoding token poll: %w", err) + } + return body.AccessToken, body.Error, nil +} + +// MintHubKey exchanges a GitHub token for a hub API key via POST /api/keys. The +// token is sent once, here, and never stored. +func (c *Client) MintHubKey(ctx context.Context, githubToken string) (key, login string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.hub("/api/keys"), nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+githubToken) + resp, err := c.httpClient().Do(req) + if err != nil { + return "", "", fmt.Errorf("minting hub key: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", "", fmt.Errorf("hub key mint failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + var body struct { + Key string `json:"key"` + Login string `json:"login"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body); err != nil { + return "", "", fmt.Errorf("decoding hub key: %w", err) + } + return body.Key, body.Login, nil +} + +// RevokeHubKey best-effort revokes a key on the hub (DELETE /api/keys). +func (c *Client) RevokeHubKey(ctx context.Context, key string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.hub("/api/keys"), nil) + if err != nil { + return err + } + req.Header.Set("X-Hub-Key", key) + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} + +// Login runs the full bootstrap: discover the client id, device flow, mint a hub +// key, and store it. Prompts are written to out. Returns the resolved GitHub +// login. The GitHub token never leaves this process except for the mint call. +func (c *Client) Login(ctx context.Context, out io.Writer) (login string, err error) { + cfg, err := c.FetchAuthConfig(ctx) + if err != nil { + return "", err + } + if !cfg.AuthEnabled { + return "", fmt.Errorf("hub %s does not require authentication (open mode); no key needed", HostOf(c.HubURL)) + } + if cfg.GitHubClientID == "" { + return "", fmt.Errorf("hub reports auth enabled but advertises no GitHub client id") + } + dc, err := c.RequestDeviceCode(ctx, cfg.GitHubClientID) + if err != nil { + return "", err + } + fmt.Fprintf(out, "\nTo authorize this device, open:\n %s\nand enter the code:\n %s\n\nWaiting for authorization...\n", + dc.VerificationURI, dc.UserCode) + + token, err := c.PollForToken(ctx, cfg.GitHubClientID, dc) + if err != nil { + return "", err + } + key, login, err := c.MintHubKey(ctx, token) + if err != nil { + return "", err + } + if err := SaveKey(c.HubURL, key); err != nil { + return "", fmt.Errorf("saving hub key: %w", err) + } + return login, nil +} diff --git a/cmd/mxcli/hubauth/deviceflow_test.go b/cmd/mxcli/hubauth/deviceflow_test.go new file mode 100644 index 000000000..9feef091f --- /dev/null +++ b/cmd/mxcli/hubauth/deviceflow_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hubauth + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// fakeGitHub serves the device-flow endpoints. It returns authorization_pending +// for the first (pendingRounds) polls, then the token. +func fakeGitHub(t *testing.T, pendingRounds int) *httptest.Server { + t.Helper() + var polls int + mux := http.NewServeMux() + mux.HandleFunc("/login/device/code", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"device_code":"DC","user_code":"WXYZ-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":1}`)) + }) + mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if polls < pendingRounds { + polls++ + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + _, _ = w.Write([]byte(`{"access_token":"gho_from_device"}`)) + }) + return httptest.NewServer(mux) +} + +// fakeHub serves /api/auth-config and /api/keys. +func fakeHub(t *testing.T, clientID, login string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/auth-config", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"authEnabled":true,"requireAuth":true,"githubClientId":"` + clientID + `"}`)) + }) + mux.HandleFunc("/api/keys", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNoContent) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer gho_from_device" { + t.Errorf("mint auth header = %q, want Bearer gho_from_device", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"key":"hub_minted_key","login":"` + login + `"}`)) + }) + return httptest.NewServer(mux) +} + +// testClient wires a Client to the stub servers with an instant sleep. +func testClient(hub, gh *httptest.Server) *Client { + return &Client{ + HubURL: hub.URL, + GitHubBase: gh.URL, + HTTP: hub.Client(), + sleep: func(context.Context, time.Duration) {}, // no real waiting + } +} + +func TestFetchAuthConfig(t *testing.T) { + hub := fakeHub(t, "cid123", "alice") + defer hub.Close() + c := &Client{HubURL: hub.URL, HTTP: hub.Client()} + + cfg, err := c.FetchAuthConfig(context.Background()) + if err != nil { + t.Fatal(err) + } + if !cfg.AuthEnabled || cfg.GitHubClientID != "cid123" { + t.Errorf("cfg = %+v, want enabled/cid123", cfg) + } +} + +func TestPollForToken_PendingThenSuccess(t *testing.T) { + gh := fakeGitHub(t, 2) // two pending rounds, then success + defer gh.Close() + c := &Client{GitHubBase: gh.URL, HTTP: gh.Client(), sleep: func(context.Context, time.Duration) {}} + + tok, err := c.PollForToken(context.Background(), "cid", DeviceCode{DeviceCode: "DC", Interval: 1, ExpiresIn: 900}) + if err != nil { + t.Fatalf("PollForToken: %v", err) + } + if tok != "gho_from_device" { + t.Errorf("token = %q, want gho_from_device", tok) + } +} + +func TestMintHubKey(t *testing.T) { + hub := fakeHub(t, "cid", "alice") + defer hub.Close() + c := &Client{HubURL: hub.URL, HTTP: hub.Client()} + + key, login, err := c.MintHubKey(context.Background(), "gho_from_device") + if err != nil { + t.Fatal(err) + } + if key != "hub_minted_key" || login != "alice" { + t.Errorf("mint = %q/%q, want hub_minted_key/alice", key, login) + } +} + +func TestLogin_FullFlowStoresKey(t *testing.T) { + withTempStore(t) + t.Setenv(EnvHubKey, "") + + gh := fakeGitHub(t, 1) + defer gh.Close() + hub := fakeHub(t, "cid", "alice") + defer hub.Close() + c := testClient(hub, gh) + + var out bytes.Buffer + login, err := c.Login(context.Background(), &out) + if err != nil { + t.Fatalf("Login: %v", err) + } + if login != "alice" { + t.Errorf("login = %q, want alice", login) + } + // The instructions were printed. + if !strings.Contains(out.String(), "WXYZ-1234") { + t.Errorf("device instructions missing the user code, got: %s", out.String()) + } + // And the minted key was stored for the hub host. + if k := ResolveKey(hub.URL); k != "hub_minted_key" { + t.Errorf("stored key = %q, want hub_minted_key", k) + } +} + +func TestLogin_OpenModeHubIsAnError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/auth-config", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"authEnabled":false}`)) + }) + hub := httptest.NewServer(mux) + defer hub.Close() + c := &Client{HubURL: hub.URL, HTTP: hub.Client()} + + if _, err := c.Login(context.Background(), &bytes.Buffer{}); err == nil { + t.Error("Login against an open-mode hub should error (no key needed)") + } +} diff --git a/cmd/mxcli/hubauth/hubauth.go b/cmd/mxcli/hubauth/hubauth.go new file mode 100644 index 000000000..3cc346c05 --- /dev/null +++ b/cmd/mxcli/hubauth/hubauth.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package hubauth is the client side of tunnel-hub authentication: it obtains a +// GitHub identity via the OAuth device flow, exchanges it for a hub-minted API +// key, and stores/resolves that key per hub host in ~/.mxcli/auth.json. +// +// `mxcli auth hub login` runs Login (device flow → mint → store); `mxcli run +// --hub` calls ResolveKey to attach an X-Hub-Key header. The GitHub token stays +// on this machine except for the single mint call to the hub. +package hubauth + +import ( + "net/url" + "os" + "strings" + "time" + + "github.com/mendixlabs/mxcli/internal/auth" +) + +// EnvHubKey overrides the stored key — set once as a repo/environment secret so a +// reaped Claude Code web container re-registers without an interactive login. +const EnvHubKey = "MXCLI_HUB_KEY" + +// DefaultHubURL is the hosted hub used when --hub is not given. +const DefaultHubURL = "https://hub.mxcli.org" + +// HostOf returns the host[:port] of a hub URL (used as the storage key). +func HostOf(hubURL string) string { + if !strings.Contains(hubURL, "://") { + hubURL = "https://" + hubURL + } + u, err := url.Parse(hubURL) + if err != nil || u.Host == "" { + return strings.TrimRight(hubURL, "/") + } + return u.Host +} + +// ProfileKey is the auth.json profile name a hub host's key is stored under. +func ProfileKey(host string) string { return "hub:" + host } + +// ResolveKey returns the hub API key to present to hubURL: the MXCLI_HUB_KEY env +// var first (deterministic for web sessions), then the stored key for the host. +// Returns "" when none is configured (open-mode hubs need no key). +func ResolveKey(hubURL string) string { + if k := strings.TrimSpace(os.Getenv(EnvHubKey)); k != "" { + return k + } + store, err := loadStore() + if err != nil { + return "" + } + if cred, err := store.Get(ProfileKey(HostOf(hubURL))); err == nil { + return cred.Token + } + return "" +} + +// SaveKey stores a minted hub key for hubURL's host. +func SaveKey(hubURL, key string) error { + store, err := loadStore() + if err != nil { + return err + } + return store.Put(ProfileKey(HostOf(hubURL)), &auth.Credential{ + Scheme: auth.SchemeHubKey, + Token: key, + CreatedAt: time.Now().UTC(), + }) +} + +// DeleteKey removes the stored hub key for hubURL's host. +func DeleteKey(hubURL string) error { + store, err := loadStore() + if err != nil { + return err + } + return store.Delete(ProfileKey(HostOf(hubURL))) +} + +// StoredKey returns the stored key for a host (ignoring the env override) and +// whether one exists — used by `auth hub status`. +func StoredKey(hubURL string) (key string, ok bool) { + store, err := loadStore() + if err != nil { + return "", false + } + cred, err := store.Get(ProfileKey(HostOf(hubURL))) + if err != nil { + return "", false + } + return cred.Token, true +} + +// storeOverride lets tests point the store at a temp file. +var storeOverride auth.Store + +func loadStore() (auth.Store, error) { + if storeOverride != nil { + return storeOverride, nil + } + return auth.DefaultFileStore() +} diff --git a/cmd/mxcli/hubauth/hubauth_test.go b/cmd/mxcli/hubauth/hubauth_test.go new file mode 100644 index 000000000..d83706845 --- /dev/null +++ b/cmd/mxcli/hubauth/hubauth_test.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hubauth + +import ( + "path/filepath" + "testing" + + "github.com/mendixlabs/mxcli/internal/auth" +) + +func TestHostOf(t *testing.T) { + cases := map[string]string{ + "https://hub.mxcli.org": "hub.mxcli.org", + "https://hub.mxcli.org/": "hub.mxcli.org", + "http://localhost:8080/x": "localhost:8080", + "hub.mxcli.org": "hub.mxcli.org", + "https://hub.example.com/api/": "hub.example.com", + } + for in, want := range cases { + if got := HostOf(in); got != want { + t.Errorf("HostOf(%q) = %q, want %q", in, got, want) + } + } +} + +// withTempStore points the package store at a fresh temp auth.json and restores +// the previous override afterward. +func withTempStore(t *testing.T) { + t.Helper() + prev := storeOverride + storeOverride = auth.NewFileStore(filepath.Join(t.TempDir(), "auth.json")) + t.Cleanup(func() { storeOverride = prev }) +} + +func TestKeyStoreRoundTrip(t *testing.T) { + withTempStore(t) + t.Setenv(EnvHubKey, "") // ensure env override is off + + const hub = "https://hub.mxcli.org" + if k := ResolveKey(hub); k != "" { + t.Fatalf("expected no key initially, got %q", k) + } + if _, ok := StoredKey(hub); ok { + t.Fatal("StoredKey should report none initially") + } + + if err := SaveKey(hub, "hk_secret"); err != nil { + t.Fatalf("SaveKey: %v", err) + } + if k := ResolveKey(hub); k != "hk_secret" { + t.Errorf("ResolveKey = %q, want hk_secret", k) + } + if k, ok := StoredKey(hub); !ok || k != "hk_secret" { + t.Errorf("StoredKey = %q, %v; want hk_secret, true", k, ok) + } + // Host-keyed: a different hub has no key. + if k := ResolveKey("https://other.example.com"); k != "" { + t.Errorf("different host should have no key, got %q", k) + } + + if err := DeleteKey(hub); err != nil { + t.Fatalf("DeleteKey: %v", err) + } + if k := ResolveKey(hub); k != "" { + t.Errorf("ResolveKey after delete = %q, want empty", k) + } +} + +func TestResolveKey_EnvOverridesStore(t *testing.T) { + withTempStore(t) + const hub = "https://hub.mxcli.org" + if err := SaveKey(hub, "stored-key"); err != nil { + t.Fatal(err) + } + t.Setenv(EnvHubKey, "env-key") + if k := ResolveKey(hub); k != "env-key" { + t.Errorf("ResolveKey = %q, want env-key (env must win over store)", k) + } +} + +func TestProfileKey(t *testing.T) { + if got := ProfileKey("hub.mxcli.org"); got != "hub:hub.mxcli.org" { + t.Errorf("ProfileKey = %q", got) + } +} diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 6e66921df..607ff3e45 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -76,6 +76,33 @@ func (a *API) Mount(mux *http.ServeMux) { mux.HandleFunc("/api/deregister", a.handleDeregister) mux.HandleFunc("/api/backends", a.handleBackends) mux.HandleFunc("/api/keys", a.handleKeys) + mux.HandleFunc("/api/auth-config", a.handleAuthConfig) +} + +// AuthConfigResponse tells a client whether the hub requires GitHub auth and, if +// so, which OAuth App client id to use for the device flow. The client id is a +// public value (it appears in every browser redirect); no secret is exposed. +type AuthConfigResponse struct { + AuthEnabled bool `json:"authEnabled"` + RequireAuth bool `json:"requireAuth"` + GitHubClientID string `json:"githubClientId,omitempty"` +} + +// handleAuthConfig (GET /api/auth-config) lets `mxcli auth hub login` discover the +// hub's OAuth App client id so the user needn't configure one. Open-mode hubs +// report authEnabled:false and the client falls back to the shared secret. +func (a *API) handleAuthConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + resp := AuthConfigResponse{} + if a.opts.Auth.enabled() { + resp.AuthEnabled = true + resp.RequireAuth = a.opts.Auth.RequireAuth + resp.GitHubClientID = a.opts.Auth.GitHubClientID + } + writeJSON(w, http.StatusOK, resp) } func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/mxcli/tunnelhub/api_keys_test.go b/cmd/mxcli/tunnelhub/api_keys_test.go index d5fbdb866..807498f22 100644 --- a/cmd/mxcli/tunnelhub/api_keys_test.go +++ b/cmd/mxcli/tunnelhub/api_keys_test.go @@ -172,3 +172,27 @@ func TestAPI_OpenModeRegisterUnchanged(t *testing.T) { t.Errorf("open-mode register should be owner-less, got %+v", got) } } + +func TestAPI_AuthConfig(t *testing.T) { + // Open mode: authEnabled false, no client id. + open, _ := newTestAPI(t, "") + rec := doJSON(t, open, http.MethodGet, "/api/auth-config", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("open auth-config status = %d", rec.Code) + } + var oc AuthConfigResponse + _ = json.Unmarshal(rec.Body.Bytes(), &oc) + if oc.AuthEnabled || oc.GitHubClientID != "" { + t.Errorf("open mode auth-config = %+v, want disabled/empty", oc) + } + + // Auth on: exposes the client id. + api, _, done := newAuthedAPI(t, true) + defer done() + r2 := doJSON(t, api, http.MethodGet, "/api/auth-config", "", nil) + var ac AuthConfigResponse + _ = json.Unmarshal(r2.Body.Bytes(), &ac) + if !ac.AuthEnabled || !ac.RequireAuth || ac.GitHubClientID != "cid" { + t.Errorf("authed auth-config = %+v, want enabled/require/cid", ac) + } +} diff --git a/internal/auth/credential.go b/internal/auth/credential.go index 45fc0560b..0f4129213 100644 --- a/internal/auth/credential.go +++ b/internal/auth/credential.go @@ -22,6 +22,10 @@ type Scheme string const ( // SchemePAT uses "Authorization: MxToken " (Content API, marketplace). SchemePAT Scheme = "pat" + // SchemeHubKey is an opaque tunnel-hub API key sent as "X-Hub-Key: " + // on /api/register. Minted by `mxcli auth hub login`, stored per hub host + // under the profile "hub:". + SchemeHubKey Scheme = "hubkey" ) // ProfileDefault is the profile name used when none is specified. From 18966077ba73c19fa8a638a9363bce9815ab5b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 15:50:29 +0000 Subject: [PATCH 09/17] =?UTF-8?q?docs(hub-auth):=20slice=204=20done=20?= =?UTF-8?q?=E2=80=94=20run-local=20auth=20section=20+=20proposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/mendix/run-local.md | 36 ++++++++++++++++--- .../PROPOSAL_hub_authentication.md | 14 ++++++-- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local.md index 8e894da88..9825e5822 100644 --- a/.claude/skills/mendix/run-local.md +++ b/.claude/skills/mendix/run-local.md @@ -109,7 +109,8 @@ Launch `run --local` as the **sole** command in its invocation (don't chain a tr |------|---------|---------| | `--local` | — | Required; run without Docker (implied by `--hub`) | | `--hub` | — | Expose the app in a browser at a tunnel-hub URL (see below) | -| `--hub-secret` | — | Shared auth (`user:pass`) matching the hub's `--secret` | +| `--hub-secret` | — | Shared auth (`user:pass`) matching an **open** hub's `--secret` | +| *(hub API key)* | — | For an **authenticated** hub: `mxcli auth hub login`, or set `MXCLI_HUB_KEY` (see below) | | `--watch` | off | Rebuild + hot-apply on each change | | `--ensure-db` | off | Provision local Postgres + app database if missing | | `--setup` | off | Cache MxBuild+runtime + ensure DB, then exit (SessionStart bring-up) | @@ -268,9 +269,36 @@ sortable overview at `https://hub.example.com/`. Each `run --hub` self-registers with `--watch` for the full loop: edit here → hot-apply → refresh the browser. **Hub setup:** wildcard `*.example.com` A record (+ `hub.example.com`) → the VPS; inbound -80+443 open (per-subdomain Let's Encrypt on demand). **Security:** this version uses one -shared `--secret` and open registration, so keep the hub to people you trust and don't -expose it publicly (per-tenant auth is a follow-up). +80+443 open (per-subdomain Let's Encrypt on demand). + +### Authenticated hub (GitHub) + +A self-hosted hub started **without** the GitHub flags is open — the shared `--hub-secret` +is all `run --hub` needs. A hub started **with** `--github-oauth-client-id` (see +`mxcli tunnel-hub --help`) adds per-user isolation: browsers sign in with GitHub and see +only their own previews, and registration needs a **per-user hub API key** instead of the +shared secret. + +```bash +# once per environment: mint + cache a hub key from your GitHub identity (device flow) +mxcli auth hub login # defaults to https://hub.mxcli.org; --hub for others +mxcli auth hub status # show whether a key is configured +mxcli auth hub logout # revoke + remove it + +# then run as normal — the key is sent automatically, stamping the preview as yours +mxcli run --hub https://hub.mxcli.org -p app.mpr +``` + +For an unattended environment (Claude Code on the web — the container is reaped, so the +interactive login won't persist), set the key once as an environment/repo secret; it takes +precedence over the stored key: + +```bash +export MXCLI_HUB_KEY= # from `mxcli auth hub login` on a durable machine +``` + +The GitHub token never leaves your machine except for the one-time key-mint request to the +hub; only the opaque hub key is stored (`~/.mxcli/auth.json`, mode 0600, keyed by hub host). ## Validation checklist diff --git a/docs/11-proposals/PROPOSAL_hub_authentication.md b/docs/11-proposals/PROPOSAL_hub_authentication.md index 88b1a3074..d15dd8efb 100644 --- a/docs/11-proposals/PROPOSAL_hub_authentication.md +++ b/docs/11-proposals/PROPOSAL_hub_authentication.md @@ -304,9 +304,17 @@ lines (no login). See *Open questions* on retention default and JSONL-vs-SQLite. anonymous register, revoked key can't register, open-mode keys `404` and register unchanged. **Client is slice 4** (`mxcli run --hub` still sends `X-Hub-Secret` until then, so enabling `--require-auth` on a hosted hub waits for slice 4). -4. **Client**: `mxcli auth hub login/status/logout` (device flow), `run --hub` sends - `X-Hub-Key` (env → auth.json → legacy secret). Tests: auth.json round-trip, header - selection. +4. **Client** — ✅ **done**: `GET /api/auth-config` advertises the OAuth client id (public) + so login needs no config; the `hubauth` package runs the GitHub **device flow** (poll + honouring `authorization_pending`/`slow_down`/expiry), mints a key via `POST /api/keys` + (GitHub token sent once, never stored), and caches it per hub host in `~/.mxcli/auth.json` + (new `SchemeHubKey`, profile `hub:`, mode `0600`). `mxcli auth hub login/status/ + logout` (logout best-effort revokes on the hub then drops the local copy). `run --hub` + resolves the key (`MXCLI_HUB_KEY` env → store) and `RegisterWithHub` sends `X-Hub-Key` + (owner stamped) alongside the legacy `X-Hub-Secret`, so authed and open hubs both work + from one client. Tests: store round-trip + env-override precedence, `HostOf`, device-flow + pending→success (stubbed GitHub), mint, full `Login` stores the key, open-mode-hub login + errors, `auth-config` open vs authed. Docs: `run-local` skill "Authenticated hub" section. 5. **Wire-up + docs + E2E** against `hub.mxcli.org`: flags in `tunnel-hub`, `run-local` skill + docs-site, CLAUDE.md status line; verify owner isolation end-to-end (two GitHub users, cross-access = 403). From 4d2a296f5a372d8167873c6f97fa94f7f73d65e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 23:06:50 +0000 Subject: [PATCH 10/17] hub-auth: fix review findings (anon listing leak + hardening) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/tunnelhub/api.go | 19 ++++- cmd/mxcli/tunnelhub/api_keys_test.go | 31 ++++++++ cmd/mxcli/tunnelhub/auth.go | 75 ++++++++++++++----- cmd/mxcli/tunnelhub/auth_test.go | 30 ++++++-- cmd/mxcli/tunnelhub/server.go | 8 ++ .../PROPOSAL_hub_authentication.md | 25 +++++++ 6 files changed, 157 insertions(+), 31 deletions(-) diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 607ff3e45..4ef3b5fd8 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -267,10 +267,21 @@ func (a *API) byToken(w http.ResponseWriter, r *http.Request, fn func(token stri } func (a *API) handleBackends(w http.ResponseWriter, r *http.Request) { - // In open mode Auth.sessionLogin returns "" and every backend is listed; - // with auth on it returns the viewer's GitHub login so the list is filtered - // to the caller's own previews. - writeJSON(w, http.StatusOK, a.opts.Registry.List(r.URL.Query().Get("sort"), a.opts.Auth.sessionLogin(r))) + sort := r.URL.Query().Get("sort") + if a.opts.Auth.enabled() { + // Auth on: the listing is the caller's own-previews view. An unauthenticated + // caller must NOT receive the full list (that would leak every user's + // subdomains/owners/ports); require a valid session and filter to its login. + login := a.opts.Auth.sessionLogin(r) + if login == "" { + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + writeJSON(w, http.StatusOK, a.opts.Registry.List(sort, login)) + return + } + // Open mode (no auth): list everything — today's behaviour. + writeJSON(w, http.StatusOK, a.opts.Registry.List(sort, "")) } func bearerToken(r *http.Request) string { diff --git a/cmd/mxcli/tunnelhub/api_keys_test.go b/cmd/mxcli/tunnelhub/api_keys_test.go index 807498f22..51c7f9ca3 100644 --- a/cmd/mxcli/tunnelhub/api_keys_test.go +++ b/cmd/mxcli/tunnelhub/api_keys_test.go @@ -196,3 +196,34 @@ func TestAPI_AuthConfig(t *testing.T) { t.Errorf("authed auth-config = %+v, want enabled/require/cid", ac) } } + +// TestAPI_BackendsRequiresAuthWhenEnabled is the fix for the anonymous-listing +// leak: with auth on, an unauthenticated GET /api/backends must 401 (not return +// every user's previews). A valid session gets its own filtered list. +func TestAPI_BackendsRequiresAuthWhenEnabled(t *testing.T) { + api, _, done := newAuthedAPI(t, true) + defer done() + + // Register a preview owned by alice (via a minted key). + key := mintKey(t, api, "gho_test") + if rec := doJSON(t, api, http.MethodPost, "/api/register", + `{"project":"Secret","branch":"main"}`, map[string]string{"X-Hub-Key": key}); rec.Code != http.StatusOK { + t.Fatalf("register status = %d", rec.Code) + } + + // Anonymous (no cookie) must NOT get the listing. + anon := doJSON(t, api, http.MethodGet, "/api/backends", "", nil) + if anon.Code != http.StatusUnauthorized { + t.Fatalf("anonymous /api/backends = %d, want 401 (leak!)", anon.Code) + } + if strings.Contains(anon.Body.String(), "Secret") { + t.Errorf("anonymous response leaked a preview: %s", anon.Body) + } + + // A valid alice session sees her own preview. + cookie := signSession(api.opts.Auth.SessionSecret, "alice", time.Unix(1_700_000_000, 0).Add(time.Hour)) + ok := doJSON(t, api, http.MethodGet, "/api/backends", "", map[string]string{"Cookie": sessionCookieName + "=" + cookie}) + if ok.Code != http.StatusOK || !strings.Contains(ok.Body.String(), "Secret") { + t.Errorf("alice /api/backends = %d body %s, want 200 with her preview", ok.Code, ok.Body) + } +} diff --git a/cmd/mxcli/tunnelhub/auth.go b/cmd/mxcli/tunnelhub/auth.go index ecb9d9acf..7653aa1f3 100644 --- a/cmd/mxcli/tunnelhub/auth.go +++ b/cmd/mxcli/tunnelhub/auth.go @@ -7,6 +7,8 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "fmt" + "io" "net/http" "net/url" "strconv" @@ -72,19 +74,31 @@ func (a *AuthConfig) auditSink() audit.Sink { return audit.NoOp() } -// signSession produces an HMAC-signed cookie value carrying {login, exp}. Format: -// base64url(payload) "." base64url(HMAC-SHA256(payload)), payload = "login|expUnix". -func signSession(secret []byte, login string, exp time.Time) string { - payload := login + "|" + strconv.FormatInt(exp.Unix(), 10) +// Signing tags domain-separate the two token types so a session cookie can never +// be replayed as an OAuth state (or vice versa): the tag is mixed into the MAC, +// and each verifier only accepts its own tag. +const ( + tagSession = "session" + tagState = "state" +) + +// signTagged produces an HMAC-signed value carrying {msg, exp} under a domain tag. +// Format: base64url(payload) "." base64url(HMAC-SHA256(tag ‖ 0x00 ‖ payload)), +// payload = "msg|expUnix". The tag is authenticated but not carried in the token +// (the verifier supplies the expected tag). +func signTagged(secret []byte, tag, msg string, exp time.Time) string { + payload := msg + "|" + strconv.FormatInt(exp.Unix(), 10) mac := hmac.New(sha256.New, secret) + mac.Write([]byte(tag)) + mac.Write([]byte{0}) mac.Write([]byte(payload)) sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig } -// verifySession validates a cookie value and returns the login when the signature -// is valid and the session has not expired. Constant-time signature comparison. -func verifySession(secret []byte, value string, now time.Time) (login string, ok bool) { +// verifyTagged validates a value under the expected tag and returns msg when the +// signature is valid and it has not expired. Constant-time signature comparison. +func verifyTagged(secret []byte, tag, value string, now time.Time) (msg string, ok bool) { dot := strings.LastIndexByte(value, '.') if dot <= 0 { return "", false @@ -95,12 +109,14 @@ func verifySession(secret []byte, value string, now time.Time) (login string, ok return "", false } mac := hmac.New(sha256.New, secret) + mac.Write([]byte(tag)) + mac.Write([]byte{0}) mac.Write(payload) want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(want), []byte(sigB64)) { return "", false } - // payload = "login|expUnix"; login (GitHub) never contains '|'. + // payload = "msg|expUnix"; msg (GitHub login / b64 url) never contains '|'. sep := strings.LastIndexByte(string(payload), '|') if sep <= 0 { return "", false @@ -115,6 +131,15 @@ func verifySession(secret []byte, value string, now time.Time) (login string, ok return string(payload[:sep]), true } +// signSession / verifySession are the session-cookie wrappers (tag "session"). +func signSession(secret []byte, login string, exp time.Time) string { + return signTagged(secret, tagSession, login, exp) +} + +func verifySession(secret []byte, value string, now time.Time) (login string, ok bool) { + return verifyTagged(secret, tagSession, value, now) +} + // setSessionCookie writes a fresh signed session cookie for login. func (a *AuthConfig) setSessionCookie(w http.ResponseWriter, login string) { exp := a.clock().Add(a.ttl()) @@ -189,11 +214,11 @@ func (a *AuthConfig) callbackURL() string { // no delimiter collides), reusing the session HMAC scheme with a short TTL. func (a *AuthConfig) signState(returnURL string) string { enc := base64.RawURLEncoding.EncodeToString([]byte(returnURL)) - return signSession(a.SessionSecret, enc, a.clock().Add(10*time.Minute)) + return signTagged(a.SessionSecret, tagState, enc, a.clock().Add(10*time.Minute)) } func (a *AuthConfig) verifyState(state string) (returnURL string, ok bool) { - enc, ok := verifySession(a.SessionSecret, state, a.clock()) + enc, ok := verifyTagged(a.SessionSecret, tagState, state, a.clock()) if !ok { return "", false } @@ -204,15 +229,12 @@ func (a *AuthConfig) verifyState(state string) (returnURL string, ok bool) { return string(b), true } -// clientIP returns the source address for audit, honouring the 443 front's -// X-Forwarded-For (first hop) when present. +// clientIP returns the source address for audit. The hub terminates TLS directly +// (it *is* the 443 edge), so RemoteAddr is the real peer and X-Forwarded-For is +// attacker-controlled — we deliberately do NOT trust it, to keep audit IPs +// unspoofable. A deployment that fronts the hub with a trusted reverse proxy would +// need an explicit allow-listed-proxy option before honouring XFF. func clientIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - if i := strings.IndexByte(xff, ','); i >= 0 { - return strings.TrimSpace(xff[:i]) - } - return strings.TrimSpace(xff) - } return stripPort(r.RemoteAddr) } @@ -320,12 +342,22 @@ func (a *AuthConfig) exchangeCode(code string) (string, error) { return "", err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github token exchange returned HTTP %d", resp.StatusCode) + } var body struct { AccessToken string `json:"access_token"` + Error string `json:"error"` } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body); err != nil { return "", err } + if body.Error != "" { + return "", fmt.Errorf("github token exchange failed: %s", body.Error) + } + if body.AccessToken == "" { + return "", fmt.Errorf("github token exchange returned no access_token") + } return body.AccessToken, nil } @@ -338,10 +370,13 @@ func (a *AuthConfig) fetchLogin(token string) (string, error) { return "", err } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github /user returned HTTP %d", resp.StatusCode) + } var body struct { Login string `json:"login"` } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body); err != nil { return "", err } return body.Login, nil diff --git a/cmd/mxcli/tunnelhub/auth_test.go b/cmd/mxcli/tunnelhub/auth_test.go index 584f79b85..65f873007 100644 --- a/cmd/mxcli/tunnelhub/auth_test.go +++ b/cmd/mxcli/tunnelhub/auth_test.go @@ -300,16 +300,14 @@ func TestAuthorizePreview_OwnerAllowsOtherDenies(t *testing.T) { } } -func TestClientIP_PrefersForwardedFor(t *testing.T) { +// The hub is the TLS edge, so clientIP must use RemoteAddr and must NOT trust a +// client-supplied X-Forwarded-For (which would let a caller spoof audit IPs). +func TestClientIP_UsesRemoteAddrNotForwardedFor(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "https://app.mxcli.org/", nil) r.RemoteAddr = "10.0.0.1:5555" - r.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1") - if got := clientIP(r); got != "203.0.113.7" { - t.Errorf("clientIP = %q, want 203.0.113.7", got) - } - r.Header.Del("X-Forwarded-For") + r.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1") // attacker-supplied if got := clientIP(r); got != "10.0.0.1" { - t.Errorf("clientIP fallback = %q, want 10.0.0.1", got) + t.Errorf("clientIP = %q, want 10.0.0.1 (RemoteAddr, XFF ignored)", got) } } @@ -351,3 +349,21 @@ func TestLogout_ClearsCookieAndAudits(t *testing.T) { t.Errorf("expected logout audit line, got: %s", buf.String()) } } + +// TestSessionStateDomainSeparation asserts a session cookie can't be replayed as +// an OAuth state, and vice versa — the two share a secret but not a signing tag. +func TestSessionStateDomainSeparation(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + a := testAuth(now) + + // A valid session cookie must NOT verify as a state token. + cookie := signSession(a.SessionSecret, "alice", now.Add(time.Hour)) + if _, ok := a.verifyState(cookie); ok { + t.Error("a session cookie must not be accepted as an OAuth state") + } + // A valid state token must NOT verify as a session cookie. + state := a.signState("https://app.mxcli.org/") + if _, ok := verifySession(a.SessionSecret, state, now); ok { + t.Error("an OAuth state must not be accepted as a session cookie") + } +} diff --git a/cmd/mxcli/tunnelhub/server.go b/cmd/mxcli/tunnelhub/server.go index 359c7b58f..927b73493 100644 --- a/cmd/mxcli/tunnelhub/server.go +++ b/cmd/mxcli/tunnelhub/server.go @@ -197,6 +197,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.opts.Auth.authHandler().ServeHTTP(w, r) return } + // The admin overview shows the viewer's own previews — gate it behind a + // session so an anonymous visitor is sent to login rather than served a + // page whose /api/backends fetch would 401. + if s.opts.Auth.enabled() && s.opts.Auth.sessionLogin(r) == "" { + ret := "https://" + s.opts.HubHost + r.URL.RequestURI() + http.Redirect(w, r, "https://"+s.opts.HubHost+"/auth/github/login?return="+url.QueryEscape(ret), http.StatusFound) + return + } s.admin.ServeHTTP(w, r) default: sub, ok := s.subOf(host) diff --git a/docs/11-proposals/PROPOSAL_hub_authentication.md b/docs/11-proposals/PROPOSAL_hub_authentication.md index d15dd8efb..1024a8491 100644 --- a/docs/11-proposals/PROPOSAL_hub_authentication.md +++ b/docs/11-proposals/PROPOSAL_hub_authentication.md @@ -214,6 +214,31 @@ Client (`mxcli run --hub` / `mxcli auth`): `--require-auth` defaults on with a client id present; document that a client id without a session secret refuses to start. +### Review follow-ups (external code review of the PR) + +An independent review of the implementation surfaced these; the first four are **fixed +in the PR**, the last two are **acknowledged limitations** tracked here: + +- **Anonymous listing leak (fixed).** `GET /api/backends` filtered by `sessionLogin`, + which returned `""` for both "auth off" and "no session" — so with `--require-auth` on, + an unauthenticated caller received *every* user's previews. `handleBackends` now `401`s + when auth is enabled and the caller has no valid session; the admin page redirects an + anonymous visitor to login rather than serving a shell whose fetch would `401`. +- **Session/state confusion (fixed).** The session cookie and the OAuth `state` shared one + HMAC scheme. They are now domain-separated by a signing tag (`session` vs `state`), so + neither can be replayed as the other. +- **OAuth status handling (fixed).** `exchangeCode`/`fetchLogin` now check the HTTP status + (and the token endpoint's `error` field) before decoding, instead of decoding blindly. +- **Spoofable audit IP (fixed).** The hub *is* the TLS edge, so `clientIP` now uses + `RemoteAddr` and ignores the client-supplied `X-Forwarded-For` (which could forge audit + IPs). A future trusted-proxy deployment would add an explicit allow-list option. +- **Key persistence (acknowledged, deferred).** The key store is in-memory, so a hub + restart invalidates all hub keys (automation must re-run `auth hub login`, or use a + durable `MXCLI_HUB_KEY`). Persisting keys to disk is future work — see *Open questions*. +- **No rate-limiting on `/api/keys` (acknowledged, deferred).** A holder of a valid GitHub + token can mint unbounded keys. Keys are login-bound and revocable, so the blast radius is + that user's own quota; a mint rate-limit is hardening for a later pass. + ## Audit logging & usage tracking A hosted, internet-facing hub needs a durable record of **who authenticated, who was From dddcdb3a734f7f4f058b823258b0449b412bd2db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 05:23:29 +0000 Subject: [PATCH 11/17] hub-auth: show signed-in GitHub identity on the admin page 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 " 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/tunnelhub/admin.go | 24 +++++++++++++++-- cmd/mxcli/tunnelhub/api.go | 22 ++++++++++++++++ cmd/mxcli/tunnelhub/api_keys_test.go | 39 ++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/cmd/mxcli/tunnelhub/admin.go b/cmd/mxcli/tunnelhub/admin.go index 73f14980c..80f3a84b4 100644 --- a/cmd/mxcli/tunnelhub/admin.go +++ b/cmd/mxcli/tunnelhub/admin.go @@ -44,12 +44,18 @@ const adminHTML = ` .sol { color:var(--mut); font-size:.82rem; } .empty { color:var(--mut); padding:2rem 0; } code { font-family:ui-monospace,monospace; } + .who { color:var(--mut); font-size:.85rem; } + .who b { color:var(--fg); font-weight:600; } + .signout { font:inherit; font-size:.82rem; color:var(--accent); background:none; border:1px solid var(--line); border-radius:.4rem; padding:.15rem .55rem; cursor:pointer; } + .signout:hover { border-color:var(--accent); }

mxcli tunnel-hub

- + + +
@@ -119,10 +125,24 @@ const adminHTML = ` function load(){ fetch("/api/backends").then(function(r){return r.json();}).then(function(j){ data=j||[]; render(); }).catch(function(){}); } + function whoami(){ + fetch("/api/whoami").then(function(r){return r.json();}).then(function(j){ + var who = document.getElementById("who"), btn = document.getElementById("signout"); + if(j && j.authEnabled && j.login){ + who.innerHTML = "signed in as "+esc(j.login)+""; + btn.hidden = false; + } else { + who.textContent = ""; btn.hidden = true; + } + }).catch(function(){}); + } + document.getElementById("signout").addEventListener("click", function(){ + fetch("/auth/logout", {method:"POST"}).then(function(){ location.reload(); }).catch(function(){ location.reload(); }); + }); document.querySelectorAll("th").forEach(function(th){ th.addEventListener("click", function(){ sortKey=th.dataset.k; render(); }); }); - load(); setInterval(load, 5000); + whoami(); load(); setInterval(load, 5000); })(); ` diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 4ef3b5fd8..9fd0a0542 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -77,6 +77,28 @@ func (a *API) Mount(mux *http.ServeMux) { mux.HandleFunc("/api/backends", a.handleBackends) mux.HandleFunc("/api/keys", a.handleKeys) mux.HandleFunc("/api/auth-config", a.handleAuthConfig) + mux.HandleFunc("/api/whoami", a.handleWhoami) +} + +// WhoamiResponse tells the admin page who the current session belongs to, so a +// signed-in viewer can confirm their identity (and see a Sign-out control). +type WhoamiResponse struct { + AuthEnabled bool `json:"authEnabled"` + Login string `json:"login,omitempty"` +} + +// handleWhoami (GET /api/whoami) returns the authenticated GitHub login for the +// current session, or an empty login in open mode / when unauthenticated. +func (a *API) handleWhoami(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + resp := WhoamiResponse{AuthEnabled: a.opts.Auth.enabled()} + if resp.AuthEnabled { + resp.Login = a.opts.Auth.sessionLogin(r) + } + writeJSON(w, http.StatusOK, resp) } // AuthConfigResponse tells a client whether the hub requires GitHub auth and, if diff --git a/cmd/mxcli/tunnelhub/api_keys_test.go b/cmd/mxcli/tunnelhub/api_keys_test.go index 51c7f9ca3..11c8de81e 100644 --- a/cmd/mxcli/tunnelhub/api_keys_test.go +++ b/cmd/mxcli/tunnelhub/api_keys_test.go @@ -6,6 +6,7 @@ import ( "bytes" "encoding/json" "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -227,3 +228,41 @@ func TestAPI_BackendsRequiresAuthWhenEnabled(t *testing.T) { t.Errorf("alice /api/backends = %d body %s, want 200 with her preview", ok.Code, ok.Body) } } + +func TestAPI_Whoami(t *testing.T) { + // Open mode: authEnabled false, no login. + open, _ := newTestAPI(t, "") + rec := doJSON(t, open, http.MethodGet, "/api/whoami", "", nil) + var wo WhoamiResponse + _ = json.Unmarshal(rec.Body.Bytes(), &wo) + if wo.AuthEnabled || wo.Login != "" { + t.Errorf("open whoami = %+v, want disabled/empty", wo) + } + + // Auth on, no session → authEnabled true, empty login. + api, _, done := newAuthedAPI(t, true) + defer done() + r1 := doJSON(t, api, http.MethodGet, "/api/whoami", "", nil) + var w1 WhoamiResponse + _ = json.Unmarshal(r1.Body.Bytes(), &w1) + if !w1.AuthEnabled || w1.Login != "" { + t.Errorf("no-session whoami = %+v, want enabled + empty login", w1) + } + + // Auth on, valid session cookie → the login. + rec2 := httptest.NewRecorder() + api.opts.Auth.setSessionCookie(rec2, "alice") + mux := http.NewServeMux() + api.Mount(mux) + req := httptest.NewRequest(http.MethodGet, "/api/whoami", nil) + for _, c := range rec2.Result().Cookies() { + req.AddCookie(c) + } + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + var w2 WhoamiResponse + _ = json.Unmarshal(rw.Body.Bytes(), &w2) + if !w2.AuthEnabled || w2.Login != "alice" { + t.Errorf("session whoami = %+v, want alice", w2) + } +} From b22b5d78ff11831847e290b728a4c7765ddbb09d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 06:25:22 +0000 Subject: [PATCH 12/17] hub-auth: post-deploy fixes from sudoku finding #31 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/mendix/run-local.md | 19 ++++++++++-- cmd/mxcli/cmd_auth_hub.go | 12 +++++++- cmd/mxcli/docker/runlocal.go | 14 +++++++-- cmd/mxcli/hubauth/deviceflow.go | 15 ++++++++++ cmd/mxcli/hubauth/deviceflow_test.go | 20 +++++++++++++ cmd/mxcli/tunnelhub/api.go | 18 ++++++++--- cmd/mxcli/tunnelhub/api_keys_test.go | 45 ++++++++++++++++++++++++++++ 7 files changed, 133 insertions(+), 10 deletions(-) diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local.md index 9825e5822..fffc161f1 100644 --- a/.claude/skills/mendix/run-local.md +++ b/.claude/skills/mendix/run-local.md @@ -289,9 +289,16 @@ mxcli auth hub logout # revoke + remove it mxcli run --hub https://hub.mxcli.org -p app.mpr ``` -For an unattended environment (Claude Code on the web — the container is reaped, so the -interactive login won't persist), set the key once as an environment/repo secret; it takes -precedence over the stored key: +**Where the device flow is blocked** (Claude Code web containers only allow repo-scoped +GitHub API paths, so `mxcli auth hub login` gets a 403 before printing a code), mint the +key from a GitHub token directly instead: + +```bash +mxcli auth hub login --token # skips the device flow, mints + caches the key +``` + +For an unattended environment (the container is reaped, so any cached login won't persist), +set the key once as an environment/repo secret; it takes precedence over the stored key: ```bash export MXCLI_HUB_KEY= # from `mxcli auth hub login` on a durable machine @@ -300,6 +307,12 @@ export MXCLI_HUB_KEY= # from `mxcli auth hub login` on a durable The GitHub token never leaves your machine except for the one-time key-mint request to the hub; only the opaque hub key is stored (`~/.mxcli/auth.json`, mode 0600, keyed by hub host). +**Registration failure is non-fatal:** if the hub is unreachable or the key is stale/rejected, +`run --hub` prints a warning and continues as a normal local run (the app boots on localhost; +only the public preview URL is lost). On an authenticated hub the shared `--hub-secret` still +works as a fallback registration credential — a valid key stamps the preview as yours, a valid +secret registers it owner-less. + ## Validation checklist - [ ] Project is Mendix 11.x. diff --git a/cmd/mxcli/cmd_auth_hub.go b/cmd/mxcli/cmd_auth_hub.go index 5ad41a67a..fd171977c 100644 --- a/cmd/mxcli/cmd_auth_hub.go +++ b/cmd/mxcli/cmd_auth_hub.go @@ -54,15 +54,25 @@ func init() { for _, c := range []*cobra.Command{authHubLoginCmd, authHubStatusCmd, authHubLogoutCmd} { c.Flags().String("hub", hubauth.DefaultHubURL, "hub base URL") } + authHubLoginCmd.Flags().String("token", "", "GitHub token (PAT) to mint the hub key from directly, skipping the device flow (use where the device flow is blocked, e.g. Claude Code web)") authHubCmd.AddCommand(authHubLoginCmd, authHubStatusCmd, authHubLogoutCmd) authCmd.AddCommand(authHubCmd) } func runAuthHubLogin(cmd *cobra.Command, _ []string) error { hubURL, _ := cmd.Flags().GetString("hub") + token, _ := cmd.Flags().GetString("token") client := &hubauth.Client{HubURL: hubURL} - login, err := client.Login(cmd.Context(), cmd.OutOrStdout()) + var login string + var err error + if token != "" { + // Direct token → key mint (no device flow). For environments whose egress + // proxy blocks GitHub's device endpoints. + login, err = client.LoginWithToken(cmd.Context(), token) + } else { + login, err = client.Login(cmd.Context(), cmd.OutOrStdout()) + } if err != nil { return err } diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 2388987cf..7678b383f 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -544,9 +544,19 @@ func RunLocal(opts LocalRunOptions) error { fmt.Fprintf(w, "Registering with hub %s...\n", opts.Hub) hubReg, err = RegisterWithHub(opts.Hub, opts.HubSecret, opts.HubKey, meta, opts.AppPort) if err != nil { - return fmt.Errorf("hub registration: %w", err) + // A failed registration (unreachable hub, stale/rejected key) must cost + // only the public preview URL — not the whole run. Degrade to a normal + // local run so the app still boots and is reachable on localhost. + // (findings #27, #31C) + fmt.Fprintf(stderr, "Warning: hub registration failed (%v); continuing local-only — the app runs on localhost but has no public preview URL.\n", err) + if opts.HubKey == "" { + fmt.Fprintf(stderr, " hint: this hub requires auth; set MXCLI_HUB_KEY or run 'mxcli auth hub login'.\n") + } + hubReg = nil + err = nil + } else { + appRootURL = hubReg.URL } - appRootURL = hubReg.URL } // 6. Boot the runtime against the fresh deployment. Tee the runtime's own diff --git a/cmd/mxcli/hubauth/deviceflow.go b/cmd/mxcli/hubauth/deviceflow.go index aee8b8728..153012e64 100644 --- a/cmd/mxcli/hubauth/deviceflow.go +++ b/cmd/mxcli/hubauth/deviceflow.go @@ -237,6 +237,21 @@ func (c *Client) RevokeHubKey(ctx context.Context, key string) error { return nil } +// LoginWithToken mints and stores a hub key directly from a GitHub token (PAT or +// OAuth token), skipping the device flow. This is the escape hatch for +// environments whose egress proxy blocks GitHub's device endpoints (e.g. Claude +// Code web containers, which only permit repo-scoped GitHub paths). (findings #31A) +func (c *Client) LoginWithToken(ctx context.Context, githubToken string) (login string, err error) { + key, login, err := c.MintHubKey(ctx, strings.TrimSpace(githubToken)) + if err != nil { + return "", err + } + if err := SaveKey(c.HubURL, key); err != nil { + return "", fmt.Errorf("saving hub key: %w", err) + } + return login, nil +} + // Login runs the full bootstrap: discover the client id, device flow, mint a hub // key, and store it. Prompts are written to out. Returns the resolved GitHub // login. The GitHub token never leaves this process except for the mint call. diff --git a/cmd/mxcli/hubauth/deviceflow_test.go b/cmd/mxcli/hubauth/deviceflow_test.go index 9feef091f..6be852363 100644 --- a/cmd/mxcli/hubauth/deviceflow_test.go +++ b/cmd/mxcli/hubauth/deviceflow_test.go @@ -149,3 +149,23 @@ func TestLogin_OpenModeHubIsAnError(t *testing.T) { t.Error("Login against an open-mode hub should error (no key needed)") } } + +func TestLoginWithToken_MintsAndStores(t *testing.T) { + withTempStore(t) + t.Setenv(EnvHubKey, "") + + hub := fakeHub(t, "cid", "alice") + defer hub.Close() + c := &Client{HubURL: hub.URL, HTTP: hub.Client()} + + login, err := c.LoginWithToken(context.Background(), "gho_from_device") + if err != nil { + t.Fatalf("LoginWithToken: %v", err) + } + if login != "alice" { + t.Errorf("login = %q, want alice", login) + } + if k := ResolveKey(hub.URL); k != "hub_minted_key" { + t.Errorf("stored key = %q, want hub_minted_key", k) + } +} diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 9fd0a0542..237d272f5 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -187,19 +187,29 @@ func (a *API) authorizeRegister(w http.ResponseWriter, r *http.Request) (owner s } return "", true } + // A valid per-user key stamps an owner. key := strings.TrimSpace(r.Header.Get("X-Hub-Key")) if a.opts.Keys != nil { if login, found := a.opts.Keys.Resolve(key); found { return login, true } } - // No valid key. Enforce only when required; soft mode registers anonymously. - if a.opts.Auth.RequireAuth { + // Fall back to the shared secret: a valid X-Hub-Secret registers owner-less, + // even with auth on. This keeps the legacy secret a meaningful registration + // credential during a transition (key → owner, secret → owner-less), rather + // than being silently ignored once OAuth is enabled. (findings #31B) + if a.opts.RegisterSecret != "" && r.Header.Get("X-Hub-Secret") == a.opts.RegisterSecret { + return "", true + } + // No valid credential. Refuse when auth is required or a shared secret is + // configured (so a set secret actually gates); otherwise (soft mode, no + // secret) register anonymously as before. + if a.opts.Auth.RequireAuth || a.opts.RegisterSecret != "" { a.opts.Audit.Log(audit.Event{ Event: audit.EventRegisterDeny, IP: clientIP(r), Outcome: "deny", - Detail: "missing or invalid X-Hub-Key", + Detail: "missing or invalid X-Hub-Key / X-Hub-Secret", }) - http.Error(w, "missing or invalid X-Hub-Key (run 'mxcli auth hub login')", http.StatusUnauthorized) + http.Error(w, "missing or invalid X-Hub-Key (run 'mxcli auth hub login') or X-Hub-Secret", http.StatusUnauthorized) return "", false } return "", true diff --git a/cmd/mxcli/tunnelhub/api_keys_test.go b/cmd/mxcli/tunnelhub/api_keys_test.go index 11c8de81e..6f4d1e39e 100644 --- a/cmd/mxcli/tunnelhub/api_keys_test.go +++ b/cmd/mxcli/tunnelhub/api_keys_test.go @@ -266,3 +266,48 @@ func TestAPI_Whoami(t *testing.T) { t.Errorf("session whoami = %+v, want alice", w2) } } + +// TestAPI_RegisterSecretFallbackWhenAuthOn covers finding #31B: with auth on, a +// valid X-Hub-Secret registers owner-less (the legacy secret stays a meaningful +// credential), while a wrong/absent one is refused. +func TestAPI_RegisterSecretFallbackWhenAuthOn(t *testing.T) { + api, buf, done := newAuthedAPI(t, true) // require-auth on + defer done() + api.opts.RegisterSecret = "s3cret" + + // Valid shared secret, no key → 200, owner-less. + ok := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"A","branch":"main"}`, + map[string]string{"X-Hub-Secret": "s3cret"}) + if ok.Code != http.StatusOK { + t.Fatalf("secret register status = %d, want 200 (body %s)", ok.Code, ok.Body) + } + if got := api.opts.Registry.List("project", ""); len(got) != 1 || got[0].Owner != "" { + t.Errorf("secret register should be owner-less, got %+v", got) + } + // Wrong secret, no key → 401 + register_deny. + bad := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"B","branch":"main"}`, + map[string]string{"X-Hub-Secret": "nope"}) + if bad.Code != http.StatusUnauthorized { + t.Errorf("wrong-secret status = %d, want 401", bad.Code) + } + if !strings.Contains(buf.String(), `"event":"register_deny"`) { + t.Errorf("expected register_deny audit line, got: %s", buf.String()) + } +} + +// TestAPI_SoftModeWithSecretRequiresIt covers the flip side of #31B: in soft mode +// (require-auth off) a configured secret still gates — a keyless, secretless +// registration is refused rather than silently allowed. +func TestAPI_SoftModeWithSecretRequiresIt(t *testing.T) { + api, _, done := newAuthedAPI(t, false) // soft + defer done() + api.opts.RegisterSecret = "s3cret" + + if rec := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"A"}`, nil); rec.Code != http.StatusUnauthorized { + t.Errorf("soft-mode + secret, no creds: status = %d, want 401", rec.Code) + } + if rec := doJSON(t, api, http.MethodPost, "/api/register", `{"project":"A"}`, + map[string]string{"X-Hub-Secret": "s3cret"}); rec.Code != http.StatusOK { + t.Errorf("soft-mode + valid secret: status = %d, want 200", rec.Code) + } +} From 23de09c497449a59019748998dbd20a46280b736 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 12:57:42 +0000 Subject: [PATCH 13/17] hub-auth: durable key store so keys survive restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/cmd_tunnelhub.go | 12 +++ cmd/mxcli/tunnelhub/keys.go | 141 ++++++++++++++++++++++++++++--- cmd/mxcli/tunnelhub/keys_test.go | 58 ++++++++++++- cmd/mxcli/tunnelhub/server.go | 12 ++- 4 files changed, 208 insertions(+), 15 deletions(-) diff --git a/cmd/mxcli/cmd_tunnelhub.go b/cmd/mxcli/cmd_tunnelhub.go index 82813fa4a..b89ad72c1 100644 --- a/cmd/mxcli/cmd_tunnelhub.go +++ b/cmd/mxcli/cmd_tunnelhub.go @@ -71,6 +71,7 @@ Then, in each app's environment: cookieDomain, _ := cmd.Flags().GetString("cookie-domain") requireAuth, _ := cmd.Flags().GetBool("require-auth") auditLog, _ := cmd.Flags().GetString("audit-log") + keysFile, _ := cmd.Flags().GetString("keys-file") if domain == "" { fmt.Fprintln(os.Stderr, "Error: --domain is required (the wildcard base, e.g. example.com)") @@ -80,6 +81,12 @@ Then, in each app's environment: home, _ := os.UserHomeDir() certCache = filepath.Join(home, ".mxcli", "hub-certs") } + // Persist keys by default when auth is on, so they survive restarts and + // users don't have to re-configure MXCLI_HUB_KEY after every redeploy. + if keysFile == "" && ghClientID != "" { + home, _ := os.UserHomeDir() + keysFile = filepath.Join(home, ".mxcli", "hub-keys.json") + } // Env fallbacks so secrets need not appear in the process table. if ghClientSecret == "" { ghClientSecret = os.Getenv("MXCLI_HUB_GH_SECRET") @@ -106,6 +113,7 @@ Then, in each app's environment: CertCacheDir: certCache, Auth: auth, Audit: auditSink, + KeysFile: keysFile, }) if err != nil { fmt.Fprintf(os.Stderr, "Error: configuring tunnel-hub: %v\n", err) @@ -117,6 +125,9 @@ Then, in each app's environment: mode = "required (owner check enforced on previews)" } fmt.Printf(" auth: GitHub OAuth enabled, %s\n", mode) + if keysFile != "" { + fmt.Printf(" keys: durable at %s (survive restarts)\n", keysFile) + } } host := hubHost @@ -181,5 +192,6 @@ func init() { tunnelHubCmd.Flags().String("cookie-domain", "", "Session cookie domain for SSO across previews (default .)") tunnelHubCmd.Flags().Bool("require-auth", true, "Enforce the owner check on previews (deny non-owners); --require-auth=false leaves owned previews open but still filters the listing") tunnelHubCmd.Flags().String("audit-log", "", "Append-only JSONL audit trail path (\"stdout\" for stdout; empty = off)") + tunnelHubCmd.Flags().String("keys-file", "", "Durable hub API-key store path (default ~/.mxcli/hub-keys.json when auth is on); keys survive restarts so clients keep their MXCLI_HUB_KEY") rootCmd.AddCommand(tunnelHubCmd) } diff --git a/cmd/mxcli/tunnelhub/keys.go b/cmd/mxcli/tunnelhub/keys.go index 8f9e4473b..6825d4dbe 100644 --- a/cmd/mxcli/tunnelhub/keys.go +++ b/cmd/mxcli/tunnelhub/keys.go @@ -5,7 +5,15 @@ package tunnelhub import ( "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" "sync" + "time" ) // KeyStore maps opaque hub API keys to the GitHub login they were minted for. @@ -16,17 +24,48 @@ import ( // credential, mirroring how the audit trail refuses to carry secrets. The plain // key is returned to the caller exactly once, at Mint. // -// In-memory for the first cut (keys are lost on hub restart; a user re-runs -// `mxcli auth hub login`). All methods are safe for concurrent use. +// A key does not expire and is not tied to any browser session — it stays valid +// until explicitly revoked, so a user configures MXCLI_HUB_KEY once. When a file +// path is set the store is durable: keys survive hub restarts (otherwise every +// restart would force everyone to re-mint). All methods are safe for concurrent +// use. type KeyStore struct { mu sync.RWMutex - byHash map[string]string // sha256(key) -> login - newToken func() string // overridable for tests; default newToken() + byHash map[string]keyRecord // sha256(key) -> record + path string // "" = in-memory only (no persistence) + newToken func() string // overridable for tests; default newToken() + now func() time.Time // overridable for tests; default time.Now } -// NewKeyStore returns an empty key store. +// keyRecord is the persisted per-key metadata (never the plaintext key). +type keyRecord struct { + Login string `json:"login"` + CreatedAt time.Time `json:"createdAt"` +} + +// keysFile is the on-disk layout for the durable key store. +type keysFile struct { + Version int `json:"version"` + Keys map[string]keyRecord `json:"keys"` +} + +const keysFileVersion = 1 + +// NewKeyStore returns an empty in-memory key store (no persistence). Suitable for +// tests and open-mode hubs; keys are lost on restart. func NewKeyStore() *KeyStore { - return &KeyStore{byHash: map[string]string{}} + return &KeyStore{byHash: map[string]keyRecord{}} +} + +// NewKeyStoreFile returns a durable key store backed by path. An existing file is +// loaded; mint/revoke write through (atomic, mode 0600). Keys persist across hub +// restarts so users need not reconfigure MXCLI_HUB_KEY. +func NewKeyStoreFile(path string) (*KeyStore, error) { + s := &KeyStore{byHash: map[string]keyRecord{}, path: path} + if err := s.load(); err != nil { + return nil, err + } + return s, nil } func hashKey(key string) string { @@ -41,12 +80,21 @@ func (s *KeyStore) mintToken() string { return newToken() } +func (s *KeyStore) clock() time.Time { + if s.now != nil { + return s.now() + } + return time.Now().UTC() +} + // Mint issues a fresh opaque key bound to login and returns the plain key (shown -// to the caller once — only its hash is retained). +// to the caller once — only its hash is retained). The store is persisted when +// backed by a file. func (s *KeyStore) Mint(login string) string { key := s.mintToken() s.mu.Lock() - s.byHash[hashKey(key)] = login + s.byHash[hashKey(key)] = keyRecord{Login: login, CreatedAt: s.clock()} + _ = s.saveLocked() s.mu.Unlock() return key } @@ -58,12 +106,12 @@ func (s *KeyStore) Resolve(key string) (login string, ok bool) { } s.mu.RLock() defer s.mu.RUnlock() - login, ok = s.byHash[hashKey(key)] - return login, ok + rec, ok := s.byHash[hashKey(key)] + return rec.Login, ok } // Revoke removes a key. It returns the login it was bound to (for auditing) and -// whether it existed. +// whether it existed. Persisted when backed by a file. func (s *KeyStore) Revoke(key string) (login string, ok bool) { if key == "" { return "", false @@ -71,9 +119,76 @@ func (s *KeyStore) Revoke(key string) (login string, ok bool) { h := hashKey(key) s.mu.Lock() defer s.mu.Unlock() - login, ok = s.byHash[h] + rec, ok := s.byHash[h] if ok { delete(s.byHash, h) + _ = s.saveLocked() + } + return rec.Login, ok +} + +// load reads the key file into memory. A missing file is not an error (fresh +// store). Refuses a world/group-readable file on Unix (keys metadata is sensitive). +func (s *KeyStore) load() error { + if s.path == "" { + return nil + } + info, err := os.Stat(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("key store: stat %s: %w", s.path, err) + } + if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("key store %s has too-open permissions %o (want 0600)", s.path, info.Mode().Perm()) + } + data, err := os.ReadFile(s.path) + if err != nil { + return fmt.Errorf("key store: read %s: %w", s.path, err) + } + if len(data) == 0 { + return nil + } + var kf keysFile + if err := json.Unmarshal(data, &kf); err != nil { + return fmt.Errorf("key store: parse %s: %w", s.path, err) + } + if kf.Keys != nil { + s.byHash = kf.Keys + } + return nil +} + +// saveLocked atomically writes the store to disk (temp + rename, mode 0600). The +// caller must hold s.mu. No-op for an in-memory store. +func (s *KeyStore) saveLocked() error { + if s.path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(keysFile{Version: keysFileVersion, Keys: s.byHash}, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(s.path), ".hub-keys.*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil && runtime.GOOS != "windows" { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err } - return login, ok + return os.Rename(tmpPath, s.path) } diff --git a/cmd/mxcli/tunnelhub/keys_test.go b/cmd/mxcli/tunnelhub/keys_test.go index cd71a7b05..dceac9601 100644 --- a/cmd/mxcli/tunnelhub/keys_test.go +++ b/cmd/mxcli/tunnelhub/keys_test.go @@ -2,7 +2,11 @@ package tunnelhub -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestKeyStore_MintResolveRevoke(t *testing.T) { ks := NewKeyStore() @@ -67,3 +71,55 @@ func TestKeyStore_StoresHashedNotPlaintext(t *testing.T) { t.Error("hashed key should be the map key") } } + +// TestKeyStore_PersistsAcrossRestart is the core of "keys outlive the session": +// a key minted by one store instance resolves in a fresh instance loaded from the +// same file (simulating a hub restart), and a revoke is likewise durable. +func TestKeyStore_PersistsAcrossRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "hub-keys.json") + + s1, err := NewKeyStoreFile(path) + if err != nil { + t.Fatalf("NewKeyStoreFile: %v", err) + } + key := s1.Mint("alice") + + // A brand-new store (hub restart) loads the same file and still knows the key. + s2, err := NewKeyStoreFile(path) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if login, ok := s2.Resolve(key); !ok || login != "alice" { + t.Errorf("after restart Resolve = %q, %v; want alice, true", login, ok) + } + + // Revoke through s2, then reopen again — the revoke persisted. + s2.Revoke(key) + s3, _ := NewKeyStoreFile(path) + if _, ok := s3.Resolve(key); ok { + t.Error("revoked key must not resolve after restart") + } +} + +func TestKeyStore_FileMode0600(t *testing.T) { + path := filepath.Join(t.TempDir(), "hub-keys.json") + s, _ := NewKeyStoreFile(path) + s.Mint("alice") + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("key file mode = %o, want 600", perm) + } +} + +func TestKeyStore_RejectsTooOpenFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "hub-keys.json") + if err := os.WriteFile(path, []byte(`{"version":1,"keys":{}}`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := NewKeyStoreFile(path); err == nil { + t.Error("a world-readable key file should be refused") + } +} diff --git a/cmd/mxcli/tunnelhub/server.go b/cmd/mxcli/tunnelhub/server.go index 927b73493..cb696b33b 100644 --- a/cmd/mxcli/tunnelhub/server.go +++ b/cmd/mxcli/tunnelhub/server.go @@ -54,6 +54,9 @@ type ServerOptions struct { // Audit receives auth + registration events (login/deny/register/key). Nil → // audit.NoOp(). The same sink is shared with Auth. Audit audit.Sink + // KeysFile persists the hub API-key store so keys survive restarts (empty = + // in-memory, keys lost on restart). Only meaningful when Auth is enabled. + KeysFile string } // Server is the running multi-tenant hub: one embedded chisel reverse server @@ -95,8 +98,15 @@ func NewServer(o ServerOptions) (*Server, error) { } // A shared key store backs /api/keys + X-Hub-Key registration (only reachable - // when Auth is enabled; harmless otherwise). + // when Auth is enabled; harmless otherwise). A durable file store keeps keys + // valid across restarts so users don't re-configure MXCLI_HUB_KEY. keys := NewKeyStore() + if o.KeysFile != "" { + keys, err = NewKeyStoreFile(o.KeysFile) + if err != nil { + return nil, fmt.Errorf("key store: %w", err) + } + } api := NewAPI(APIOptions{ Registry: o.Registry, ControlURL: "https://" + o.HubHost, From a3d5ecec6777d0338c64d33bb8f078f3b2019167 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 13:17:45 +0000 Subject: [PATCH 14/17] hub-auth: browser key issuance; retire the device flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/mendix/run-local.md | 34 ++-- cmd/mxcli/cmd_auth_hub.go | 44 ++-- cmd/mxcli/hubauth/deviceflow.go | 288 --------------------------- cmd/mxcli/hubauth/deviceflow_test.go | 171 ---------------- cmd/mxcli/hubauth/hubkey.go | 92 +++++++++ cmd/mxcli/hubauth/hubkey_test.go | 76 +++++++ cmd/mxcli/tunnelhub/admin.go | 102 +++++++++- cmd/mxcli/tunnelhub/api.go | 56 +++++- cmd/mxcli/tunnelhub/api_keys_test.go | 71 +++++++ 9 files changed, 415 insertions(+), 519 deletions(-) delete mode 100644 cmd/mxcli/hubauth/deviceflow.go delete mode 100644 cmd/mxcli/hubauth/deviceflow_test.go create mode 100644 cmd/mxcli/hubauth/hubkey.go create mode 100644 cmd/mxcli/hubauth/hubkey_test.go diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local.md index fffc161f1..2cbf060f1 100644 --- a/.claude/skills/mendix/run-local.md +++ b/.claude/skills/mendix/run-local.md @@ -279,33 +279,25 @@ is all `run --hub` needs. A hub started **with** `--github-oauth-client-id` (see only their own previews, and registration needs a **per-user hub API key** instead of the shared secret. -```bash -# once per environment: mint + cache a hub key from your GitHub identity (device flow) -mxcli auth hub login # defaults to https://hub.mxcli.org; --hub for others -mxcli auth hub status # show whether a key is configured -mxcli auth hub logout # revoke + remove it - -# then run as normal — the key is sent automatically, stamping the preview as yours -mxcli run --hub https://hub.mxcli.org -p app.mpr -``` +**Get a key from the hub's browser page** (works from any device — desktop, or Claude +Code web/mobile, whose container can't reach GitHub's device endpoints): -**Where the device flow is blocked** (Claude Code web containers only allow repo-scoped -GitHub API paths, so `mxcli auth hub login` gets a 403 before printing a code), mint the -key from a GitHub token directly instead: +1. Open `https://hub.mxcli.org/cli` in a browser and sign in with GitHub. +2. Click **Create a hub key** and copy it. +3. Set it as an environment/repo secret in your Claude Code environment — it's picked up + automatically and survives container reaping: ```bash -mxcli auth hub login --token # skips the device flow, mints + caches the key +export MXCLI_HUB_KEY= +mxcli run --hub https://hub.mxcli.org -p app.mpr # registers previews as you ``` -For an unattended environment (the container is reaped, so any cached login won't persist), -set the key once as an environment/repo secret; it takes precedence over the stored key: - -```bash -export MXCLI_HUB_KEY= # from `mxcli auth hub login` on a durable machine -``` +The key is **durable** (no expiry, survives hub restarts) — you set it once. It stays valid +until you revoke it (sign out on the hub, or `mxcli auth hub logout`). -The GitHub token never leaves your machine except for the one-time key-mint request to the -hub; only the opaque hub key is stored (`~/.mxcli/auth.json`, mode 0600, keyed by hub host). +Headless alternative (CI, or a machine with a GitHub token): `mxcli auth hub login --token +` mints and caches a key in `~/.mxcli/auth.json` (mode 0600). The GitHub token +is used once for the mint and never stored. **Registration failure is non-fatal:** if the hub is unreachable or the key is stale/rejected, `run --hub` prints a warning and continues as a normal local run (the app boots on localhost; diff --git a/cmd/mxcli/cmd_auth_hub.go b/cmd/mxcli/cmd_auth_hub.go index fd171977c..7f32bd801 100644 --- a/cmd/mxcli/cmd_auth_hub.go +++ b/cmd/mxcli/cmd_auth_hub.go @@ -5,6 +5,7 @@ package main import ( "fmt" "os" + "strings" "github.com/mendixlabs/mxcli/cmd/mxcli/hubauth" "github.com/spf13/cobra" @@ -19,14 +20,16 @@ var authHubCmd = &cobra.Command{ Long: `Authenticate to a tunnel-hub so 'mxcli run --hub' can register previews under your GitHub identity. -'mxcli auth hub login' runs the GitHub device flow, exchanges your identity for a -hub-minted API key, and caches it in ~/.mxcli/auth.json keyed by hub host. Your -GitHub token stays on this machine except for the single mint request to the hub. +Get a key from the hub's browser page — open https:///cli, sign in with +GitHub, and copy the key. This works from any device (including Claude Code on the +web/mobile, whose container cannot reach GitHub's device endpoints). Set it as an +environment/repo secret so every session picks it up: -For an unattended environment (e.g. Claude Code on the web, where the container is -reaped), set the key once as an environment/repo secret instead: + MXCLI_HUB_KEY= # takes precedence over any stored key - MXCLI_HUB_KEY= # takes precedence over the stored key +'mxcli auth hub login --token ' is the headless alternative (CI): it +mints a key from a GitHub token and caches it in ~/.mxcli/auth.json. 'status' and +'logout' inspect and revoke the stored key. Open self-hosted hubs need no key — 'run --hub' falls back to the shared --hub-secret.`, @@ -54,7 +57,7 @@ func init() { for _, c := range []*cobra.Command{authHubLoginCmd, authHubStatusCmd, authHubLogoutCmd} { c.Flags().String("hub", hubauth.DefaultHubURL, "hub base URL") } - authHubLoginCmd.Flags().String("token", "", "GitHub token (PAT) to mint the hub key from directly, skipping the device flow (use where the device flow is blocked, e.g. Claude Code web)") + authHubLoginCmd.Flags().String("token", "", "GitHub token (PAT) to mint the hub key from, for headless use (CI); interactive users mint from the hub's /cli page instead") authHubCmd.AddCommand(authHubLoginCmd, authHubStatusCmd, authHubLogoutCmd) authCmd.AddCommand(authHubCmd) } @@ -62,22 +65,27 @@ func init() { func runAuthHubLogin(cmd *cobra.Command, _ []string) error { hubURL, _ := cmd.Flags().GetString("hub") token, _ := cmd.Flags().GetString("token") - client := &hubauth.Client{HubURL: hubURL} + out := cmd.OutOrStdout() - var login string - var err error - if token != "" { - // Direct token → key mint (no device flow). For environments whose egress - // proxy blocks GitHub's device endpoints. - login, err = client.LoginWithToken(cmd.Context(), token) - } else { - login, err = client.Login(cmd.Context(), cmd.OutOrStdout()) + // The primary way to get a key is the hub's browser page — it works from any + // device (including Claude Code web/mobile, whose container can't run GitHub's + // device flow). This command covers the headless path: mint from a token. + if token == "" { + fmt.Fprintf(out, "To get a hub key, open this in a browser, sign in with GitHub, and copy the key:\n\n") + fmt.Fprintf(out, " %s/cli\n\n", strings.TrimRight(hubURL, "/")) + fmt.Fprintf(out, "Then set it in your environment (a repo/environment secret in Claude Code):\n") + fmt.Fprintf(out, " export %s=\n\n", hubauth.EnvHubKey) + fmt.Fprintf(out, "Or, headless, mint from a GitHub token: mxcli auth hub login --token \n") + return nil } + + client := &hubauth.Client{HubURL: hubURL} + login, err := client.LoginWithToken(cmd.Context(), token) if err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "\n✓ Logged in as %s. Hub key saved for %s.\n", login, hubauth.HostOf(hubURL)) - fmt.Fprintf(cmd.OutOrStdout(), " 'mxcli run --hub %s' will now register previews as %s.\n", hubURL, login) + fmt.Fprintf(out, "✓ Logged in as %s. Hub key saved for %s.\n", login, hubauth.HostOf(hubURL)) + fmt.Fprintf(out, " 'mxcli run --hub %s' will now register previews as %s.\n", hubURL, login) return nil } diff --git a/cmd/mxcli/hubauth/deviceflow.go b/cmd/mxcli/hubauth/deviceflow.go deleted file mode 100644 index 153012e64..000000000 --- a/cmd/mxcli/hubauth/deviceflow.go +++ /dev/null @@ -1,288 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package hubauth - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -// Client carries the transport + endpoints for a login. Zero value is production -// (real GitHub, http.DefaultClient); tests override the bases + clock. -type Client struct { - HubURL string // e.g. https://hub.mxcli.org - HTTP *http.Client // default http.DefaultClient - GitHubBase string // default https://github.com (device code + token poll) - - // now/sleep are injectable so tests don't wall-clock through the poll loop. - now func() time.Time - sleep func(context.Context, time.Duration) -} - -func (c *Client) httpClient() *http.Client { - if c.HTTP != nil { - return c.HTTP - } - return http.DefaultClient -} - -func (c *Client) githubBase() string { - if c.GitHubBase != "" { - return strings.TrimRight(c.GitHubBase, "/") - } - return "https://github.com" -} - -func (c *Client) sleepFor(ctx context.Context, d time.Duration) { - if c.sleep != nil { - c.sleep(ctx, d) - return - } - select { - case <-ctx.Done(): - case <-time.After(d): - } -} - -// HubAuthConfig is the client-visible slice of GET /api/auth-config. -type HubAuthConfig struct { - AuthEnabled bool `json:"authEnabled"` - RequireAuth bool `json:"requireAuth"` - GitHubClientID string `json:"githubClientId"` -} - -// FetchAuthConfig asks the hub whether GitHub auth is required and, if so, which -// OAuth App client id to use for the device flow. -func (c *Client) FetchAuthConfig(ctx context.Context) (HubAuthConfig, error) { - var cfg HubAuthConfig - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.hub("/api/auth-config"), nil) - if err != nil { - return cfg, err - } - resp, err := c.httpClient().Do(req) - if err != nil { - return cfg, fmt.Errorf("contacting hub: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return cfg, fmt.Errorf("hub auth-config returned HTTP %d", resp.StatusCode) - } - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&cfg); err != nil { - return cfg, fmt.Errorf("decoding hub auth-config: %w", err) - } - return cfg, nil -} - -func (c *Client) hub(path string) string { - return strings.TrimRight(c.HubURL, "/") + path -} - -// DeviceCode is GitHub's device-flow authorization prompt. -type DeviceCode struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURI string `json:"verification_uri"` - ExpiresIn int `json:"expires_in"` - Interval int `json:"interval"` -} - -// RequestDeviceCode begins the GitHub device flow for the given OAuth client id. -func (c *Client) RequestDeviceCode(ctx context.Context, clientID string) (DeviceCode, error) { - var dc DeviceCode - form := url.Values{"client_id": {clientID}, "scope": {"read:user"}} - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.githubBase()+"/login/device/code", strings.NewReader(form.Encode())) - if err != nil { - return dc, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - resp, err := c.httpClient().Do(req) - if err != nil { - return dc, fmt.Errorf("requesting device code: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return dc, fmt.Errorf("device-code request returned HTTP %d", resp.StatusCode) - } - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&dc); err != nil { - return dc, fmt.Errorf("decoding device code: %w", err) - } - if dc.Interval <= 0 { - dc.Interval = 5 - } - return dc, nil -} - -// tokenPollResponse is GitHub's device-token endpoint reply (success or a -// pending/slow-down/error signal in `error`). -type tokenPollResponse struct { - AccessToken string `json:"access_token"` - Error string `json:"error"` -} - -// PollForToken polls GitHub until the user authorizes, honouring the -// authorization_pending / slow_down backoff. Returns the GitHub access token. -func (c *Client) PollForToken(ctx context.Context, clientID string, dc DeviceCode) (string, error) { - interval := time.Duration(dc.Interval) * time.Second - deadline := time.Now().Add(time.Duration(max(dc.ExpiresIn, 300)) * time.Second) - if c.now != nil { - deadline = c.now().Add(time.Duration(max(dc.ExpiresIn, 300)) * time.Second) - } - for { - c.sleepFor(ctx, interval) - if ctx.Err() != nil { - return "", ctx.Err() - } - tok, e, err := c.pollOnce(ctx, clientID, dc.DeviceCode) - if err != nil { - return "", err - } - switch e { - case "": - if tok != "" { - return tok, nil - } - case "authorization_pending": - // keep polling at the current interval - case "slow_down": - interval += 5 * time.Second - case "expired_token": - return "", fmt.Errorf("the device code expired before authorization; run 'mxcli auth hub login' again") - case "access_denied": - return "", fmt.Errorf("authorization was denied") - default: - return "", fmt.Errorf("device authorization failed: %s", e) - } - nowT := time.Now() - if c.now != nil { - nowT = c.now() - } - if nowT.After(deadline) { - return "", fmt.Errorf("timed out waiting for device authorization") - } - } -} - -func (c *Client) pollOnce(ctx context.Context, clientID, deviceCode string) (token, errCode string, err error) { - form := url.Values{ - "client_id": {clientID}, - "device_code": {deviceCode}, - "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.githubBase()+"/login/oauth/access_token", strings.NewReader(form.Encode())) - if err != nil { - return "", "", err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "application/json") - resp, err := c.httpClient().Do(req) - if err != nil { - return "", "", fmt.Errorf("polling for token: %w", err) - } - defer resp.Body.Close() - var body tokenPollResponse - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body); err != nil { - return "", "", fmt.Errorf("decoding token poll: %w", err) - } - return body.AccessToken, body.Error, nil -} - -// MintHubKey exchanges a GitHub token for a hub API key via POST /api/keys. The -// token is sent once, here, and never stored. -func (c *Client) MintHubKey(ctx context.Context, githubToken string) (key, login string, err error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.hub("/api/keys"), nil) - if err != nil { - return "", "", err - } - req.Header.Set("Authorization", "Bearer "+githubToken) - resp, err := c.httpClient().Do(req) - if err != nil { - return "", "", fmt.Errorf("minting hub key: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return "", "", fmt.Errorf("hub key mint failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) - } - var body struct { - Key string `json:"key"` - Login string `json:"login"` - } - if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body); err != nil { - return "", "", fmt.Errorf("decoding hub key: %w", err) - } - return body.Key, body.Login, nil -} - -// RevokeHubKey best-effort revokes a key on the hub (DELETE /api/keys). -func (c *Client) RevokeHubKey(ctx context.Context, key string) error { - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.hub("/api/keys"), nil) - if err != nil { - return err - } - req.Header.Set("X-Hub-Key", key) - resp, err := c.httpClient().Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - return nil -} - -// LoginWithToken mints and stores a hub key directly from a GitHub token (PAT or -// OAuth token), skipping the device flow. This is the escape hatch for -// environments whose egress proxy blocks GitHub's device endpoints (e.g. Claude -// Code web containers, which only permit repo-scoped GitHub paths). (findings #31A) -func (c *Client) LoginWithToken(ctx context.Context, githubToken string) (login string, err error) { - key, login, err := c.MintHubKey(ctx, strings.TrimSpace(githubToken)) - if err != nil { - return "", err - } - if err := SaveKey(c.HubURL, key); err != nil { - return "", fmt.Errorf("saving hub key: %w", err) - } - return login, nil -} - -// Login runs the full bootstrap: discover the client id, device flow, mint a hub -// key, and store it. Prompts are written to out. Returns the resolved GitHub -// login. The GitHub token never leaves this process except for the mint call. -func (c *Client) Login(ctx context.Context, out io.Writer) (login string, err error) { - cfg, err := c.FetchAuthConfig(ctx) - if err != nil { - return "", err - } - if !cfg.AuthEnabled { - return "", fmt.Errorf("hub %s does not require authentication (open mode); no key needed", HostOf(c.HubURL)) - } - if cfg.GitHubClientID == "" { - return "", fmt.Errorf("hub reports auth enabled but advertises no GitHub client id") - } - dc, err := c.RequestDeviceCode(ctx, cfg.GitHubClientID) - if err != nil { - return "", err - } - fmt.Fprintf(out, "\nTo authorize this device, open:\n %s\nand enter the code:\n %s\n\nWaiting for authorization...\n", - dc.VerificationURI, dc.UserCode) - - token, err := c.PollForToken(ctx, cfg.GitHubClientID, dc) - if err != nil { - return "", err - } - key, login, err := c.MintHubKey(ctx, token) - if err != nil { - return "", err - } - if err := SaveKey(c.HubURL, key); err != nil { - return "", fmt.Errorf("saving hub key: %w", err) - } - return login, nil -} diff --git a/cmd/mxcli/hubauth/deviceflow_test.go b/cmd/mxcli/hubauth/deviceflow_test.go deleted file mode 100644 index 6be852363..000000000 --- a/cmd/mxcli/hubauth/deviceflow_test.go +++ /dev/null @@ -1,171 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package hubauth - -import ( - "bytes" - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" -) - -// fakeGitHub serves the device-flow endpoints. It returns authorization_pending -// for the first (pendingRounds) polls, then the token. -func fakeGitHub(t *testing.T, pendingRounds int) *httptest.Server { - t.Helper() - var polls int - mux := http.NewServeMux() - mux.HandleFunc("/login/device/code", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"device_code":"DC","user_code":"WXYZ-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":1}`)) - }) - mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if polls < pendingRounds { - polls++ - _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) - return - } - _, _ = w.Write([]byte(`{"access_token":"gho_from_device"}`)) - }) - return httptest.NewServer(mux) -} - -// fakeHub serves /api/auth-config and /api/keys. -func fakeHub(t *testing.T, clientID, login string) *httptest.Server { - t.Helper() - mux := http.NewServeMux() - mux.HandleFunc("/api/auth-config", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"authEnabled":true,"requireAuth":true,"githubClientId":"` + clientID + `"}`)) - }) - mux.HandleFunc("/api/keys", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.WriteHeader(http.StatusNoContent) - return - } - if got := r.Header.Get("Authorization"); got != "Bearer gho_from_device" { - t.Errorf("mint auth header = %q, want Bearer gho_from_device", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"key":"hub_minted_key","login":"` + login + `"}`)) - }) - return httptest.NewServer(mux) -} - -// testClient wires a Client to the stub servers with an instant sleep. -func testClient(hub, gh *httptest.Server) *Client { - return &Client{ - HubURL: hub.URL, - GitHubBase: gh.URL, - HTTP: hub.Client(), - sleep: func(context.Context, time.Duration) {}, // no real waiting - } -} - -func TestFetchAuthConfig(t *testing.T) { - hub := fakeHub(t, "cid123", "alice") - defer hub.Close() - c := &Client{HubURL: hub.URL, HTTP: hub.Client()} - - cfg, err := c.FetchAuthConfig(context.Background()) - if err != nil { - t.Fatal(err) - } - if !cfg.AuthEnabled || cfg.GitHubClientID != "cid123" { - t.Errorf("cfg = %+v, want enabled/cid123", cfg) - } -} - -func TestPollForToken_PendingThenSuccess(t *testing.T) { - gh := fakeGitHub(t, 2) // two pending rounds, then success - defer gh.Close() - c := &Client{GitHubBase: gh.URL, HTTP: gh.Client(), sleep: func(context.Context, time.Duration) {}} - - tok, err := c.PollForToken(context.Background(), "cid", DeviceCode{DeviceCode: "DC", Interval: 1, ExpiresIn: 900}) - if err != nil { - t.Fatalf("PollForToken: %v", err) - } - if tok != "gho_from_device" { - t.Errorf("token = %q, want gho_from_device", tok) - } -} - -func TestMintHubKey(t *testing.T) { - hub := fakeHub(t, "cid", "alice") - defer hub.Close() - c := &Client{HubURL: hub.URL, HTTP: hub.Client()} - - key, login, err := c.MintHubKey(context.Background(), "gho_from_device") - if err != nil { - t.Fatal(err) - } - if key != "hub_minted_key" || login != "alice" { - t.Errorf("mint = %q/%q, want hub_minted_key/alice", key, login) - } -} - -func TestLogin_FullFlowStoresKey(t *testing.T) { - withTempStore(t) - t.Setenv(EnvHubKey, "") - - gh := fakeGitHub(t, 1) - defer gh.Close() - hub := fakeHub(t, "cid", "alice") - defer hub.Close() - c := testClient(hub, gh) - - var out bytes.Buffer - login, err := c.Login(context.Background(), &out) - if err != nil { - t.Fatalf("Login: %v", err) - } - if login != "alice" { - t.Errorf("login = %q, want alice", login) - } - // The instructions were printed. - if !strings.Contains(out.String(), "WXYZ-1234") { - t.Errorf("device instructions missing the user code, got: %s", out.String()) - } - // And the minted key was stored for the hub host. - if k := ResolveKey(hub.URL); k != "hub_minted_key" { - t.Errorf("stored key = %q, want hub_minted_key", k) - } -} - -func TestLogin_OpenModeHubIsAnError(t *testing.T) { - mux := http.NewServeMux() - mux.HandleFunc("/api/auth-config", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"authEnabled":false}`)) - }) - hub := httptest.NewServer(mux) - defer hub.Close() - c := &Client{HubURL: hub.URL, HTTP: hub.Client()} - - if _, err := c.Login(context.Background(), &bytes.Buffer{}); err == nil { - t.Error("Login against an open-mode hub should error (no key needed)") - } -} - -func TestLoginWithToken_MintsAndStores(t *testing.T) { - withTempStore(t) - t.Setenv(EnvHubKey, "") - - hub := fakeHub(t, "cid", "alice") - defer hub.Close() - c := &Client{HubURL: hub.URL, HTTP: hub.Client()} - - login, err := c.LoginWithToken(context.Background(), "gho_from_device") - if err != nil { - t.Fatalf("LoginWithToken: %v", err) - } - if login != "alice" { - t.Errorf("login = %q, want alice", login) - } - if k := ResolveKey(hub.URL); k != "hub_minted_key" { - t.Errorf("stored key = %q, want hub_minted_key", k) - } -} diff --git a/cmd/mxcli/hubauth/hubkey.go b/cmd/mxcli/hubauth/hubkey.go new file mode 100644 index 000000000..7b2d38812 --- /dev/null +++ b/cmd/mxcli/hubauth/hubkey.go @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hubauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// Client talks to a hub's key API. The primary way to obtain a key is the hub's +// browser page (`https:///cli`) — sign in with GitHub, mint, copy into +// MXCLI_HUB_KEY. This client covers the headless path: mint directly from a +// GitHub token (`auth hub login --token`, CI) and revoke. +// +// There is deliberately no GitHub device flow: the target environment (Claude +// Code containers) blocks GitHub's device endpoints at the egress proxy, so the +// browser page is the real bootstrap. +type Client struct { + HubURL string // e.g. https://hub.mxcli.org + HTTP *http.Client // default http.DefaultClient +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return http.DefaultClient +} + +func (c *Client) hub(path string) string { + return strings.TrimRight(c.HubURL, "/") + path +} + +// MintHubKey exchanges a GitHub token for a hub API key via POST /api/keys. The +// token is sent once, here, and never stored. +func (c *Client) MintHubKey(ctx context.Context, githubToken string) (key, login string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.hub("/api/keys"), nil) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+githubToken) + resp, err := c.httpClient().Do(req) + if err != nil { + return "", "", fmt.Errorf("minting hub key: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", "", fmt.Errorf("hub key mint failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + var body struct { + Key string `json:"key"` + Login string `json:"login"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body); err != nil { + return "", "", fmt.Errorf("decoding hub key: %w", err) + } + return body.Key, body.Login, nil +} + +// RevokeHubKey best-effort revokes a key on the hub (DELETE /api/keys). +func (c *Client) RevokeHubKey(ctx context.Context, key string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.hub("/api/keys"), nil) + if err != nil { + return err + } + req.Header.Set("X-Hub-Key", key) + resp, err := c.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} + +// LoginWithToken mints and stores a hub key from a GitHub token (PAT or OAuth +// token), for headless environments (CI, or a container that has a token but +// can't run a browser). Interactive users mint from the hub's /cli page instead. +func (c *Client) LoginWithToken(ctx context.Context, githubToken string) (login string, err error) { + key, login, err := c.MintHubKey(ctx, strings.TrimSpace(githubToken)) + if err != nil { + return "", err + } + if err := SaveKey(c.HubURL, key); err != nil { + return "", fmt.Errorf("saving hub key: %w", err) + } + return login, nil +} diff --git a/cmd/mxcli/hubauth/hubkey_test.go b/cmd/mxcli/hubauth/hubkey_test.go new file mode 100644 index 000000000..eb569aa61 --- /dev/null +++ b/cmd/mxcli/hubauth/hubkey_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hubauth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// fakeHub serves the hub key endpoint used by the headless mint path. +func fakeHub(t *testing.T, login string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/api/keys", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNoContent) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer gho_token" { + t.Errorf("mint auth header = %q, want Bearer gho_token", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"key":"hub_minted_key","login":"` + login + `"}`)) + }) + return httptest.NewServer(mux) +} + +func TestMintHubKey(t *testing.T) { + hub := fakeHub(t, "alice") + defer hub.Close() + c := &Client{HubURL: hub.URL, HTTP: hub.Client()} + + key, login, err := c.MintHubKey(context.Background(), "gho_token") + if err != nil { + t.Fatal(err) + } + if key != "hub_minted_key" || login != "alice" { + t.Errorf("mint = %q/%q, want hub_minted_key/alice", key, login) + } +} + +func TestMintHubKey_ErrorStatus(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/keys", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + }) + hub := httptest.NewServer(mux) + defer hub.Close() + c := &Client{HubURL: hub.URL, HTTP: hub.Client()} + + if _, _, err := c.MintHubKey(context.Background(), "bad"); err == nil { + t.Error("a non-200 mint response should be an error") + } +} + +func TestLoginWithToken_MintsAndStores(t *testing.T) { + withTempStore(t) + t.Setenv(EnvHubKey, "") + + hub := fakeHub(t, "alice") + defer hub.Close() + c := &Client{HubURL: hub.URL, HTTP: hub.Client()} + + login, err := c.LoginWithToken(context.Background(), "gho_token") + if err != nil { + t.Fatalf("LoginWithToken: %v", err) + } + if login != "alice" { + t.Errorf("login = %q, want alice", login) + } + if k := ResolveKey(hub.URL); k != "hub_minted_key" { + t.Errorf("stored key = %q, want hub_minted_key", k) + } +} diff --git a/cmd/mxcli/tunnelhub/admin.go b/cmd/mxcli/tunnelhub/admin.go index 80f3a84b4..fba88d30d 100644 --- a/cmd/mxcli/tunnelhub/admin.go +++ b/cmd/mxcli/tunnelhub/admin.go @@ -4,17 +4,22 @@ package tunnelhub import "net/http" -// NewAdmin returns the handler for the hub's admin overview page. The page is -// self-contained (inline CSS/JS, no external assets) and refreshes itself from -// GET /api/backends. +// NewAdmin returns the handler for the hub's admin pages: the preview overview at +// "/" and the "get your CLI key" page at "/cli". Both are self-contained (inline +// CSS/JS, no external assets). When auth is on, the server gates these behind a +// session before they are reached. func NewAdmin(_ *Registry) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { + switch r.URL.Path { + case "/": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(adminHTML)) + case "/cli": + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(cliKeyHTML)) + default: http.NotFound(w, r) - return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(adminHTML)) }) } @@ -54,6 +59,7 @@ const adminHTML = `

mxcli tunnel-hub

+ @@ -127,12 +133,13 @@ const adminHTML = ` } function whoami(){ fetch("/api/whoami").then(function(r){return r.json();}).then(function(j){ - var who = document.getElementById("who"), btn = document.getElementById("signout"); + var who = document.getElementById("who"), btn = document.getElementById("signout"), + cli = document.getElementById("clikey"); if(j && j.authEnabled && j.login){ who.innerHTML = "signed in as "+esc(j.login)+""; - btn.hidden = false; + btn.hidden = false; cli.hidden = false; } else { - who.textContent = ""; btn.hidden = true; + who.textContent = ""; btn.hidden = true; cli.hidden = true; } }).catch(function(){}); } @@ -146,3 +153,78 @@ const adminHTML = ` })(); ` + +// cliKeyHTML is the "get your hub key" page (served at /cli). A signed-in user +// mints a durable hub key via the session cookie (no PAT, no device flow) and +// copies it into MXCLI_HUB_KEY. This is the primary key-acquisition path for a +// Claude Code web/mobile user, whose container cannot reach GitHub's device flow. +const cliKeyHTML = ` + + +mxcli tunnel-hub — CLI key + + +

mxcli tunnel-hub

← previews
+
+
+

Create a durable hub key to let mxcli run --hub register previews as you. + The key does not expire and survives hub restarts — you set it once.

+ +
+
+ +` diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 237d272f5..9f6ffa9da 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -5,6 +5,7 @@ package tunnelhub import ( "encoding/json" "net/http" + "net/url" "strings" "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" @@ -233,18 +234,33 @@ func (a *API) handleKeys(w http.ResponseWriter, r *http.Request) { } } -// handleKeyMint validates the caller's GitHub token (Bearer) against GitHub, then -// issues an opaque hub key bound to that login. The GitHub token is used once and -// discarded — never stored. +// handleKeyMint issues an opaque hub key bound to a GitHub login. Two ways to +// authenticate the mint: +// +// 1. A signed-in **browser session** (the /cli page) — the natural path for a +// Claude Code web / mobile user, whose container can't reach GitHub's device +// endpoints. Cookie auth is CSRF-sensitive, so it additionally requires a +// same-origin custom header (a cross-site form can't set one) and, when the +// browser sends an Origin, a matching one. +// 2. An `Authorization: Bearer ` (CLI `auth hub login --token`, +// CI). The token is verified against GitHub once and discarded — never stored. func (a *API) handleKeyMint(w http.ResponseWriter, r *http.Request) { - token := bearerToken(r) - if token == "" { - http.Error(w, "missing GitHub token (Authorization: Bearer )", http.StatusUnauthorized) - return - } - login, err := a.opts.Auth.fetchLogin(token) - if err != nil || login == "" { - http.Error(w, "could not verify GitHub identity", http.StatusUnauthorized) + var login string + if token := bearerToken(r); token != "" { + l, err := a.opts.Auth.fetchLogin(token) + if err != nil || l == "" { + http.Error(w, "could not verify GitHub identity", http.StatusUnauthorized) + return + } + login = l + } else if l := a.opts.Auth.sessionLogin(r); l != "" { + if r.Header.Get(mintHeader) == "" || !sameOrigin(r, a.opts.Auth.HubHost) { + http.Error(w, "cookie-authenticated mint requires the "+mintHeader+" header and a same-origin request", http.StatusForbidden) + return + } + login = l + } else { + http.Error(w, "sign in at the hub, or send Authorization: Bearer ", http.StatusUnauthorized) return } key := a.opts.Keys.Mint(login) @@ -254,6 +270,24 @@ func (a *API) handleKeyMint(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, KeyResponse{Key: key, Login: login}) } +// mintHeader is the custom header the /cli page sends on a cookie-authenticated +// mint; its presence (unsettable by a cross-site form) is the CSRF guard. +const mintHeader = "X-Hub-Mint" + +// sameOrigin reports whether the request's Origin (when present) is the hub host. +// Absent Origin falls through to the custom-header requirement. +func sameOrigin(r *http.Request, hubHost string) bool { + o := r.Header.Get("Origin") + if o == "" { + return true + } + u, err := url.Parse(o) + if err != nil { + return false + } + return stripPort(u.Host) == hubHost +} + // handleKeyRevoke removes the presented key (X-Hub-Key). Idempotent: revoking an // unknown key still returns 204. func (a *API) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/mxcli/tunnelhub/api_keys_test.go b/cmd/mxcli/tunnelhub/api_keys_test.go index 6f4d1e39e..82ff3610f 100644 --- a/cmd/mxcli/tunnelhub/api_keys_test.go +++ b/cmd/mxcli/tunnelhub/api_keys_test.go @@ -311,3 +311,74 @@ func TestAPI_SoftModeWithSecretRequiresIt(t *testing.T) { t.Errorf("soft-mode + valid secret: status = %d, want 200", rec.Code) } } + +// TestAPI_BrowserMint covers the session-cookie mint path (the /cli page): a +// signed-in browser mints a key with the X-Hub-Mint header; without the header +// (CSRF) it is refused; a cross-origin Origin is refused. +func TestAPI_BrowserMint(t *testing.T) { + api, buf, done := newAuthedAPI(t, true) + defer done() + + // Build a request carrying a valid session cookie for alice. + setW := httptest.NewRecorder() + api.opts.Auth.setSessionCookie(setW, "alice") + cookies := setW.Result().Cookies() + withCookie := func(req *http.Request) { + for _, c := range cookies { + req.AddCookie(c) + } + } + mux := http.NewServeMux() + api.Mount(mux) + + // (1) Cookie + X-Hub-Mint header + same-origin → 200, minted for alice. + req := httptest.NewRequest(http.MethodPost, "https://hub.mxcli.org/api/keys", nil) + req.Header.Set("X-Hub-Mint", "1") + req.Header.Set("Origin", "https://hub.mxcli.org") + withCookie(req) + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, req) + if rw.Code != http.StatusOK { + t.Fatalf("browser mint status = %d, want 200 (body %s)", rw.Code, rw.Body) + } + var kr KeyResponse + _ = json.Unmarshal(rw.Body.Bytes(), &kr) + if kr.Login != "alice" || kr.Key == "" { + t.Errorf("mint response = %+v", kr) + } + if login, ok := api.opts.Keys.Resolve(kr.Key); !ok || login != "alice" { + t.Errorf("minted key resolves to %q,%v; want alice", login, ok) + } + if !strings.Contains(buf.String(), `"event":"key_mint"`) { + t.Errorf("expected key_mint audit line") + } + + // (2) Cookie but NO X-Hub-Mint header (CSRF) → 403. + noHdr := httptest.NewRequest(http.MethodPost, "https://hub.mxcli.org/api/keys", nil) + withCookie(noHdr) + rw2 := httptest.NewRecorder() + mux.ServeHTTP(rw2, noHdr) + if rw2.Code != http.StatusForbidden { + t.Errorf("no-header cookie mint status = %d, want 403", rw2.Code) + } + + // (3) Cookie + header but cross-origin → 403. + xorig := httptest.NewRequest(http.MethodPost, "https://hub.mxcli.org/api/keys", nil) + xorig.Header.Set("X-Hub-Mint", "1") + xorig.Header.Set("Origin", "https://evil.example.com") + withCookie(xorig) + rw3 := httptest.NewRecorder() + mux.ServeHTTP(rw3, xorig) + if rw3.Code != http.StatusForbidden { + t.Errorf("cross-origin cookie mint status = %d, want 403", rw3.Code) + } + + // (4) No cookie, no bearer → 401. + anon := httptest.NewRequest(http.MethodPost, "https://hub.mxcli.org/api/keys", nil) + anon.Header.Set("X-Hub-Mint", "1") + rw4 := httptest.NewRecorder() + mux.ServeHTTP(rw4, anon) + if rw4.Code != http.StatusUnauthorized { + t.Errorf("anon mint status = %d, want 401", rw4.Code) + } +} From 75420f5fe0af305126a8f822a9a5eff5351400ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 13:31:15 +0000 Subject: [PATCH 15/17] hub-auth: rotate keys by default + browser key management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/tunnelhub/admin.go | 38 ++++++++-- cmd/mxcli/tunnelhub/api.go | 103 ++++++++++++++++++++++----- cmd/mxcli/tunnelhub/api_keys_test.go | 53 ++++++++++++++ cmd/mxcli/tunnelhub/keys.go | 32 +++++++++ cmd/mxcli/tunnelhub/keys_test.go | 24 +++++++ 5 files changed, 226 insertions(+), 24 deletions(-) diff --git a/cmd/mxcli/tunnelhub/admin.go b/cmd/mxcli/tunnelhub/admin.go index fba88d30d..5e6504aa3 100644 --- a/cmd/mxcli/tunnelhub/admin.go +++ b/cmd/mxcli/tunnelhub/admin.go @@ -182,6 +182,10 @@ const cliKeyHTML = ` .warn { color:var(--mut); font-size:.85rem; } .err { color:#ef4444; } code.inline { font-family:ui-monospace,monospace; background:var(--row); padding:.05rem .35rem; border-radius:.3rem; } + .count { color:var(--mut); font-size:.88rem; margin-bottom:.7rem; } + .danger { background:none; color:#ef4444; border:1px solid var(--line); margin-left:.6rem; } + .danger:hover { border-color:#ef4444; } + .keep { display:block; color:var(--mut); font-size:.85rem; margin-top:.8rem; }

mxcli tunnel-hub

← previews
@@ -189,20 +193,35 @@ const cliKeyHTML = `

Create a durable hub key to let mxcli run --hub register previews as you. The key does not expire and survives hub restarts — you set it once.

+
+ +
` diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 9f6ffa9da..9abe07e65 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -4,6 +4,7 @@ package tunnelhub import ( "encoding/json" + "fmt" "net/http" "net/url" "strings" @@ -48,6 +49,18 @@ type KeyResponse struct { Login string `json:"login"` } +// KeyListResponse is returned by GET /api/keys: how many active keys the caller +// has (for the /cli page's "you have N keys"). +type KeyListResponse struct { + Login string `json:"login"` + Count int `json:"count"` +} + +// KeyRevokeResponse is returned by a session-authed DELETE /api/keys (revoke-all). +type KeyRevokeResponse struct { + Revoked int `json:"revoked"` +} + // RegisterResponse is returned to `mxcli run --hub` after registration. type RegisterResponse struct { Subdomain string `json:"subdomain"` @@ -225,6 +238,8 @@ func (a *API) handleKeys(w http.ResponseWriter, r *http.Request) { return } switch r.Method { + case http.MethodGet: + a.handleKeyList(w, r) case http.MethodPost: a.handleKeyMint(w, r) case http.MethodDelete: @@ -234,6 +249,32 @@ func (a *API) handleKeys(w http.ResponseWriter, r *http.Request) { } } +// keyCaller resolves the login for a key-management request. Two auth methods: +// an Authorization: Bearer (verified against GitHub, no CSRF risk +// as it isn't ambient), or a hub session cookie. State-changing cookie calls +// (mint, revoke-all) pass writeCSRF=true so they additionally require the +// X-Hub-Mint header + a same-origin request; reads (list) pass false. On failure +// it writes the response and returns ok=false. +func (a *API) keyCaller(w http.ResponseWriter, r *http.Request, writeCSRF bool) (login string, ok bool) { + if token := bearerToken(r); token != "" { + l, err := a.opts.Auth.fetchLogin(token) + if err != nil || l == "" { + http.Error(w, "could not verify GitHub identity", http.StatusUnauthorized) + return "", false + } + return l, true + } + if l := a.opts.Auth.sessionLogin(r); l != "" { + if writeCSRF && (r.Header.Get(mintHeader) == "" || !sameOrigin(r, a.opts.Auth.HubHost)) { + http.Error(w, "this request requires the "+mintHeader+" header and a same-origin request", http.StatusForbidden) + return "", false + } + return l, true + } + http.Error(w, "sign in at the hub, or send Authorization: Bearer ", http.StatusUnauthorized) + return "", false +} + // handleKeyMint issues an opaque hub key bound to a GitHub login. Two ways to // authenticate the mint: // @@ -245,24 +286,21 @@ func (a *API) handleKeys(w http.ResponseWriter, r *http.Request) { // 2. An `Authorization: Bearer ` (CLI `auth hub login --token`, // CI). The token is verified against GitHub once and discarded — never stored. func (a *API) handleKeyMint(w http.ResponseWriter, r *http.Request) { - var login string - if token := bearerToken(r); token != "" { - l, err := a.opts.Auth.fetchLogin(token) - if err != nil || l == "" { - http.Error(w, "could not verify GitHub identity", http.StatusUnauthorized) - return - } - login = l - } else if l := a.opts.Auth.sessionLogin(r); l != "" { - if r.Header.Get(mintHeader) == "" || !sameOrigin(r, a.opts.Auth.HubHost) { - http.Error(w, "cookie-authenticated mint requires the "+mintHeader+" header and a same-origin request", http.StatusForbidden) - return - } - login = l - } else { - http.Error(w, "sign in at the hub, or send Authorization: Bearer ", http.StatusUnauthorized) + login, ok := a.keyCaller(w, r, true) + if !ok { return } + // By default a new key replaces the caller's existing keys (rotate) so the + // store doesn't silently accumulate live credentials; ?replace=false mints an + // additional key and keeps the others. + if r.URL.Query().Get("replace") != "false" { + if n := a.opts.Keys.RevokeLogin(login); n > 0 { + a.opts.Audit.Log(audit.Event{ + Event: audit.EventKeyRevoke, Login: login, IP: clientIP(r), Outcome: "ok", + Detail: fmt.Sprintf("rotate: revoked %d", n), + }) + } + } key := a.opts.Keys.Mint(login) a.opts.Audit.Log(audit.Event{ Event: audit.EventKeyMint, Login: login, IP: clientIP(r), Outcome: "ok", @@ -270,6 +308,16 @@ func (a *API) handleKeyMint(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, KeyResponse{Key: key, Login: login}) } +// handleKeyList (GET /api/keys) reports how many active keys the caller has, so +// the /cli page can show "you have N keys". +func (a *API) handleKeyList(w http.ResponseWriter, r *http.Request) { + login, ok := a.keyCaller(w, r, false) + if !ok { + return + } + writeJSON(w, http.StatusOK, KeyListResponse{Login: login, Count: a.opts.Keys.CountLogin(login)}) +} + // mintHeader is the custom header the /cli page sends on a cookie-authenticated // mint; its presence (unsettable by a cross-site form) is the CSRF guard. const mintHeader = "X-Hub-Mint" @@ -290,14 +338,31 @@ func sameOrigin(r *http.Request, hubHost string) bool { // handleKeyRevoke removes the presented key (X-Hub-Key). Idempotent: revoking an // unknown key still returns 204. +// handleKeyRevoke (DELETE /api/keys) revokes either a single key presented as +// X-Hub-Key (the CLI logout path) or, for a signed-in browser with no X-Hub-Key, +// all of the caller's keys (leak recovery / cleanup). func (a *API) handleKeyRevoke(w http.ResponseWriter, r *http.Request) { - key := strings.TrimSpace(r.Header.Get("X-Hub-Key")) - if login, ok := a.opts.Keys.Revoke(key); ok { + if key := strings.TrimSpace(r.Header.Get("X-Hub-Key")); key != "" { + if login, ok := a.opts.Keys.Revoke(key); ok { + a.opts.Audit.Log(audit.Event{ + Event: audit.EventKeyRevoke, Login: login, IP: clientIP(r), Outcome: "ok", + }) + } + w.WriteHeader(http.StatusNoContent) + return + } + login, ok := a.keyCaller(w, r, true) + if !ok { + return + } + n := a.opts.Keys.RevokeLogin(login) + if n > 0 { a.opts.Audit.Log(audit.Event{ Event: audit.EventKeyRevoke, Login: login, IP: clientIP(r), Outcome: "ok", + Detail: fmt.Sprintf("revoke-all: %d", n), }) } - w.WriteHeader(http.StatusNoContent) + writeJSON(w, http.StatusOK, KeyRevokeResponse{Revoked: n}) } func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/mxcli/tunnelhub/api_keys_test.go b/cmd/mxcli/tunnelhub/api_keys_test.go index 82ff3610f..dff38c044 100644 --- a/cmd/mxcli/tunnelhub/api_keys_test.go +++ b/cmd/mxcli/tunnelhub/api_keys_test.go @@ -382,3 +382,56 @@ func TestAPI_BrowserMint(t *testing.T) { t.Errorf("anon mint status = %d, want 401", rw4.Code) } } + +// TestAPI_KeyRotateCountRevokeAll covers the /cli key-management endpoints: +// mint rotates by default (old keys revoked), GET reports the count, DELETE by +// session revokes all, and ?replace=false accumulates. +func TestAPI_KeyRotateCountRevokeAll(t *testing.T) { + api, _, done := newAuthedAPI(t, true) + defer done() + + // bearer helper (CI path); simplest way to drive mint without cookies. + bearer := map[string]string{"Authorization": "Bearer gho_test"} + + // First mint → 1 key. + k1 := mintKey(t, api, "gho_test") + if n := api.opts.Keys.CountLogin("alice"); n != 1 { + t.Fatalf("count after first mint = %d, want 1", n) + } + // Second mint rotates (default replace) → still 1, and k1 is dead. + k2 := mintKey(t, api, "gho_test") + if n := api.opts.Keys.CountLogin("alice"); n != 1 { + t.Errorf("count after rotate = %d, want 1", n) + } + if _, ok := api.opts.Keys.Resolve(k1); ok { + t.Error("rotate should have revoked the previous key") + } + if _, ok := api.opts.Keys.Resolve(k2); !ok { + t.Error("newest key should resolve") + } + + // GET /api/keys reports the count. + gl := doJSON(t, api, http.MethodGet, "/api/keys", "", bearer) + var kl KeyListResponse + _ = json.Unmarshal(gl.Body.Bytes(), &kl) + if kl.Login != "alice" || kl.Count != 1 { + t.Errorf("GET keys = %+v, want alice/1", kl) + } + + // ?replace=false accumulates → 2. + doJSON(t, api, http.MethodPost, "/api/keys?replace=false", "", bearer) + if n := api.opts.Keys.CountLogin("alice"); n != 2 { + t.Errorf("count after replace=false = %d, want 2", n) + } + + // DELETE (bearer, no X-Hub-Key) revokes all. + dr := doJSON(t, api, http.MethodDelete, "/api/keys", "", bearer) + var krr KeyRevokeResponse + _ = json.Unmarshal(dr.Body.Bytes(), &krr) + if krr.Revoked != 2 { + t.Errorf("revoke-all = %d, want 2", krr.Revoked) + } + if n := api.opts.Keys.CountLogin("alice"); n != 0 { + t.Errorf("count after revoke-all = %d, want 0", n) + } +} diff --git a/cmd/mxcli/tunnelhub/keys.go b/cmd/mxcli/tunnelhub/keys.go index 6825d4dbe..86c22fca4 100644 --- a/cmd/mxcli/tunnelhub/keys.go +++ b/cmd/mxcli/tunnelhub/keys.go @@ -110,6 +110,38 @@ func (s *KeyStore) Resolve(key string) (login string, ok bool) { return rec.Login, ok } +// CountLogin returns how many active keys a login has. +func (s *KeyStore) CountLogin(login string) int { + s.mu.RLock() + defer s.mu.RUnlock() + n := 0 + for _, rec := range s.byHash { + if rec.Login == login { + n++ + } + } + return n +} + +// RevokeLogin removes all of a login's keys and returns how many were removed. +// Used for "revoke all my keys" (leak recovery / rotate). Persisted when backed +// by a file. +func (s *KeyStore) RevokeLogin(login string) int { + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for h, rec := range s.byHash { + if rec.Login == login { + delete(s.byHash, h) + n++ + } + } + if n > 0 { + _ = s.saveLocked() + } + return n +} + // Revoke removes a key. It returns the login it was bound to (for auditing) and // whether it existed. Persisted when backed by a file. func (s *KeyStore) Revoke(key string) (login string, ok bool) { diff --git a/cmd/mxcli/tunnelhub/keys_test.go b/cmd/mxcli/tunnelhub/keys_test.go index dceac9601..3b25ef214 100644 --- a/cmd/mxcli/tunnelhub/keys_test.go +++ b/cmd/mxcli/tunnelhub/keys_test.go @@ -123,3 +123,27 @@ func TestKeyStore_RejectsTooOpenFile(t *testing.T) { t.Error("a world-readable key file should be refused") } } + +func TestKeyStore_CountAndRevokeLogin(t *testing.T) { + ks := NewKeyStore() + ks.Mint("alice") + ks.Mint("alice") + ks.Mint("bob") + + if n := ks.CountLogin("alice"); n != 2 { + t.Errorf("alice count = %d, want 2", n) + } + if n := ks.CountLogin("carol"); n != 0 { + t.Errorf("carol count = %d, want 0", n) + } + // Revoke all of alice's keys; bob's are untouched. + if n := ks.RevokeLogin("alice"); n != 2 { + t.Errorf("RevokeLogin(alice) = %d, want 2", n) + } + if n := ks.CountLogin("alice"); n != 0 { + t.Errorf("alice count after revoke = %d, want 0", n) + } + if n := ks.CountLogin("bob"); n != 1 { + t.Errorf("bob count = %d, want 1 (unaffected)", n) + } +} From 736041cbb9a053a0ac66b6c2805927db85c1fa63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:29:32 +0000 Subject: [PATCH 16/17] hub-auth: drop stale device-flow wording from help + doc comments 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- cmd/mxcli/cmd_auth_hub.go | 6 +++--- cmd/mxcli/hubauth/hubauth.go | 12 ++++++------ cmd/mxcli/tunnelhub/api.go | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cmd/mxcli/cmd_auth_hub.go b/cmd/mxcli/cmd_auth_hub.go index 7f32bd801..08fe12fe1 100644 --- a/cmd/mxcli/cmd_auth_hub.go +++ b/cmd/mxcli/cmd_auth_hub.go @@ -12,8 +12,8 @@ import ( ) // authHubCmd groups the tunnel-hub credential commands under `mxcli auth hub`. -// A hub API key authorizes `mxcli run --hub` to register a preview as you; it is -// minted from your GitHub identity via the device flow and stored per hub host. +// A hub API key authorizes `mxcli run --hub` to register a preview as you; get +// one from the hub's /cli browser page (or --token for headless use). var authHubCmd = &cobra.Command{ Use: "hub", Short: "Manage tunnel-hub API keys (mxcli run --hub)", @@ -37,7 +37,7 @@ Open self-hosted hubs need no key — 'run --hub' falls back to the shared var authHubLoginCmd = &cobra.Command{ Use: "login", - Short: "Mint and store a hub API key via GitHub device flow", + Short: "Get a hub API key (browser page, or --token for headless use)", RunE: runAuthHubLogin, } diff --git a/cmd/mxcli/hubauth/hubauth.go b/cmd/mxcli/hubauth/hubauth.go index 3cc346c05..ac30fbbf7 100644 --- a/cmd/mxcli/hubauth/hubauth.go +++ b/cmd/mxcli/hubauth/hubauth.go @@ -1,12 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 -// Package hubauth is the client side of tunnel-hub authentication: it obtains a -// GitHub identity via the OAuth device flow, exchanges it for a hub-minted API -// key, and stores/resolves that key per hub host in ~/.mxcli/auth.json. +// Package hubauth is the client side of tunnel-hub authentication: it stores and +// resolves the hub API key per hub host in ~/.mxcli/auth.json, and (headless) +// mints one from a GitHub token. // -// `mxcli auth hub login` runs Login (device flow → mint → store); `mxcli run -// --hub` calls ResolveKey to attach an X-Hub-Key header. The GitHub token stays -// on this machine except for the single mint call to the hub. +// The primary way to obtain a key is the hub's browser page (`https:///cli`); +// `mxcli auth hub login --token` covers the headless/CI path. `mxcli run --hub` +// calls ResolveKey (MXCLI_HUB_KEY env → store) to attach an X-Hub-Key header. package hubauth import ( diff --git a/cmd/mxcli/tunnelhub/api.go b/cmd/mxcli/tunnelhub/api.go index 9abe07e65..143135cac 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -116,8 +116,8 @@ func (a *API) handleWhoami(w http.ResponseWriter, r *http.Request) { } // AuthConfigResponse tells a client whether the hub requires GitHub auth and, if -// so, which OAuth App client id to use for the device flow. The client id is a -// public value (it appears in every browser redirect); no secret is exposed. +// so, the OAuth App client id (a public value that appears in every browser +// redirect; no secret is exposed) — used to probe whether a hub is authenticated. type AuthConfigResponse struct { AuthEnabled bool `json:"authEnabled"` RequireAuth bool `json:"requireAuth"` From 1a470b3dea7131917e5d0be0755ab6e77a45006c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 14:35:59 +0000 Subject: [PATCH 17/17] docs(hub-auth): update site/skill/proposal/CLAUDE.md to as-built model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/mendix/run-local.md | 2 +- CLAUDE.md | 1 + docs-site/src/tools/run-local.md | 36 +++++++++++++++++-- .../PROPOSAL_hub_authentication.md | 19 ++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local.md index 2cbf060f1..b685543cd 100644 --- a/.claude/skills/mendix/run-local.md +++ b/.claude/skills/mendix/run-local.md @@ -110,7 +110,7 @@ Launch `run --local` as the **sole** command in its invocation (don't chain a tr | `--local` | — | Required; run without Docker (implied by `--hub`) | | `--hub` | — | Expose the app in a browser at a tunnel-hub URL (see below) | | `--hub-secret` | — | Shared auth (`user:pass`) matching an **open** hub's `--secret` | -| *(hub API key)* | — | For an **authenticated** hub: `mxcli auth hub login`, or set `MXCLI_HUB_KEY` (see below) | +| *(hub API key)* | — | For an **authenticated** hub: get one from `https:///cli`, set `MXCLI_HUB_KEY` (see below) | | `--watch` | off | Rebuild + hot-apply on each change | | `--ensure-db` | off | Provision local Postgres + app database if missing | | `--setup` | off | Cache MxBuild+runtime + ensure DB, then exit (SessionStart bring-up) | diff --git a/CLAUDE.md b/CLAUDE.md index e470abcf6..8e38b1588 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -525,6 +525,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati - Docker build integration (`mxcli docker build`) with PAD patching (Phase 1) - Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`): Docker-free `mxbuild --serve` + standalone runtime, hot `reload_model` for behavioural changes and restart+DDL for structural ones (chosen from the serve build's `restartRequired`). Bundles the browser client (`web/dist/` via mxbuild's rollup runner, which the serve Deploy target skips) so Mendix 11.x apps render in a browser. `--watch` keeps an incremental rollup bundler hot (CHOKIDAR_USEPOLLING for container fs; ~3-4s page re-bundle, skipped for model-only edits) and watches only model source (`.mpr`+`mprcontents/`). `--ensure-db` provisions the local Postgres + app database if missing; `--setup` does the non-blocking prerequisites (cache mxbuild+runtime, ensure DB) and exits — `mxcli init` wires it into a Claude Code SessionStart hook so a fresh/reaped web session self-bootstraps, and `docs-site/src/tools/bootstrap-prompt.md` is the empty-repo seed prompt. `--screenshot` captures a Playwright PNG each change (pixel-perfect page loop), with `--screenshot-url` deep links (repeatable for multi-page sets, one PNG per page) and `--screenshot-user`/`--screenshot-password` form login (session saved as Playwright storage state, reused via `screenshot --load-storage`). See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` - External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`): the app stays local and reverse-tunnels out over a single 443 connection (embedded chisel) to a static relay, so it is reachable in a browser at a public URL — works from egress-only environments (Claude Code web), verified live through the session's MITM egress proxy. `run --hub` implies `--local`, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA/`originURI` work under the public origin), resolves the control proxy honouring `NO_PROXY`, and retries forever. `mxcli tunnel-hub --domain ` is the **multi-tenant** relay: a registry keyed by prefix/project/solution/branch/worktree (stable URLs on reconnect) fronts many previews at per-subdomain hosts (`[prefix-]project[-branch].`; main collapses to the project) over one 443 with per-subdomain autocert, a registration API (`/api/register|status|deregister|backends`), and a sortable availability overview at `hub./`. Client identity flags: `--hub-prefix`/`--hub-project`/`--hub-solution`/`--hub-branch`/`--hub-worktree` (project + branch auto-detected). Package: `cmd/mxcli/tunnelhub/`. See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4) +- Tunnel-hub GitHub authentication (opt-in, gated on `--github-oauth-client-id`; absent = today's open hub): **viewer plane** — GitHub OAuth web flow + HMAC-signed SSO session cookie (`Domain=.`), owner-checked previews (`--require-auth` default on → 302 to login / 403 non-owner; soft mode filters the listing only), `/api/backends` filtered to the viewer (unauthenticated → 401), admin "signed in as" via `/api/whoami`. **Registration plane** — durable, hashed hub API keys (`--keys-file`, default `~/.mxcli/hub-keys.json`, survive restarts) presented as `X-Hub-Key` → stamps `Backend.Owner`; shared `X-Hub-Secret` still works as an owner-less fallback. **Key issuance** — the hub's `/cli` browser page mints a key from the session cookie (no PAT; the device flow was removed as Claude Code containers block GitHub's device endpoints), rotate-by-default + count + revoke-all; `mxcli auth hub login --token ` is the headless path; `run --hub` reads `MXCLI_HUB_KEY` (env → `~/.mxcli/auth.json`) and degrades to local-only if registration fails. Append-only JSONL audit trail (`--audit-log`, no secrets). Packages: `cmd/mxcli/tunnelhub/` (+`audit/`), `cmd/mxcli/hubauth/`. See `docs/11-proposals/PROPOSAL_hub_authentication.md` - Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`): `--metrics` registers a Prometheus Micrometer registry at boot (served at `http://127.0.0.1:/prometheus`); `--runtime-setting` merges arbitrary runtime config (e.g. `Metrics.Registries` for otlp/influx/statsd, or `OpenTelemetry._RuntimeSpanFilters`) into mxcli's **single** boot `update_configuration` call — the admin action replaces rather than merges, so folding settings into the one boot call is the only safe way. OTel traces via `--trace` attach the bundled `opentelemetry-javaagent` to the runtime JVM (console exporter → the tee'd runtime log) and ship default `OpenTelemetry._RuntimeSpanFilters` (unfiltered per-activity tracing is ~10× slower); `--trace-service` sets `OTEL_SERVICE_NAME`. The console exporter omits timestamps/parent span IDs (no flame charts), so `--trace-otlp ` (implies `--trace`) switches to the OTLP exporter (protocol `http/protobuf`) pointed at a collector; user-set `OTEL_*` env still takes precedence. See `.claude/skills/mendix/run-local.md` - OQL query execution against running runtime (`mxcli oql`) - Microflow/nanoflow debugger (`mxcli debug`): set breakpoints **by name** (activity resolved from the model), inspect paused flows + variables, step over/into/out, continue — against a `run --local` runtime. Two M2EE planes wired behind one command (admin `enable/disable/status`, app `/debugger/` session); `run --local --debug` enables it at boot. **Nanoflows** are auto-detected (uses the `nanoflow_name` breakpoint param; paused nanoflows are merged from `poll_events`, which `get_paused_microflows` omits). Nanoflow `LOG` output is rewritten to the `Client_Nanoflow` node in the runtime log. See `.claude/skills/mendix/debug-microflows.md` and `docs/11-proposals/PROPOSAL_microflow_debugger.md` diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index b52fc0c4d..277b7418c 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -63,7 +63,8 @@ so structural changes need a restart; behavioural changes do not. |------|---------|---------| | `--local` | — | Required; run without Docker (implied by `--hub`) | | `--hub` | — | Expose the running app in a browser at a tunnel-hub URL (see [External browser preview](#external-browser-preview---hub)) | -| `--hub-secret` | — | Shared auth (`user:pass`) matching the hub's `--secret` | +| `--hub-secret` | — | Shared auth (`user:pass`) matching an **open** hub's `--secret` | +| *(hub API key)* | — | For a **GitHub-authenticated** hub: get one from `https:///cli`, set `MXCLI_HUB_KEY` (see below) | | `--watch` | off | Rebuild + hot-apply on every project change | | `--ensure-db` | off | Provision local Postgres + the app database if missing (fresh-session bootstrap) | | `--setup` | off | Prepare prerequisites (cache MxBuild+runtime, ensure DB) and exit without booting — for a SessionStart hook | @@ -186,8 +187,37 @@ projects, solutions, branches, and worktrees — with a sortable overview at **Hub setup:** a wildcard `*.example.com` A record (and `hub.example.com`) pointed at the VPS; inbound 80 + 443 open — a Let's Encrypt cert is issued per subdomain on demand. -**Security:** this version uses a single shared `--secret` with open registration, so keep -the hub to people you trust and don't expose it publicly (per-tenant auth is a follow-up). +### Authenticated hub (GitHub) + +A hub started **without** the GitHub flags is open — the shared `--secret` gates +registration, so keep it to people you trust. A hub started **with** GitHub OAuth adds +per-user isolation: viewers sign in with GitHub and see only their own previews, and each +preview is owned by whoever registered it. + +```bash +# create a GitHub OAuth App (callback https://hub.example.com/auth/github/callback), then: +mxcli tunnel-hub --domain example.com --secret alice:s3cret \ + --github-oauth-client-id --github-oauth-client-secret \ + --session-secret "$(openssl rand -hex 32)" --audit-log ~/.mxcli/hub-audit.jsonl +``` + +With auth on, `--require-auth` (default) 302s an unauthenticated viewer to GitHub and +returns a 403 to a non-owner; `--require-auth=false` filters the admin listing but leaves +previews open. Hub API keys are stored durably (`--keys-file`, default +`~/.mxcli/hub-keys.json`) so they survive restarts. + +**Get a key (any device, including Claude Code web/mobile):** open `https://hub.example.com/cli`, +sign in with GitHub, click **Create a hub key**, and set it as an environment/repo secret: + +```bash +export MXCLI_HUB_KEY= +mxcli run --hub https://hub.example.com -p app.mpr # registers previews as you +``` + +The key is durable and does not expire — set it once. Manage it from the same page ("Revoke +all keys"), or headless with `mxcli auth hub login --token `. If registration +fails (unreachable hub, stale key), `run --hub` warns and continues as a normal local run +instead of aborting. ## The change signal diff --git a/docs/11-proposals/PROPOSAL_hub_authentication.md b/docs/11-proposals/PROPOSAL_hub_authentication.md index 1024a8491..431578827 100644 --- a/docs/11-proposals/PROPOSAL_hub_authentication.md +++ b/docs/11-proposals/PROPOSAL_hub_authentication.md @@ -20,6 +20,25 @@ and continues that document's slice numbering as **slice 6**. **Self-hosted hubs stay open.** Everything here is opt-in via config; a hub started without the GitHub flags behaves exactly as it does today. +> **As-built amendments (post slice 4, shipped in PR #50 + #51).** Three things +> changed from the original design below during live testing on `hub.mxcli.org`; +> the design narrative is kept for the audit trail, but the shipped behaviour is: +> - **Key bootstrap is a browser page, not the device flow.** The Claude Code +> container's egress proxy blocks GitHub's device endpoints, so the GitHub +> **device flow was removed**. Instead the hub serves a signed-in **`/cli` +> page** that mints a key from the session cookie (CSRF-guarded); `mxcli auth +> hub login --token ` is the headless alternative. `GET /api/whoami` backs +> the "signed in as" indicator. +> - **Keys are durable, not in-memory.** The key store persists to +> `--keys-file` (default `~/.mxcli/hub-keys.json`, mode `0600`, hashed), so keys +> survive restarts — resolving *Open question* on persistence. +> - **Keys rotate by default + are manageable.** `POST /api/keys` replaces the +> caller's existing keys (opt out with `?replace=false`); `GET /api/keys` counts +> them; a session-authed `DELETE /api/keys` revokes all (leak recovery). This +> supersedes the "no list/rotate" gap. Keys still do **not** expire, by design +> (long-lived so `MXCLI_HUB_KEY` is set once); a per-login mint rate limit +> remains optional hardening. + ## Decisions locked (this proposal) - **Access model: owner-only.** A viewer sees a preview iff their GitHub login is the