diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local.md index 8e894da88..b685543cd 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: 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) | @@ -268,9 +269,41 @@ 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. + +**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): + +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 +export MXCLI_HUB_KEY= +mxcli run --hub https://hub.mxcli.org -p app.mpr # registers previews as you +``` + +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`). + +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; +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 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/cmd/mxcli/cmd_auth_hub.go b/cmd/mxcli/cmd_auth_hub.go new file mode 100644 index 000000000..08fe12fe1 --- /dev/null +++ b/cmd/mxcli/cmd_auth_hub.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "strings" + + "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; 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)", + Long: `Authenticate to a tunnel-hub so 'mxcli run --hub' can register previews +under your GitHub identity. + +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: + + MXCLI_HUB_KEY= # takes precedence over any 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.`, +} + +var authHubLoginCmd = &cobra.Command{ + Use: "login", + Short: "Get a hub API key (browser page, or --token for headless use)", + 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") + } + 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) +} + +func runAuthHubLogin(cmd *cobra.Command, _ []string) error { + hubURL, _ := cmd.Flags().GetString("hub") + token, _ := cmd.Flags().GetString("token") + out := 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(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 +} + +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/cmd_tunnelhub.go b/cmd/mxcli/cmd_tunnelhub.go index 0fced4aec..b89ad72c1 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,13 @@ 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") + keysFile, _ := cmd.Flags().GetString("keys-file") if domain == "" { fmt.Fprintln(os.Stderr, "Error: --domain is required (the wildcard base, e.g. example.com)") @@ -64,6 +81,27 @@ 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") + } + 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 +111,24 @@ Then, in each app's environment: TunnelAuth: secret, RegisterSecret: secret, CertCacheDir: certCache, + Auth: auth, + Audit: auditSink, + KeysFile: keysFile, }) 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) + if keysFile != "" { + fmt.Printf(" keys: durable at %s (survive restarts)\n", keysFile) + } + } host := hubHost if host == "" { @@ -96,6 +147,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 +186,12 @@ 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)") + 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/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..7678b383f 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,11 +542,21 @@ 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) + // 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/hubauth.go b/cmd/mxcli/hubauth/hubauth.go new file mode 100644 index 000000000..ac30fbbf7 --- /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 stores and +// resolves the hub API key per hub host in ~/.mxcli/auth.json, and (headless) +// mints one from a GitHub token. +// +// 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 ( + "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/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 73f14980c..5e6504aa3 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)) }) } @@ -44,12 +49,19 @@ 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 +131,128 @@ 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"), + cli = document.getElementById("clikey"); + if(j && j.authEnabled && j.login){ + who.innerHTML = "signed in as "+esc(j.login)+""; + btn.hidden = false; cli.hidden = false; + } else { + who.textContent = ""; btn.hidden = true; cli.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); +})(); + +` + +// 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 c0a614d62..143135cac 100644 --- a/cmd/mxcli/tunnelhub/api.go +++ b/cmd/mxcli/tunnelhub/api.go @@ -4,8 +4,12 @@ package tunnelhub import ( "encoding/json" + "fmt" "net/http" + "net/url" "strings" + + "github.com/mendixlabs/mxcli/cmd/mxcli/tunnelhub/audit" ) // APIOptions configures the registration API. @@ -22,6 +26,15 @@ 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 + // 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. @@ -29,6 +42,25 @@ 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"` +} + +// 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"` @@ -45,6 +77,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} } @@ -54,6 +89,56 @@ 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) + 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 +// 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"` + 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) { @@ -61,9 +146,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 { @@ -74,11 +160,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 @@ -94,6 +185,186 @@ 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 + } + // 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 + } + } + // 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 / X-Hub-Secret", + }) + http.Error(w, "missing or invalid X-Hub-Key (run 'mxcli auth hub login') or X-Hub-Secret", 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.MethodGet: + a.handleKeyList(w, r) + case http.MethodPost: + a.handleKeyMint(w, r) + case http.MethodDelete: + a.handleKeyRevoke(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// 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: +// +// 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) { + 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", + }) + 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" + +// 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. +// 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) { + 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), + }) + } + writeJSON(w, http.StatusOK, KeyRevokeResponse{Revoked: n}) +} + func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) { a.byToken(w, r, func(token string) { if !a.opts.Registry.Heartbeat(token) { @@ -127,7 +398,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) { - writeJSON(w, http.StatusOK, a.opts.Registry.List(r.URL.Query().Get("sort"))) + 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 new file mode 100644 index 000000000..dff38c044 --- /dev/null +++ b/cmd/mxcli/tunnelhub/api_keys_test.go @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "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) + } +} + +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) + } +} + +// 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) + } +} + +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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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/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/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) + } +} diff --git a/cmd/mxcli/tunnelhub/auth.go b/cmd/mxcli/tunnelhub/auth.go new file mode 100644 index 000000000..7653aa1f3 --- /dev/null +++ b/cmd/mxcli/tunnelhub/auth.go @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "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() +} + +// 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 +} + +// 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 + } + 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([]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 = "msg|expUnix"; msg (GitHub login / b64 url) 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 +} + +// 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()) + 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 signTagged(a.SessionSecret, tagState, enc, a.clock().Add(10*time.Minute)) +} + +func (a *AuthConfig) verifyState(state string) (returnURL string, ok bool) { + enc, ok := verifyTagged(a.SessionSecret, tagState, 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. 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 { + 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() + 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(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 +} + +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() + 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(io.LimitReader(resp.Body, 1<<16)).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. 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() || !a.RequireAuth || 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..65f873007 --- /dev/null +++ b/cmd/mxcli/tunnelhub/auth_test.go @@ -0,0 +1,369 @@ +// 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. +// RequireAuth is on so the preview owner-check tests exercise enforcement. +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", + RequireAuth: true, + 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_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"} + 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()) + } +} + +// 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") // attacker-supplied + if got := clientIP(r); got != "10.0.0.1" { + t.Errorf("clientIP = %q, want 10.0.0.1 (RemoteAddr, XFF ignored)", 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()) + } +} + +// 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/keys.go b/cmd/mxcli/tunnelhub/keys.go new file mode 100644 index 000000000..86c22fca4 --- /dev/null +++ b/cmd/mxcli/tunnelhub/keys.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 + +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. +// 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. +// +// 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]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 +} + +// 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]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 { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +func (s *KeyStore) mintToken() string { + if s.newToken != nil { + return s.newToken() + } + 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). 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)] = keyRecord{Login: login, CreatedAt: s.clock()} + _ = s.saveLocked() + 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() + rec, ok := s.byHash[hashKey(key)] + 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) { + if key == "" { + return "", false + } + h := hashKey(key) + s.mu.Lock() + defer s.mu.Unlock() + 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 os.Rename(tmpPath, s.path) +} diff --git a/cmd/mxcli/tunnelhub/keys_test.go b/cmd/mxcli/tunnelhub/keys_test.go new file mode 100644 index 000000000..3b25ef214 --- /dev/null +++ b/cmd/mxcli/tunnelhub/keys_test.go @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tunnelhub + +import ( + "os" + "path/filepath" + "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") + } +} + +// 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") + } +} + +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) + } +} diff --git a/cmd/mxcli/tunnelhub/registry.go b/cmd/mxcli/tunnelhub/registry.go index f2debb32d..ba4ded4fb 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. @@ -63,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 @@ -139,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 { @@ -210,15 +217,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..cb696b33b 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 @@ -44,6 +46,17 @@ 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 + // 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 @@ -84,11 +97,24 @@ 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). 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, TunnelAuth: o.TunnelAuth, RegisterSecret: o.RegisterSecret, + Auth: o.Auth, + Keys: keys, + Audit: o.Audit, }) apiMux := http.NewServeMux() api.Mount(apiMux) @@ -175,6 +201,20 @@ 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 + } + // 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) @@ -187,6 +227,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) @@ -223,7 +268,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-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 66eeeb25f..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 @@ -152,6 +171,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 +198,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,20 +233,132 @@ 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 +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): add `Backend.Owner`, - `List(sort, viewer)`, thread a viewer through admin/`/api/backends`. Pure refactor, - `""` viewer = today. Tests: registry filtering. -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). -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. -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. +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** — ✅ **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** — ✅ **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** — ✅ **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). @@ -227,7 +366,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. @@ -248,6 +388,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 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.