From 15a62358f4339dd07b094742cfce299d515602f8 Mon Sep 17 00:00:00 2001 From: Eeetan Date: Mon, 26 Oct 2026 00:14:13 -0400 Subject: [PATCH 1/3] Add opt-in Sign in with Misty for compute --- cli/cmd/auth_login.go | 15 + cli/cmd/auth_misty.go | 98 ++++ cli/cmd/auth_misty_test.go | 66 +++ deploy/private/misty-login.env.example | 7 + docs/agents/misty-login-verification.md | 76 +++ docs/agents/misty-login.md | 135 ++++++ src/app.go | 7 + src/auth.go | 68 ++- src/member_auth.go | 3 +- src/misty_auth.go | 451 ++++++++++++++++++ src/misty_auth_test.go | 338 +++++++++++++ web-interface/src/api.ts | 6 +- web-interface/src/auth.tsx | 11 +- web-interface/src/components/AccountPage.tsx | 195 ++++---- .../src/components/MistyLogin.test.tsx | 130 +++++ web-interface/src/components/MistyLogin.tsx | 189 ++++++++ 16 files changed, 1697 insertions(+), 98 deletions(-) create mode 100644 cli/cmd/auth_misty.go create mode 100644 cli/cmd/auth_misty_test.go create mode 100644 deploy/private/misty-login.env.example create mode 100644 docs/agents/misty-login-verification.md create mode 100644 docs/agents/misty-login.md create mode 100644 src/misty_auth.go create mode 100644 src/misty_auth_test.go create mode 100644 web-interface/src/components/MistyLogin.test.tsx create mode 100644 web-interface/src/components/MistyLogin.tsx diff --git a/cli/cmd/auth_login.go b/cli/cmd/auth_login.go index 8fff8d0..d6a4d32 100644 --- a/cli/cmd/auth_login.go +++ b/cli/cmd/auth_login.go @@ -16,9 +16,20 @@ import ( type LoginCmd struct { Email string `help:"Mist member email" required:""` PasswordStdin bool `help:"Read password from standard input (for automation)"` + Misty bool `help:"Sign in with a code sent by Misty"` + CodeStdin bool `help:"Read the Misty code from standard input"` } func (l *LoginCmd) Run(ctx *AppContext) error { + if l.Misty { + if l.PasswordStdin { + return fmt.Errorf("--password-stdin cannot be used with --misty") + } + return l.loginMisty(ctx) + } + if l.CodeStdin { + return fmt.Errorf("--code-stdin requires --misty") + } var password string if l.PasswordStdin { data, err := io.ReadAll(io.LimitReader(os.Stdin, 130)) @@ -57,6 +68,10 @@ func (l *LoginCmd) Run(ctx *AppContext) error { if response.StatusCode != 200 { return fmt.Errorf("login failed (HTTP %d)", response.StatusCode) } + return ctx.saveLogin(base, response) +} + +func (ctx *AppContext) saveLogin(base string, response *http.Response) error { cookies := []string{} for _, cookie := range response.Cookies() { if strings.Contains(cookie.Name, "session_token") && cookie.Value != "" { diff --git a/cli/cmd/auth_misty.go b/cli/cmd/auth_misty.go new file mode 100644 index 0000000..c36b11e --- /dev/null +++ b/cli/cmd/auth_misty.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" + + "golang.org/x/term" +) + +func (l *LoginCmd) loginMisty(ctx *AppContext) error { + base, err := url.Parse(ctx.baseURL()) + if err != nil || base.Host == "" || base.User != nil { + return fmt.Errorf("invalid compute URL") + } + ip := net.ParseIP(base.Hostname()) + if base.Scheme != "https" && !(base.Scheme == "http" && (base.Hostname() == "localhost" || (ip != nil && ip.IsLoopback()))) { + return fmt.Errorf("Misty login requires HTTPS (HTTP only on loopback)") + } + // Never follow a login redirect with a code or binding cookie. + client := *ctx.HTTPClient + client.Timeout = 15 * time.Second + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + origin := base.Scheme + "://" + base.Host + post := func(path string, body any, cookies []*http.Cookie) (*http.Response, error) { + data, _ := json.Marshal(body) + req, err := http.NewRequest(http.MethodPost, origin+path, bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("invalid login request") + } + req.Header.Set("Origin", origin) + req.Header.Set("Content-Type", "application/json") + for _, cookie := range cookies { + req.AddCookie(cookie) + } + response, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("compute login unavailable") + } + return response, nil + } + start, err := post("/auth/misty/start", map[string]string{"email": l.Email}, nil) + if err != nil { + return err + } + defer start.Body.Close() + if start.StatusCode != 202 { + return fmt.Errorf("requesting code failed (HTTP %d)", start.StatusCode) + } + var challenge struct { + ID string `json:"challenge_id"` + } + if json.NewDecoder(io.LimitReader(start.Body, 8192)).Decode(&challenge) != nil || challenge.ID == "" { + return fmt.Errorf("invalid login response") + } + var cookies []*http.Cookie + for _, cookie := range start.Cookies() { + if cookie.Name == "misty_login" { + cookies = append(cookies, cookie) + } + } + if len(cookies) != 1 { + return fmt.Errorf("login returned no challenge cookie") + } + fmt.Fprintln(os.Stderr, "If your email belongs to an active Misty account, a code will arrive shortly.") + var code []byte + if l.CodeStdin { + code, err = io.ReadAll(io.LimitReader(os.Stdin, 16)) + } else { + fmt.Fprint(os.Stderr, "Verification code: ") + code, err = term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + } + if err != nil { + return fmt.Errorf("cannot read code (use --code-stdin for automation)") + } + value := strings.TrimSpace(string(code)) + if !regexp.MustCompile(`^[0-9]{6}$`).MatchString(value) { + return fmt.Errorf("enter a six-digit code") + } + confirm, err := post("/auth/misty/confirm", map[string]string{"challenge_id": challenge.ID, "code": value}, cookies) + if err != nil { + return err + } + defer confirm.Body.Close() + if confirm.StatusCode != 200 { + return fmt.Errorf("code verification failed (HTTP %d); check the code or start again", confirm.StatusCode) + } + return ctx.saveLogin(ctx.baseURL(), confirm) +} diff --git a/cli/cmd/auth_misty_test.go b/cli/cmd/auth_misty_test.go new file mode 100644 index 0000000..c677d91 --- /dev/null +++ b/cli/cmd/auth_misty_test.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMistyCLIUsesBoundCodeAndSavesProtectedSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Origin") == "" || r.Header.Get("X-API-Key") != "" { + t.Error("incorrect client authentication") + } + switch r.URL.Path { + case "/auth/misty/start": + http.SetCookie(w, &http.Cookie{Name: "misty_login", Value: "binding", Path: "/auth"}) + w.WriteHeader(202) + json.NewEncoder(w).Encode(map[string]string{"challenge_id": "challenge"}) + case "/auth/misty/confirm": + cookie, err := r.Cookie("misty_login") + if err != nil || cookie.Value != "binding" { + t.Error("binding missing") + } + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["code"] != "123456" || body["challenge_id"] != "challenge" { + t.Error("incorrect confirmation") + } + http.SetCookie(w, &http.Cookie{Name: "session_token", Value: "synthetic-session", HttpOnly: true}) + w.WriteHeader(200) + default: + t.Error("unexpected path", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + input, err := os.CreateTemp(t.TempDir(), "code") + if err != nil { + t.Fatal(err) + } + defer input.Close() + input.WriteString("123456\n") + input.Seek(0, 0) + original := os.Stdin + os.Stdin = input + defer func() { os.Stdin = original }() + ctx := &AppContext{HTTPClient: server.Client(), APIBaseURL: server.URL, ConfigPath: filepath.Join(t.TempDir(), "config.json")} + if err := (&LoginCmd{Email: "member@example.com", Misty: true, CodeStdin: true}).Run(ctx); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(ctx.ConfigPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "synthetic-session") || strings.Contains(string(data), "123456") || strings.Contains(string(data), "binding") { + t.Fatal("incorrect persisted credentials") + } + info, err := os.Stat(ctx.ConfigPath) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal("unprotected config", err) + } +} diff --git a/deploy/private/misty-login.env.example b/deploy/private/misty-login.env.example new file mode 100644 index 0000000..4e11115 --- /dev/null +++ b/deploy/private/misty-login.env.example @@ -0,0 +1,7 @@ +# Copy to a protected operator file; never commit the populated copy. +# Enable only after the portal and gateway have working HTTPS. +MIST_AUTH_MODE=misty +MIST_SITE_ORIGIN=https://compute.example.com +MISTY_GATEWAY_URL=https://gateway.example.com +# Issued gw_ key with exactly auth:email and auth:status; server-side only. +MISTY_GATEWAY_API_KEY= diff --git a/docs/agents/misty-login-verification.md b/docs/agents/misty-login-verification.md new file mode 100644 index 0000000..8893eab --- /dev/null +++ b/docs/agents/misty-login-verification.md @@ -0,0 +1,76 @@ +# Misty login verification — 2026-10-26 + +The local implementation passed independent code review and the checks below. +No live service was deployed and no real email was sent. Tailscale was not needed. + +## Cross-service browser check + +Chromium exercised the built compute frontend and real Go authentication code +against freshly built Misty gateway, team-tracking and verification containers. +Each service used disposable storage; gateway and verification had their own +Postgres databases. Private calls used separately issued scoped credentials. +Only the email sender and its code generator were test replacements. Unrelated +compute job/cluster routes were stubs; no Kubernetes jobs ran. + +Observed results: + +- An active registered email received a challenge; an incorrect code failed. +- The correct code created a compute session, preserving the seeded existing + local user ID and administrator role. +- Reusing the redeemed challenge failed. +- Password account-creation and reset controls were absent in Misty mode. +- Retiring the member in the real directory caused the existing compute session + to be rejected after the configured status cache expired, within 40 seconds. +- Unknown and inactive emails received the generic start response, could not + establish sessions, and created no verification-code rows. +- The gateway also passed its resolver credential-separation, upstream outage, + revocation, migration/readiness and email-login smoke assertions. +- Chromium reported no page errors. + +A separate Chromium run through real compute authentication with a fake gateway +verified sign-out, HttpOnly session cookies, and the absence of a gateway key in +browser requests. + +## Automated checks + +- API and CLI: Linux `go test -race ./...` passed. Regressions cover concurrent + redemption, expiry, attempts, browser binding, strict origin/body validation, + database failures, retired members, upstream outages, legacy sessions, account + collisions/bans, preserved ownership after email changes and quota responses. +- Frontend: Vitest, TypeScript, ESLint on changed sources, Prettier checks on + changed sources, and the production build passed. The resend regression + selects an earlier request explicitly after a replacement request. +- `git diff --check` passed. + +The macOS API build hits pre-existing Linux-specific `statfs` type differences, +so Go tests ran in `golang:1.26` containers. `npm ci` encountered missing optional +platform packages in the existing lockfile; `npm install --ignore-scripts` +installed the same existing package versions plus missing optional entries for +verification. The original lockfile was restored; dependency repair is separate +from this feature. + +## Local evidence + +The operator workspace retains the disposable test harness and logs: + +- `/tmp/misty-compute-browser/fullstack.py`: container orchestration, derived + from Misty's existing `services/gateway/tests/smoke_stack.py`. +- `/tmp/misty-compute-browser/fullstack_harness_test.go`: compute auth/static + frontend harness, injected only into a temporary test checkout. +- `/tmp/misty-compute-browser/fullstack.cjs`: browser assertions. +- `/tmp/misty-compute-fullstack.log` and + `/tmp/misty-compute-fullstack-browser.log`: successful integration results. +- `/tmp/misty-compute-browser/fullstack-signed-in.png`: authenticated account UI. +- `/tmp/misty-compute-browser/artifact-hash.txt`: SHA-256 over changed Go and + TypeScript source/test paths and contents. + +These are local temporary artifacts, not deployment assets. All test containers, +networks and disposable databases were removed after verification. + +## Remaining live checks + +The private compute host was unreachable because this Mac's Tailscale client was +logged out. Live HTTPS, ingress/proxy-IP configuration, real email delivery, +database backup/cutover and existing production account mappings remain +unverified. Misty's staging gateway still requires deployment and provisioning. +Follow [the rollout guide](misty-login.md) before enabling the feature. diff --git a/docs/agents/misty-login.md b/docs/agents/misty-login.md new file mode 100644 index 0000000..78a0daa --- /dev/null +++ b/docs/agents/misty-login.md @@ -0,0 +1,135 @@ +# Sign in with Misty + +`MIST_AUTH_MODE=misty` replaces local password login with Misty email codes. +The default is `local`; no deployment manifest enables the new mode automatically. +Misty checks its registered primary email and active directory record, generates +and sends the code, verifies it, and returns an immutable person ID. Mist keeps +its own SQLite sessions, user IDs, roles, teams, jobs and files. + +## Configuration and rollout + +Use `deploy/private/misty-login.env.example` as the configuration reference: + +| Variable | Meaning | +| --- | --- | +| `MIST_AUTH_MODE` | `local` (default) or `misty`; unknown values fail startup | +| `MIST_SITE_ORIGIN` | Exact browser origin; required in Misty mode | +| `MISTY_GATEWAY_URL` | Gateway origin, without an API path | +| `MISTY_GATEWAY_API_KEY` | Server-only issued `gw_` key with `auth:email` and `auth:status` | + +Both origins must use HTTPS; loopback HTTP is allowed for development only. +Misty mode forces authentication and rejects `MIST_AUTH_DISABLED=true`. +Every auth POST must have the exact configured Origin. The existing trusted +Nginx address list (`MIST_TRUSTED_PROXY_IPS`) must be correct: only those socket +peers may attest `X-Real-IP`. Browsers never supply the gateway key or client IP. + +1. Deploy and verify Misty's gateway/verification changes in staging first. + Follow `services/gateway/docs/DEPLOYMENT.md` in UTMIST/Misty for private + consumer credentials, migration 002 and scoped gateway key issuance. +2. Establish HTTPS on the compute portal. Its existing private HTTP deployment + cannot enable this mode. Do not expose the Kubernetes API or widen tailnet + access just to enable login. +3. Back up the existing auth database using `deploy/private/backup-auth.sh`. + Check that the active Misty email for each existing admin matches exactly one + local user (case-insensitively). Duplicate matches or a local ban deny login. +4. Deploy this source using the normal reviewed compute release process. Load + the gateway key through a protected Kubernetes Secret, never a frontend env + variable or a checked-in manifest. Configure the remaining values on the API + deployment and keep its existing single-replica SQLite/PVC arrangement. +5. Verify an existing account retains its local ID, role, teams and files; a new + eligible member gets role `user`. Verify wrong-code errors, replay rejection, + sign-out, and inactive-member denial before considering the cutover complete. + +Startup adds `misty_identity`, `misty_login`, `misty_legacy_owner`, and nullable +`session.mistyPersonId`; existing rows and credentials remain. Old password +sessions are rejected while Misty mode is enabled. Password creation/change/reset +endpoints and their UI controls are disabled in this mode. Existing local admin +roles remain local authority; Misty never grants compute administrator access. + +If `MIST_LEGACY_OWNER_EMAIL` is configured, first enablement requires it to match +one existing local user. That user's ID is pinned durably before any email can +change. The pin survives restarts and rollback to `local`; email reassignment +must never transfer old jobs or datasets. Changing the configured legacy email +does not overwrite a recorded pin. Handle deliberate ownership transfers as a +separate reviewed data migration. + +For rollback, restore `MIST_AUTH_MODE=local` and restart the same code version. +Keep the additive tables, existing database and volume. Existing local passwords +work again; users first created through Misty have no password and need an admin +to provision one. Rolling back to older code that lacks the durable ownership pin +is unsafe after a legacy owner's email changes. + +## Login and session boundaries + +The browser calls `/auth/misty/start` with `{email}`, then +`/auth/misty/confirm` with `{challenge_id,code}`. The API forwards to gateway +`/v1/auth/email/start` and `/v1/auth/email/confirm` with its server credential and +socket-derived client IP. A hashed HttpOnly browser binding, expiry, attempt +budget and atomic in-flight reservation prevent unbound/replayed confirmation. +Neither the code nor a session token is returned to JavaScript or logged. +The API stores challenge metadata, not code values or code hashes. + +The gateway's generic 202 does not disclose whether an account exists or an +email was sent. A failed/throttled resend can leave the earlier code valid; +the UI retains recent request IDs so users may explicitly choose that request. +It never silently tries a code against multiple challenges. + +First successful login links the verified email to one existing local ID, or +creates a new ordinary member. Subsequent logins use the stored Misty person ID, +allowing email changes without transferring local ownership. Conflicting emails, +duplicate accounts, bans and already-linked identities fail closed. + +Authenticated requests check `/v1/auth/people/{id}/status`. Results are cached +per person for 30 seconds; errors deny access and are cached for 5 seconds. +A retirement therefore blocks subsequent requests after at most 30 seconds of +cached eligibility. Ongoing jobs are governed by the existing local ban/team +revocation policy; Misty retirement does not automatically cancel running jobs. +Authentication is checked when a request starts, not continuously during a +long-running response. + +Compute adds durable per-IP start (5/minute) and confirm (30/minute) admission, +five attempts per challenge, ten-minute expiry and strict 4 KiB inputs. Misty +also enforces application, client-IP and mailbox quotas. Database failures deny +admission before sending; upstream failures do not grant sessions. Definitive +upstream 429 responses preserve a wait response and do not use a local guess. +The gateway currently allows 60 status checks/minute per application: size its +quota/cache policy deliberately before exceeding about 30 continuously active +identities. HTTP clients have bounded timeouts and do not follow redirects. + +## CLI + +Use the HTTPS portal URL (the same origin configured on the server): + +```sh +mist --api-url https://compute.example.com/api auth login --misty --email member@example.com +``` + +The CLI requests a code, reads it without echoing, confirms with the bound +challenge cookie and saves only session cookies in its existing mode-0600 config. +`--code-stdin` reads a code from standard input; do not put codes in arguments. +The old password flow remains available only in local mode. + +## Validation and changed files + +Run API/CLI tests on Linux (the existing storage code has Linux-specific statfs +types), including `go test -race ./...` in `src` and `cli`. Run frontend Vitest, +TypeScript, ESLint and the production build. All automated providers are local +fakes; tests must never send real mail. + +See [the verification report](misty-login-verification.md) for the completed local +checks and the remaining live deployment checks. + +- `src/misty_auth.go`: scoped gateway client, identity/challenge storage, login + handlers, eligibility cache, configuration and durable legacy-owner migration. +- `src/auth.go`, `src/app.go`, `src/member_auth.go`: session provenance, opt-in + mode enforcement, SQLite connection consistency and stable ownership routing. +- `src/misty_auth_test.go`: failure, concurrency, identity and session regressions. +- `cli/cmd/auth_misty.go`, `auth_login.go`, `auth_misty_test.go`: bound code login + and reuse of protected session persistence, with regression coverage. +- `web-interface/src/components/MistyLogin.tsx` and its test: email/code flow, + retry/error states and request selection after resend. +- `web-interface/src/auth.tsx`, `api.ts`, `components/AccountPage.tsx`: mode-aware + account gate, API response type and password-control visibility. +- `deploy/private/misty-login.env.example` and this document: configuration, + rollout/rollback and ownership/security boundaries. +- `docs/agents/misty-login-verification.md`: observed test evidence and limits. diff --git a/src/app.go b/src/app.go index d74cd05..0995b16 100644 --- a/src/app.go +++ b/src/app.go @@ -37,8 +37,15 @@ func NewKubernetesApp(executor *KubernetesExecutor, log *slog.Logger) *App { mux.HandleFunc("GET /storage/folders", a.storageFolders) mux.HandleFunc("GET /storage/files", a.storageFolderFiles) authDisabled := os.Getenv("MIST_AUTH_DISABLED") == "true" + if mode := envOr("MIST_AUTH_MODE", "local"); mode != "local" && mode != "misty" { + panic("MIST_AUTH_MODE must be local or misty") + } + if os.Getenv("MIST_AUTH_MODE") == "misty" && authDisabled { + panic("MIST_AUTH_DISABLED cannot be used with Misty login") + } authDB := os.Getenv("MIST_AUTH_DATABASE") authRequired := os.Getenv("MIST_AUTH_ENABLED") == "true" || + os.Getenv("MIST_AUTH_MODE") == "misty" || os.Getenv("MIST_TEAMS_ENABLED") == "true" || os.Getenv("BETTER_AUTH_SECRET") != "" || authDB != "" || diff --git a/src/auth.go b/src/auth.go index f8d1fe1..9a7282f 100644 --- a/src/auth.go +++ b/src/auth.go @@ -26,11 +26,13 @@ import ( ) type AuthService struct { - db *sql.DB - log *slog.Logger - origins map[string]bool - legacyEmail string - mu sync.Mutex + misty *mistyClient + db *sql.DB + log *slog.Logger + origins map[string]bool + legacyEmail string + legacyUserID string + mu sync.Mutex } func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { @@ -50,6 +52,9 @@ func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { if err != nil { return nil, fmt.Errorf("open auth db: %w", err) } + // SQLite pragmas below are connection-local. Reuse that connection so all + // admissions observe busy_timeout and foreign_keys, including concurrent logins. + db.SetMaxOpenConns(1) for _, pragma := range []string{ "PRAGMA journal_mode = WAL;", @@ -152,6 +157,10 @@ func newAuthService(dbPath string, log *slog.Logger) (*AuthService, error) { if err := s.bootstrapAdmin(); err != nil { log.Warn("bootstrap admin failed", "error", err) } + if err := s.initMisty(); err != nil { + db.Close() + return nil, err + } return s, nil } @@ -298,13 +307,26 @@ func (s *AuthService) session(r *http.Request) (*Member, error) { } now := time.Now().UTC().Format(time.RFC3339Nano) var m Member + var personID string err := s.db.QueryRowContext(r.Context(), ` - SELECT u.id, u.name, u.email, u.role + SELECT u.id, u.name, u.email, u.role, COALESCE(s.mistyPersonId, '') FROM session s JOIN user u ON s.userId = u.id WHERE s.token = ? AND s.expiresAt > ? AND COALESCE(u.banned, 0) = 0 - `, token, now).Scan(&m.ID, &m.Name, &m.Email, &m.Role) + `, token, now).Scan(&m.ID, &m.Name, &m.Email, &m.Role, &personID) if err == nil { + if s.misty != nil { + if personID == "" { + return nil, nil + } + active, err := s.misty.active(r.Context(), personID) + if err != nil { + return nil, err + } + if !active { + return nil, nil + } + } return &m, nil } else if !errors.Is(err, sql.ErrNoRows) { return nil, err @@ -375,8 +397,21 @@ func setSessionCookies(w http.ResponseWriter, token string, maxAge int, isSecure func (s *AuthService) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") path := r.URL.Path + if s.misty != nil && r.Method == http.MethodPost && + (r.Header.Get("Origin") != s.misty.origin || r.Header.Get("Sec-Fetch-Site") == "cross-site") { + writeAuthError(w, 403, "Untrusted request origin", "FORBIDDEN") + return + } + if s.misty != nil && (path == "/auth/sign-in/email" || path == "/auth/change-password" || path == "/auth/admin/set-user-password" || path == "/auth/admin/create-user") { + writeAuthError(w, 403, "Accounts sign in through Misty", "MISTY_LOGIN_REQUIRED") + return + } switch { + case path == "/auth/misty/start" && r.Method == http.MethodPost: + s.handleMistyStart(w, r) + case path == "/auth/misty/confirm" && r.Method == http.MethodPost: + s.handleMistyConfirm(w, r) case path == "/auth/sign-in/email" && r.Method == http.MethodPost: s.handleSignInEmail(w, r) case path == "/auth/sign-out" && r.Method == http.MethodPost: @@ -495,6 +530,19 @@ func (s *AuthService) handleSignOut(w http.ResponseWriter, r *http.Request) { } func (s *AuthService) handleGetSession(w http.ResponseWriter, r *http.Request) { + if s.misty != nil { + member, err := s.session(r) + if err != nil { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + if member == nil { + writeJSON(w, 200, map[string]any{"user": nil, "session": nil}) + return + } + writeJSON(w, 200, map[string]any{"user": member, "session": map[string]string{"userId": member.ID}}) + return + } token := extractSessionToken(r) if token == "" { writeJSON(w, 200, map[string]interface{}{"user": nil, "session": nil}) @@ -860,7 +908,11 @@ func (s *AuthService) middleware(next http.Handler) http.Handler { return } if r.URL.Path == "/session" && r.Method == http.MethodGet { - writeJSON(w, 200, map[string]interface{}{"enabled": true, "user": member}) + mode := "local" + if s.misty != nil { + mode = "misty" + } + writeJSON(w, 200, map[string]interface{}{"enabled": true, "user": member, "auth_mode": mode}) return } if member == nil { diff --git a/src/member_auth.go b/src/member_auth.go index 76d6511..e9d7e12 100644 --- a/src/member_auth.go +++ b/src/member_auth.go @@ -46,7 +46,8 @@ func (a *App) requestExecutor(r *http.Request) *KubernetesExecutor { return a.executor } clone := *a.executor - if a.auth != nil && strings.EqualFold(member.Email, a.auth.legacyEmail) { + if a.auth != nil && ((a.auth.legacyUserID != "" && member.ID == a.auth.legacyUserID) || + (a.auth.legacyUserID == "" && a.auth.misty == nil && strings.EqualFold(member.Email, a.auth.legacyEmail))) { clone.owner = a.executor.owner } else { digest := sha256.Sum256([]byte(member.ID)) diff --git a/src/misty_auth.go b/src/misty_auth.go new file mode 100644 index 0000000..db5d1bb --- /dev/null +++ b/src/misty_auth.go @@ -0,0 +1,451 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" +) + +const mistyCookie = "misty_login" + +var mistyID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +var mistyCode = regexp.MustCompile(`^[0-9]{6}$`) +var mistyEmail = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) +var errMistyUnavailable = errors.New("Misty authentication unavailable") + +type mistyStatus struct { + mu sync.Mutex + until time.Time + active bool + failed bool +} + +type mistyClient struct { + baseURL string + origin string + apiKey string + http *http.Client + mu sync.Mutex + statuses map[string]*mistyStatus +} + +func newMistyClient() (*mistyClient, error) { + mode := envOr("MIST_AUTH_MODE", "local") + if mode == "local" { + return nil, nil + } + if mode != "misty" { + return nil, errors.New("MIST_AUTH_MODE must be local or misty") + } + base := strings.TrimRight(os.Getenv("MISTY_GATEWAY_URL"), "/") + key := os.Getenv("MISTY_GATEWAY_API_KEY") + if !safeMistyURL(base) || !safeMistyURL(os.Getenv("MIST_SITE_ORIGIN")) || !strings.HasPrefix(key, "gw_") { + return nil, errors.New("Misty login requires MISTY_GATEWAY_URL and MIST_SITE_ORIGIN over HTTPS (HTTP only on loopback), and a scoped MISTY_GATEWAY_API_KEY") + } + return &mistyClient{baseURL: base, origin: strings.TrimRight(os.Getenv("MIST_SITE_ORIGIN"), "/"), apiKey: key, statuses: map[string]*mistyStatus{}, http: &http.Client{ + Timeout: 8 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }}, nil +} + +func safeMistyURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return false + } + if u.Scheme == "https" { + return true + } + ip := net.ParseIP(u.Hostname()) + return u.Scheme == "http" && (u.Hostname() == "localhost" || (ip != nil && ip.IsLoopback())) +} + +// Never return transport errors or upstream bodies: they can contain credentials. +func (c *mistyClient) request(ctx context.Context, method, path string, body any, out any) (int, error) { + var input io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return 0, errMistyUnavailable + } + input = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, input) + if err != nil { + return 0, errMistyUnavailable + } + req.Header.Set("X-API-Key", c.apiKey) + req.Header.Set("Content-Type", "application/json") + response, err := c.http.Do(req) + if err != nil { + return 0, errMistyUnavailable + } + defer response.Body.Close() + if response.StatusCode >= 200 && response.StatusCode < 300 && out != nil { + if err := json.NewDecoder(io.LimitReader(response.Body, 8192)).Decode(out); err != nil { + return 0, errMistyUnavailable + } + } + return response.StatusCode, nil +} + +func (c *mistyClient) active(ctx context.Context, personID string) (bool, error) { + if !mistyID.MatchString(personID) { + return false, errMistyUnavailable + } + c.mu.Lock() + entry := c.statuses[personID] + if entry == nil { + if len(c.statuses) >= 4096 { + clear(c.statuses) + } + entry = &mistyStatus{} + c.statuses[personID] = entry + } + c.mu.Unlock() + entry.mu.Lock() + defer entry.mu.Unlock() + if time.Now().Before(entry.until) { + if entry.failed { + return false, errMistyUnavailable + } + return entry.active, nil + } + var result struct { + Active *bool `json:"active"` + } + status, err := c.request(ctx, "GET", "/v1/auth/people/"+personID+"/status", nil, &result) + if err != nil || status != 200 || result.Active == nil { + entry.failed = true + entry.until = time.Now().Add(5 * time.Second) + return false, errMistyUnavailable + } + entry.active = *result.Active + entry.failed = false + entry.until = time.Now().Add(30 * time.Second) + return entry.active, nil +} + +func (s *AuthService) initMisty() error { + client, err := newMistyClient() + if err != nil { + return err + } + s.misty = client + // Always migrate; switching modes must not erase identity/session provenance. + _, err = s.db.Exec(`CREATE TABLE IF NOT EXISTS misty_identity ( + person_id TEXT PRIMARY KEY, user_id TEXT NOT NULL UNIQUE REFERENCES user(id) + ); + CREATE TABLE IF NOT EXISTS misty_login ( + binding_hash TEXT NOT NULL, challenge_id TEXT NOT NULL, email TEXT NOT NULL, + expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, in_flight INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(binding_hash,challenge_id) + ); + CREATE INDEX IF NOT EXISTS misty_login_expiry ON misty_login(expires_at); + CREATE TABLE IF NOT EXISTS misty_legacy_owner ( + singleton INTEGER PRIMARY KEY CHECK(singleton=1), user_id TEXT NOT NULL REFERENCES user(id) + );`) + if err != nil { + return fmt.Errorf("initialize Misty login tables: %w", err) + } + var exists int + if err = s.db.QueryRow(`SELECT count(*) FROM pragma_table_info('session') WHERE name='mistyPersonId'`).Scan(&exists); err != nil { + return err + } + if exists == 0 { + _, err = s.db.Exec(`ALTER TABLE session ADD COLUMN mistyPersonId TEXT`) + } + if err != nil { + return err + } + err = s.db.QueryRow(`SELECT user_id FROM misty_legacy_owner WHERE singleton=1`).Scan(&s.legacyUserID) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + if client == nil || s.legacyEmail == "" { + return nil + } + var count int + if err = s.db.QueryRow(`SELECT count(*),COALESCE(min(id),'') FROM user WHERE lower(email)=lower(?)`, s.legacyEmail).Scan(&count, &s.legacyUserID); err != nil { + return err + } + if count != 1 { + return errors.New("MIST_LEGACY_OWNER_EMAIL must match one existing compute account before enabling Misty login") + } + _, err = s.db.Exec(`INSERT INTO misty_legacy_owner(singleton,user_id) VALUES(1,?)`, s.legacyUserID) + return err +} + +func (s *AuthService) mistyGuard(w http.ResponseWriter, r *http.Request) bool { + if s.misty == nil { + writeAuthError(w, 404, "Endpoint not found", "NOT_FOUND") + return false + } + if r.Header.Get("Origin") != s.misty.origin || r.Header.Get("Sec-Fetch-Site") == "cross-site" { + writeAuthError(w, 403, "Untrusted request origin", "FORBIDDEN") + return false + } + return true +} + +func decodeMisty(w http.ResponseWriter, r *http.Request, out any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) + decoder.DisallowUnknownFields() + if decoder.Decode(out) != nil || decoder.Decode(&struct{}{}) != io.EOF { + writeAuthError(w, 400, "Invalid login request", "BAD_REQUEST") + return false + } + return true +} + +func browserBinding(r *http.Request) (string, string) { + cookie, err := r.Cookie(mistyCookie) + if err != nil || len(cookie.Value) != 64 { + return "", "" + } + if _, err = hex.DecodeString(cookie.Value); err != nil { + return "", "" + } + return cookie.Value, bindingHash(cookie.Value) +} + +func bindingHash(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +// Admission fails closed if durable rate-limit state cannot be read or written. +func (s *AuthService) admitMisty(w http.ResponseWriter, r *http.Request, key string, maximum int) bool { + var count int + now := time.Now().Unix() + err := s.db.QueryRowContext(r.Context(), `INSERT INTO rate_limit(key,count,lastRequest) VALUES(?,1,?) + ON CONFLICT(key) DO UPDATE SET + count=CASE WHEN rate_limit.lastRequest<=? THEN 1 ELSE rate_limit.count+1 END, + lastRequest=CASE WHEN rate_limit.lastRequest<=? THEN excluded.lastRequest ELSE rate_limit.lastRequest END + WHERE rate_limit.lastRequest<=? OR rate_limit.count 254 || !mistyEmail.MatchString(email) { + writeAuthError(w, 400, "Enter a valid email address", "BAD_REQUEST") + return + } + ip := clientAddress(r) + if net.ParseIP(ip) == nil { + writeAuthError(w, 400, "Invalid client address", "BAD_REQUEST") + return + } + if !s.admitMisty(w, r, "misty-start:"+ip, 5) { + return + } + var result struct { + ChallengeID string `json:"challenge_id"` + ExpiresIn int `json:"expires_in"` + } + status, err := s.misty.request(r.Context(), "POST", "/v1/auth/email/start", map[string]string{"email": email, "client_ip": ip}, &result) + if status == 429 { + w.Header().Set("Retry-After", "60") + writeAuthError(w, 429, "Please wait before requesting another code", "TOO_MANY_REQUESTS") + return + } + if err != nil || status != 202 || !mistyID.MatchString(result.ChallengeID) || result.ExpiresIn <= 0 || result.ExpiresIn > 600 { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + token, hash := browserBinding(r) + if token == "" { + token = randomToken(32) + hash = bindingHash(token) + } + now := time.Now().Unix() + if _, err = s.db.ExecContext(r.Context(), `DELETE FROM misty_login WHERE expires_at<=?`, now); err != nil { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + _, err = s.db.ExecContext(r.Context(), `INSERT INTO misty_login(binding_hash,challenge_id,email,expires_at) VALUES(?,?,?,?)`, hash, result.ChallengeID, email, now+int64(result.ExpiresIn)) + if err != nil { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + secure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil + http.SetCookie(w, &http.Cookie{Name: mistyCookie, Value: token, Path: "/auth", HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: 600}) + writeJSON(w, 202, map[string]any{"challenge_id": result.ChallengeID, "expires_in": result.ExpiresIn, "message": "If an active Misty account matches, a code will be sent."}) +} + +func (s *AuthService) handleMistyConfirm(w http.ResponseWriter, r *http.Request) { + if !s.mistyGuard(w, r) { + return + } + var payload struct { + ChallengeID string `json:"challenge_id"` + Code string `json:"code"` + } + if !decodeMisty(w, r, &payload) { + return + } + _, hash := browserBinding(r) + if hash == "" || !mistyID.MatchString(payload.ChallengeID) || !mistyCode.MatchString(payload.Code) { + writeAuthError(w, 400, "Invalid or expired login. Request a new code.", "INVALID_LOGIN") + return + } + ip := clientAddress(r) + if net.ParseIP(ip) == nil { + writeAuthError(w, 400, "Invalid client address", "BAD_REQUEST") + return + } + if !s.admitMisty(w, r, "misty-confirm:"+ip, 30) { + return + } + var email string + err := s.db.QueryRowContext(r.Context(), `UPDATE misty_login SET in_flight=1,attempts=attempts+1 + WHERE binding_hash=? AND challenge_id=? AND expires_at>? AND in_flight=0 AND attempts<5 RETURNING email`, hash, payload.ChallengeID, time.Now().Unix()).Scan(&email) + if errors.Is(err, sql.ErrNoRows) { + writeAuthError(w, 400, "Invalid or expired login. Request a new code.", "INVALID_LOGIN") + return + } + if err != nil { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + defer s.db.Exec(`UPDATE misty_login SET in_flight=0 WHERE binding_hash=? AND challenge_id=?`, hash, payload.ChallengeID) + var proof struct { + PersonID string `json:"person_id"` + Email string `json:"email"` + } + status, err := s.misty.request(r.Context(), "POST", "/v1/auth/email/confirm", map[string]string{"challenge_id": payload.ChallengeID, "code": payload.Code, "client_ip": ip}, &proof) + if err != nil || (status != 200 && status != 400 && status != 429) { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + if status == 429 { + // A definitive quota rejection is not a wrong code. Ambiguous transport + // failures retain their reservation because verification may have happened. + _, _ = s.db.Exec(`UPDATE misty_login SET attempts=attempts-1 WHERE binding_hash=? AND challenge_id=?`, hash, payload.ChallengeID) + w.Header().Set("Retry-After", "60") + writeAuthError(w, 429, "Too many attempts. Please wait.", "TOO_MANY_REQUESTS") + return + } + if status != 200 { + writeAuthError(w, 400, "Invalid or expired code. Check the code or request a new one.", "INVALID_LOGIN") + return + } + if !mistyID.MatchString(proof.PersonID) || proof.Email != email { + writeAuthError(w, 503, "Sign-in is temporarily unavailable", "AUTH_UNAVAILABLE") + return + } + user, token, err := s.createMistySession(r, proof.PersonID, email, hash, payload.ChallengeID) + if err != nil { + writeAuthError(w, 403, "This compute account is unavailable. Contact an administrator.", "ACCOUNT_UNAVAILABLE") + return + } + secure := strings.HasPrefix(os.Getenv("MIST_SITE_ORIGIN"), "https:") || r.TLS != nil + setSessionCookies(w, token, 604800, secure) + writeJSON(w, 200, map[string]any{"user": user}) +} + +func (s *AuthService) createMistySession(r *http.Request, personID, email, binding, challenge string) (*Member, string, error) { + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + return nil, "", err + } + defer tx.Rollback() + var userID string + err = tx.QueryRow(`SELECT user_id FROM misty_identity WHERE person_id=?`, personID).Scan(&userID) + if errors.Is(err, sql.ErrNoRows) { + var count int + if err = tx.QueryRow(`SELECT count(*),COALESCE(min(id),'') FROM user WHERE lower(email)=?`, email).Scan(&count, &userID); err != nil { + return nil, "", err + } + if count > 1 { + return nil, "", errors.New("ambiguous existing account") + } + if count == 0 { + userID = "user-" + randomToken(12) + _, err = tx.Exec(`INSERT INTO user(id,name,email,role,emailVerified,createdAt,updatedAt) VALUES(?,?,?,'user',1,?,?)`, userID, email, email, time.Now().UTC().Format(time.RFC3339Nano), time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return nil, "", err + } + } + if _, err = tx.Exec(`INSERT INTO misty_identity(person_id,user_id) VALUES(?,?)`, personID, userID); err != nil { + return nil, "", err + } + } else if err != nil { + return nil, "", err + } + var user Member + if err = tx.QueryRow(`SELECT id,name,email,role FROM user WHERE id=? AND COALESCE(banned,0)=0`, userID).Scan(&user.ID, &user.Name, &user.Email, &user.Role); err != nil { + return nil, "", err + } + // The stable directory identity owns the existing compute ID, even after an + // email change. Conflicting local emails fail closed; roles never come from Misty. + var conflicts int + if err = tx.QueryRow(`SELECT count(*) FROM user WHERE lower(email)=? AND id<>?`, email, userID).Scan(&conflicts); err != nil { + return nil, "", err + } + if conflicts != 0 { + return nil, "", errors.New("conflicting existing account") + } + if _, err = tx.Exec(`UPDATE user SET email=?,emailVerified=1,updatedAt=? WHERE id=?`, email, time.Now().UTC().Format(time.RFC3339Nano), userID); err != nil { + return nil, "", err + } + user.Email = email + token := randomToken(24) + now := time.Now().UTC() + stamp := now.Format(time.RFC3339Nano) + if _, err = tx.Exec(`INSERT INTO session(id,token,userId,expiresAt,createdAt,updatedAt,ipAddress,userAgent,mistyPersonId) VALUES(?,?,?,?,?,?,?,?,?)`, "sess-"+randomToken(12), token, userID, now.Add(7*24*time.Hour).Format(time.RFC3339Nano), stamp, stamp, clientAddress(r), r.UserAgent(), personID); err != nil { + return nil, "", err + } + if old := extractSessionToken(r); old != "" { + if _, err = tx.Exec(`DELETE FROM session WHERE token=?`, old); err != nil { + return nil, "", err + } + } + if _, err = tx.Exec(`DELETE FROM misty_login WHERE binding_hash=? AND challenge_id=?`, binding, challenge); err != nil { + return nil, "", err + } + if err = tx.Commit(); err != nil { + return nil, "", err + } + return &user, token, nil +} diff --git a/src/misty_auth_test.go b/src/misty_auth_test.go new file mode 100644 index 0000000..04635c4 --- /dev/null +++ b/src/misty_auth_test.go @@ -0,0 +1,338 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +const testPersonID = "00000000-0000-4000-8000-000000000001" +const testChallengeID = "00000000-0000-4000-8000-000000000002" + +func newMistyTestAuth(t *testing.T) (*AuthService, *atomic.Bool, *atomic.Int32) { + t.Helper() + active, calls := &atomic.Bool{}, &atomic.Int32{} + active.Store(true) + gateway := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-API-Key") != "gw_test_private" { + t.Error("missing gateway credential") + } + switch r.URL.Path { + case "/v1/auth/email/start": + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["client_ip"] != "192.0.2.1" { + t.Error("client IP was not derived from socket") + } + writeJSON(w, 202, map[string]any{"challenge_id": testChallengeID, "expires_in": 600}) + case "/v1/auth/email/confirm": + calls.Add(1) + var body map[string]string + json.NewDecoder(r.Body).Decode(&body) + if body["code"] != "123456" || !active.Load() { + writeJSON(w, 400, map[string]string{"detail": "invalid"}) + return + } + writeJSON(w, 200, map[string]string{"person_id": testPersonID, "email": "member@example.com"}) + case "/v1/auth/people/" + testPersonID + "/status": + writeJSON(w, 200, map[string]bool{"active": active.Load()}) + default: + t.Error("unexpected gateway path", r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(gateway.Close) + t.Setenv("MIST_AUTH_MODE", "misty") + t.Setenv("MISTY_GATEWAY_URL", gateway.URL) + t.Setenv("MISTY_GATEWAY_API_KEY", "gw_test_private") + t.Setenv("MIST_SITE_ORIGIN", "https://compute.example.com") + s := newTestAuthService(t) + return s, active, calls +} + +func mistyRequest(s *AuthService, path string, body any, cookies []*http.Cookie) *httptest.ResponseRecorder { + data, _ := json.Marshal(body) + r := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(data)) + r.RemoteAddr = "192.0.2.1:1234" + r.Header.Set("Origin", "https://compute.example.com") + r.Header.Set("Content-Type", "application/json") + for _, c := range cookies { + r.AddCookie(c) + } + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + return w +} + +func TestMistyLoginPreservesUserIDAndRole(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + _, err := s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('existing-user','Member','MEMBER@example.com','admin',datetime('now'),datetime('now'))`) + if err != nil { + t.Fatal(err) + } + start := mistyRequest(s, "/auth/misty/start", map[string]string{"email": "member@example.com"}, nil) + if start.Code != 202 { + t.Fatalf("start: %d %s", start.Code, start.Body.String()) + } + confirm := mistyRequest(s, "/auth/misty/confirm", map[string]string{"code": "123456", "challenge_id": testChallengeID}, start.Result().Cookies()) + if confirm.Code != 200 { + t.Fatalf("confirm: %d %s", confirm.Code, confirm.Body.String()) + } + var response struct { + User Member `json:"user"` + } + json.Unmarshal(confirm.Body.Bytes(), &response) + if response.User.ID != "existing-user" || response.User.Role != "admin" { + t.Fatal("lost account identity", response.User) + } + for _, cookie := range confirm.Result().Cookies() { + if cookie.MaxAge > 0 && (!cookie.Secure || !cookie.HttpOnly) { + t.Error("insecure session cookie") + } + } + if strings.Contains(confirm.Body.String(), `"token"`) { + t.Error("session token exposed to JavaScript") + } + replay := mistyRequest(s, "/auth/misty/confirm", map[string]string{"code": "123456", "challenge_id": testChallengeID}, start.Result().Cookies()) + if replay.Code != 400 { + t.Fatalf("replay: %d", replay.Code) + } +} + +func TestMistyConfirmRequiresBrowserBinding(t *testing.T) { + s, _, calls := newMistyTestAuth(t) + result := mistyRequest(s, "/auth/misty/confirm", map[string]string{"code": "123456", "challenge_id": testChallengeID}, nil) + if result.Code != 400 || calls.Load() != 0 { + t.Fatal("unbound confirm reached gateway", result.Code, calls.Load()) + } +} + +func TestMistyLoginDisablesPasswordAndRequiresOrigin(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + if got := authRequest(s, "POST", "/auth/sign-in/email", map[string]string{"email": "x", "password": "x"}, ""); got.Code != 403 { + t.Fatalf("password bypass status: %d", got.Code) + } + if got := authRequest(s, "POST", "/auth/misty/start", map[string]string{"email": "member@example.com"}, ""); got.Code != 403 { + t.Fatalf("missing Origin: %d", got.Code) + } +} + +func startMistyTest(t *testing.T, s *AuthService) []*http.Cookie { + t.Helper() + r := mistyRequest(s, "/auth/misty/start", map[string]string{"email": "member@example.com"}, nil) + if r.Code != 202 { + t.Fatalf("start: %d %s", r.Code, r.Body.String()) + } + return r.Result().Cookies() +} + +func TestMistyAttemptBudgetAndConcurrentRedemption(t *testing.T) { + t.Run("attempt budget", func(t *testing.T) { + s, _, calls := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + for i := 0; i < 5; i++ { + if r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "000000"}, cookies); r.Code != 400 { + t.Fatal(r.Code) + } + } + if r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies); r.Code != 400 || calls.Load() != 5 { + t.Fatal("budget bypass", r.Code, calls.Load()) + } + }) + t.Run("concurrent redemption", func(t *testing.T) { + s, _, calls := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + var successes atomic.Int32 + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Go(func() { + r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if r.Code == 200 { + successes.Add(1) + } else if r.Code != 400 { + t.Errorf("unexpected status %d", r.Code) + } + }) + } + wg.Wait() + if successes.Load() != 1 || calls.Load() != 1 { + t.Fatal("multiple redemption", successes.Load(), calls.Load()) + } + }) +} + +func TestMistyLinkingFailsClosed(t *testing.T) { + for _, scenario := range []string{"banned", "ambiguous", "different identity"} { + t.Run(scenario, func(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + _, err := s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('existing','Member','member@example.com','admin',datetime('now'),datetime('now'))`) + if err != nil { + t.Fatal(err) + } + switch scenario { + case "banned": + _, err = s.db.Exec(`UPDATE user SET banned=1`) + case "ambiguous": + _, err = s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('other','Member','MEMBER@example.com','user',datetime('now'),datetime('now'))`) + case "different identity": + _, err = s.db.Exec(`INSERT INTO misty_identity VALUES('00000000-0000-4000-8000-000000000099','existing')`) + } + if err != nil { + t.Fatal(err) + } + cookies := startMistyTest(t, s) + r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if r.Code != 403 || len(r.Result().Cookies()) != 0 { + t.Fatal("linked unavailable account", r.Code) + } + var count int + if err = s.db.QueryRow(`SELECT count(*) FROM session`).Scan(&count); err != nil || count != 0 { + t.Fatal("session created", count, err) + } + }) + } +} + +func TestMistySessionStatusAndProvenance(t *testing.T) { + s, active, _ := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + confirm := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if confirm.Code != 200 { + t.Fatal(confirm.Code) + } + r := httptest.NewRequest("GET", "/session", nil) + for _, c := range confirm.Result().Cookies() { + r.AddCookie(c) + } + member, err := s.session(r) + if err != nil || member == nil || member.Role != "user" { + t.Fatal("new member session", member, err) + } + active.Store(false) + s.misty.statuses[testPersonID].until = time.Time{} + if member, err = s.session(r); err != nil || member != nil { + t.Fatal("inactive session accepted", member, err) + } + s.misty.statuses[testPersonID].until = time.Time{} + s.misty.baseURL = "http://127.0.0.1:1" + if member, err = s.session(r); err == nil || member != nil { + t.Fatal("outage did not fail closed") + } + if _, err = s.db.Exec(`UPDATE session SET mistyPersonId=NULL`); err != nil { + t.Fatal(err) + } + if member, err = s.session(r); err != nil || member != nil { + t.Fatal("legacy session bypass", member, err) + } +} + +func TestMistyValidationExpiryAndDatabaseFailure(t *testing.T) { + s, _, calls := newMistyTestAuth(t) + for _, body := range []map[string]string{{"email": "x"}, {"email": "member@example.com", "client_ip": "192.0.2.99"}} { + if r := mistyRequest(s, "/auth/misty/start", body, nil); r.Code != 400 { + t.Fatal("bad input accepted", r.Code) + } + } + r := httptest.NewRequest("POST", "/auth/misty/start", strings.NewReader(`{"email":"member@example.com"}`)) + r.Header.Set("Origin", "http://localhost:3000") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + if w.Code != 403 { + t.Fatal("legacy origin accepted", w.Code) + } + cookies := startMistyTest(t, s) + if _, err := s.db.Exec(`UPDATE misty_login SET expires_at=0`); err != nil { + t.Fatal(err) + } + if r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies); r.Code != 400 || calls.Load() != 0 { + t.Fatal("expired challenge attempted") + } + s.db.Close() + if r := mistyRequest(s, "/auth/misty/start", map[string]string{"email": "member@example.com"}, nil); r.Code != 503 { + t.Fatal("database failure admitted", r.Code) + } +} + +func TestMistyConfiguration(t *testing.T) { + for _, raw := range []string{"http://compute.example.com", "https://user:pass@example.com", "https://example.com/path", "https://example.com?secret=x"} { + if safeMistyURL(raw) { + t.Error("unsafe URL accepted", raw) + } + } + for _, raw := range []string{"https://compute.example.com", "http://127.0.0.1:3000", "http://[::1]:3000"} { + if !safeMistyURL(raw) { + t.Error("safe URL rejected", raw) + } + } + t.Setenv("MIST_AUTH_MODE", "typo") + if _, err := newMistyClient(); err == nil { + t.Fatal("unknown mode accepted") + } +} + +func TestMistyLegacyOwnershipSurvivesEmailChangeAndReassignment(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + _, err := s.db.Exec(`INSERT INTO user(id,name,email,role,createdAt,updatedAt) VALUES('legacy-user','Owner','old@example.com','admin',datetime('now'),datetime('now'))`) + if err != nil { + t.Fatal(err) + } + s.legacyEmail = "old@example.com" + if err = s.initMisty(); err != nil { + t.Fatal(err) + } + r := httptest.NewRequest("POST", "/auth/misty/confirm", nil) + owner, _, err := s.createMistySession(r, testPersonID, "old@example.com", "unused", "unused") + if err != nil { + t.Fatal(err) + } + owner, _, err = s.createMistySession(r, testPersonID, "new@example.com", "unused", "unused") + if err != nil { + t.Fatal(err) + } + other, _, err := s.createMistySession(r, "00000000-0000-4000-8000-000000000099", "old@example.com", "unused", "unused") + if err != nil { + t.Fatal(err) + } + a := &App{auth: s, executor: &KubernetesExecutor{owner: "legacy-owner"}} + for _, member := range []*Member{owner, other} { + request := r.WithContext(context.WithValue(r.Context(), memberKey{}, member)) + got := a.requestExecutor(request).owner + if (got == "legacy-owner") != (member.ID == "legacy-user") { + t.Fatalf("incorrect legacy ownership for %s", member.ID) + } + } + // Reboots and rollback to local mode retain the recorded owner. + t.Setenv("MIST_AUTH_MODE", "local") + s.legacyUserID = "" + if err = s.initMisty(); err != nil || s.legacyUserID != "legacy-user" { + t.Fatal("lost durable ownership", err) + } +} + +type mistyRoundTrip func(*http.Request) (*http.Response, error) + +func (f mistyRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestMistyQuotaResponseIsRetryableWithoutSpendingGuess(t *testing.T) { + s, _, _ := newMistyTestAuth(t) + cookies := startMistyTest(t, s) + s.misty.http.Transport = mistyRoundTrip(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader(`{}`)), Header: http.Header{}}, nil + }) + r := mistyRequest(s, "/auth/misty/confirm", map[string]string{"challenge_id": testChallengeID, "code": "123456"}, cookies) + if r.Code != 429 || r.Header().Get("Retry-After") == "" { + t.Fatal("quota not preserved", r.Code) + } + var attempts int + if err := s.db.QueryRow(`SELECT attempts FROM misty_login WHERE challenge_id=?`, testChallengeID).Scan(&attempts); err != nil || attempts != 0 { + t.Fatal("quota charged a guess", attempts, err) + } +} diff --git a/web-interface/src/api.ts b/web-interface/src/api.ts index ddfeda9..cc80dca 100644 --- a/web-interface/src/api.ts +++ b/web-interface/src/api.ts @@ -132,7 +132,11 @@ async function request(path: string, options?: RequestInit): Promise { export const jobsAPI = { session: (signal?: AbortSignal) => - request<{ enabled: boolean; user: Member | null }>('/session', { signal }), + request<{ + enabled: boolean + user: Member | null + auth_mode?: 'local' | 'misty' + }>('/session', { signal }), list: (signal?: AbortSignal) => request<{ jobs: Job[]; count: number }>('/jobs', { signal }), hardware: (signal?: AbortSignal) => diff --git a/web-interface/src/auth.tsx b/web-interface/src/auth.tsx index 546b417..470a4e0 100644 --- a/web-interface/src/auth.tsx +++ b/web-interface/src/auth.tsx @@ -5,6 +5,7 @@ import { adminClient } from 'better-auth/client/plugins' import { jobsAPI } from '#/api.ts' import { errorMessage, usePolling } from '#/hooks/usePolling.ts' import { BackendConnectionError } from '#/components/BackendConnectionError.tsx' +import { MistyLogin } from '#/components/MistyLogin.tsx' export const authClient = createAuthClient({ basePath: '/auth', @@ -19,8 +20,9 @@ export type Member = { const AccountContext = createContext<{ user: Member | null enabled: boolean + authMode: 'local' | 'misty' refresh: () => void -}>({ user: null, enabled: false, refresh: () => {} }) +}>({ user: null, enabled: false, authMode: 'local', refresh: () => {} }) export const useAccount = () => useContext(AccountContext) function Login({ refresh }: { refresh: () => void }) { @@ -115,12 +117,17 @@ export function AccountGate({ children }: { children: ReactNode }) { /> ) if (session.data?.enabled && !session.data.user) - return + return session.data.auth_mode === 'misty' ? ( + + ) : ( + + ) return ( diff --git a/web-interface/src/components/AccountPage.tsx b/web-interface/src/components/AccountPage.tsx index 466cdd8..d874d05 100644 --- a/web-interface/src/components/AccountPage.tsx +++ b/web-interface/src/components/AccountPage.tsx @@ -92,6 +92,7 @@ async function listMembers() { return result.data } function Members() { + const misty = useAccount().authMode === 'misty' const members = usePolling(listMembers, 30000) const [name, setName] = useState(''), [email, setEmail] = useState(''), @@ -124,17 +125,23 @@ function Members() {
} action={ - + !misty && ( + + ) } /> {creating && ( @@ -245,14 +252,16 @@ function Members() { {member.role === 'admin' ? 'Admin' : 'Member'}
- + {!misty && ( + + )}
))} @@ -291,14 +300,16 @@ function Members() { )} - + {!misty && ( + + )} {resetUser !== null && ( setResetUser(null)}>