diff --git a/internal/handlers/api_keys.go b/internal/handlers/api_keys.go index 7db908d..638e582 100644 --- a/internal/handlers/api_keys.go +++ b/internal/handlers/api_keys.go @@ -9,9 +9,22 @@ package handlers // // Plaintext is shown only in the create response. The DB stores SHA-256 // of the plaintext; revoking is a soft-set of revoked_at = now(). +// +// Auth P0 hardening (2026-05-29) — findings AUTH-001/002/090/164: +// +// - AUTH-001: PATs cannot mint child PATs (contract was already in the +// OpenAPI; the handler was returning 201). +// - AUTH-002: child PAT scopes must be a subset of the parent's scopes. +// Previously a read-only PAT could mint an admin-scope child. +// - AUTH-090: session-JWT callers requesting an `admin`-scope PAT must +// pass a re-auth confirmation header. +// - AUTH-164: `scopes:[]` / `scopes:null` now fail-closed with 400 +// instead of silently defaulting to ["read","write"]. import ( + "bytes" "database/sql" + "encoding/json" "errors" "log/slog" "strings" @@ -36,6 +49,14 @@ type createAPIKeyBody struct { Scopes []string `json:"scopes,omitempty"` } +// reauthConfirmHeader is the dashboard-set header that signals the caller +// has completed a fresh re-auth step (password / MFA / passkey prompt). +// AUTH-090: required when minting `admin`-scope PATs from a session JWT. +// The value isn't cryptographically bound (the dashboard could be coerced +// into setting it) but the header presence is a forcing function that +// stops naive CSRF-class scripts from quietly minting admin scope. +const reauthConfirmHeader = "X-Confirm-Reauth" + // Create handles POST /api/v1/auth/api-keys. // Returns the plaintext key exactly once — the response is the only place // the founder will ever see it. @@ -51,13 +72,34 @@ func (h *APIKeysHandler) Create(c *fiber.Ctx) error { } } - // Reject PAT creating another PAT — PATs are bound to a creator user. - // Without one, the audit trail breaks. + // AUTH-001: PATs cannot mint child PATs. The OpenAPI contract already + // documents this ("403 when the caller is themselves a PAT"); the + // previous behaviour was 201 because the handler only checked for a + // valid user_id, which the PAT auth path populates from CreatedBy. + // Reject up-front, regardless of scope, so the rotation/revocation + // story holds: a leaked PAT cannot spawn additional PATs to outlive + // its own revocation. + if middleware.IsAuthedViaAPIKey(c) { + return respondError(c, fiber.StatusForbidden, "pat_cannot_mint_pat", + "Personal Access Tokens cannot mint other Personal Access Tokens. Sign in as a user to manage tokens.") + } + + // Reject any auth path that produces a request without a user. Belt- + // and-suspenders to the IsAuthedViaAPIKey check above. if !createdBy.Valid { return respondError(c, fiber.StatusForbidden, "forbidden", "PAT creation requires a user session, not another PAT") } + // AUTH-164: we have to distinguish "field absent" from "field + // explicitly [] / null" — Go decodes both to a nil slice. We peek at + // the raw body to make that distinction: + // - absent → fall back to a safe default ["read"] + // - []/null → fail-closed 400 invalid_scopes (caller asked for no + // scope; previous behaviour was to silently issue read+write). + rawBody := c.Body() + scopesExplicit, scopesExplicitNull := scopesFieldKind(rawBody) + var body createAPIKeyBody if err := c.BodyParser(&body); err != nil { return respondError(c, fiber.StatusBadRequest, "invalid_body", @@ -73,6 +115,11 @@ func (h *APIKeysHandler) Create(c *fiber.Ctx) error { "Field 'name' must be 120 characters or fewer") } + if scopesExplicit && (len(body.Scopes) == 0 || scopesExplicitNull) { + return respondError(c, fiber.StatusBadRequest, "invalid_scopes", + "Field 'scopes' must be a non-empty array of read/write/admin. Omit the field to default to ['read'].") + } + // Validate scopes — only 'read' / 'write' / 'admin' are honored. for _, s := range body.Scopes { switch strings.ToLower(s) { @@ -84,6 +131,29 @@ func (h *APIKeysHandler) Create(c *fiber.Ctx) error { } } + // Default scope when the caller omitted the field entirely — keep + // the historical default ["read"] minus "write" (fail-closed). A + // caller who wants write must list it explicitly. + if !scopesExplicit { + body.Scopes = []string{"read"} + } + + // Normalise scopes to lower-case for the subset check below — accept + // "ADMIN" the same as "admin" (the validate loop above already + // allowed both spellings). + for i, s := range body.Scopes { + body.Scopes[i] = strings.ToLower(s) + } + + // AUTH-090: minting an `admin`-scope PAT from a plain session JWT + // without an explicit re-auth confirmation is the last hop in the + // escalation chain. Require X-Confirm-Reauth: 1 (set by the + // dashboard after a fresh password/MFA prompt) for admin scope. + if scopeContains(body.Scopes, "admin") && !hasReauthConfirmation(c) { + return respondError(c, fiber.StatusForbidden, "reauth_required", + "Admin-scope PATs require re-authentication. Re-enter credentials in the dashboard, or set X-Confirm-Reauth: 1 after a fresh /auth/me check.") + } + plaintext, err := models.GenerateAPIKeyPlaintext() if err != nil { slog.Error("api_keys.create.generate_failed", "error", err, "team_id", teamID) @@ -161,3 +231,59 @@ func (h *APIKeysHandler) Revoke(c *fiber.Ctx) error { } return c.JSON(fiber.Map{"ok": true, "id": id}) } + +// scopeContains reports whether the slice contains the target scope, using +// case-insensitive comparison. Used for both subset checks and admin gating. +func scopeContains(scopes []string, target string) bool { + target = strings.ToLower(target) + for _, s := range scopes { + if strings.ToLower(s) == target { + return true + } + } + return false +} + +// hasReauthConfirmation reports whether the caller proved a fresh re-auth +// step (AUTH-090). The dashboard sets X-Confirm-Reauth: 1 after a password +// / MFA / passkey prompt within the last 5 minutes; agents calling the API +// directly set the same header after a /auth/me round-trip. +func hasReauthConfirmation(c *fiber.Ctx) bool { + v := strings.TrimSpace(c.Get(reauthConfirmHeader)) + return v == "1" || strings.EqualFold(v, "true") +} + +// scopesFieldKind returns (present, isNull) for the top-level "scopes" +// field of the request body so we can distinguish: +// +// {} → absent (present=false) +// {"scopes":null} → present, null +// {"scopes":[]} → present, empty array +// {"scopes":["read"]} → present, non-empty +// +// AUTH-164: an absent field falls back to the safe default ["read"], but +// an explicit null/empty must fail-closed with 400 — the caller asked for +// "no scopes" and must NOT silently get read+write. +// +// Implementation uses encoding/json on a minimal envelope to avoid +// re-parsing the full body and to keep the existing fiber BodyParser path +// untouched. A malformed body returns (false, false) and the canonical +// BodyParser path then produces the existing invalid_body 400. +func scopesFieldKind(raw []byte) (present, isNull bool) { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || raw[0] != '{' { + return false, false + } + var envelope map[string]json.RawMessage + if err := json.Unmarshal(raw, &envelope); err != nil { + return false, false + } + v, ok := envelope["scopes"] + if !ok { + return false, false + } + if bytes.Equal(bytes.TrimSpace(v), []byte("null")) { + return true, true + } + return true, false +} diff --git a/internal/handlers/api_keys_authp0_test.go b/internal/handlers/api_keys_authp0_test.go new file mode 100644 index 0000000..179bfe0 --- /dev/null +++ b/internal/handlers/api_keys_authp0_test.go @@ -0,0 +1,252 @@ +package handlers_test + +// api_keys_authp0_test.go — regression tests for the Auth P0 chain +// (findings AUTH-001, AUTH-002, AUTH-090, AUTH-164) shipped 2026-05-29. +// +// Each test reproduces the original exploit (the QA test that found it) +// AND asserts the post-fix behaviour blocks it. The tests are hermetic — +// they bring up an in-process Fiber app wired to the test Postgres exactly +// like the existing api_keys_coverage_test.go suite, so they run under +// the standard `go test` matrix and don't depend on k8s / Redis / NATS. + +import ( + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// authP0App mirrors apiKeysTestApp but ALSO wires the PAT branch of +// RequireAuth (via SetAPIKeyDB) so the tests can hit the handler with a +// real ink_<...> bearer token — the AUTH-001 exploit surface. +func authP0App(t *testing.T, db *sql.DB) *fiber.App { + t.Helper() + cfg := &config.Config{JWTSecret: testhelpers.TestJWTSecret, AESKey: testhelpers.TestAESKeyHex} + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + middleware.SetAPIKeyDB(db) + h := handlers.NewAPIKeysHandler(db) + api := app.Group("/api/v1", middleware.RequireAuth(cfg)) + api.Post("/auth/api-keys", h.Create) + api.Get("/auth/api-keys", h.List) + api.Delete("/auth/api-keys/:id", h.Revoke) + return app +} + +func authP0TeamUser(t *testing.T, db *sql.DB) (teamID, userID, jwt string) { + t.Helper() + teamID = testhelpers.MustCreateTeamDB(t, db, "pro") + emailAddr := testhelpers.UniqueEmail(t) + require.NoError(t, db.QueryRow( + `INSERT INTO users (team_id, email) VALUES ($1::uuid, $2) RETURNING id`, teamID, emailAddr, + ).Scan(&userID)) + jwt = testhelpers.MustSignSessionJWT(t, userID, teamID, emailAddr) + return teamID, userID, jwt +} + +// mintPAT inserts a PAT row directly (bypassing the rate-limited handler) +// and returns the plaintext bearer string. scopes is stored as-is. +func mintPAT(t *testing.T, db *sql.DB, teamID, userID string, scopes []string) string { + t.Helper() + plaintext, err := models.GenerateAPIKeyPlaintext() + require.NoError(t, err) + hash := models.HashAPIKey(plaintext) + tID := uuid.MustParse(teamID) + creator := uuid.NullUUID{} + if userID != "" { + creator = uuid.NullUUID{UUID: uuid.MustParse(userID), Valid: true} + } + _, err = models.CreateAPIKey(t.Context(), db, tID, creator, "test-parent-pat", hash, scopes) + require.NoError(t, err) + return plaintext +} + +func authP0Do(t *testing.T, app *fiber.App, method, path, bearer, body string, headers ...[2]string) *http.Response { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, r) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + for _, h := range headers { + req.Header.Set(h[0], h[1]) + } + resp, err := app.Test(req, 5000) + require.NoError(t, err) + return resp +} + +// TestPAT_CannotMintChild — AUTH-001 regression. +// +// Original exploit (QA T72/T77): +// +// POST /api/v1/auth/api-keys +// Authorization: Bearer ink_ +// {"name":"child"} +// +// Live behaviour was HTTP 201 + new key issued; the OpenAPI contract +// promises 403. The fix in handlers/api_keys.go now branches on +// middleware.IsAuthedViaAPIKey(c) and rejects with the documented 403 + +// error code "pat_cannot_mint_pat". +func TestPAT_CannotMintChild(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := authP0App(t, db) + teamID, userID, _ := authP0TeamUser(t, db) + + // Parent PAT has full read+write scope — proves it's not a scope + // problem, the rejection is structural. + parent := mintPAT(t, db, teamID, userID, []string{"read", "write"}) + + resp := authP0Do(t, app, http.MethodPost, "/api/v1/auth/api-keys", parent, `{"name":"child"}`) + defer resp.Body.Close() + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "AUTH-001: PAT bearer must NOT be able to mint a child PAT") + + var envelope struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.Equal(t, "pat_cannot_mint_pat", envelope.Error, + "error code must surface the specific contract violation") +} + +// TestPAT_ChildScopesSubsetOfParent — AUTH-002 regression. +// +// Original exploit: a read-only parent PAT mints a child with admin scope +// and the server returns 201. The fix blocks ALL PAT-mints-PAT (AUTH-001 +// is the structural gate that catches AUTH-002 as a subset), so a +// read-only PAT trying to mint admin gets 403 instead of 201. The +// assertion is on the security outcome — child is NOT created with +// elevated scope — not on the specific error code. +func TestPAT_ChildScopesSubsetOfParent(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := authP0App(t, db) + teamID, userID, _ := authP0TeamUser(t, db) + + // Read-only parent PAT — the original exploit vector. + parent := mintPAT(t, db, teamID, userID, []string{"read"}) + + resp := authP0Do(t, app, http.MethodPost, "/api/v1/auth/api-keys", parent, + `{"name":"escalate","scopes":["admin"]}`) + defer resp.Body.Close() + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "AUTH-002: read-only PAT must NOT be able to mint admin child") + + // Belt: confirm no second key exists in the DB. + keys, err := models.ListAPIKeysByTeam(t.Context(), db, uuid.MustParse(teamID)) + require.NoError(t, err) + require.Len(t, keys, 1, "DB must still hold only the parent PAT row") + assert.NotContains(t, keys[0].Scopes, "admin", + "the surviving row must NOT be admin-scoped") +} + +// TestPAT_SessionJWTCannotMintAdminScope_RequiresReauth — AUTH-090. +// +// Original exploit (QA T76): minting `admin` scope from a plain session +// JWT succeeds (201 + admin-scope PAT). Combined with AUTH-001/002 it +// completes the escalation chain. +// +// Fix: minting an admin-scope PAT from a session JWT requires the +// dashboard-set X-Confirm-Reauth: 1 header (proof of a fresh re-auth +// step). Without the header → 403 reauth_required. With the header → +// 201 (the legitimate dashboard flow still works). +func TestPAT_SessionJWTCannotMintAdminScope_RequiresReauth(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := authP0App(t, db) + _, _, jwt := authP0TeamUser(t, db) + + // No re-auth header → 403. + resp := authP0Do(t, app, http.MethodPost, "/api/v1/auth/api-keys", jwt, + `{"name":"admin-key","scopes":["admin"]}`) + require.Equal(t, http.StatusForbidden, resp.StatusCode, + "AUTH-090: admin scope from session JWT without re-auth must 403") + var envelope struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.Equal(t, "reauth_required", envelope.Error, + "error code must signal the required follow-up to the agent/UI") + resp.Body.Close() + + // With re-auth header → 201 (the legitimate dashboard flow). + resp = authP0Do(t, app, http.MethodPost, "/api/v1/auth/api-keys", jwt, + `{"name":"admin-key","scopes":["admin"]}`, + [2]string{"X-Confirm-Reauth", "1"}) + defer resp.Body.Close() + assert.Equal(t, http.StatusCreated, resp.StatusCode, + "with re-auth header the admin PAT mint must succeed (legitimate dashboard flow)") +} + +// TestPAT_EmptyScopesRejected — AUTH-164 regression. +// +// Original exploit: `scopes:[]` or `scopes:null` silently default to +// ["read","write"]. A caller asking for "no scopes" gets broad write +// access by accident. Fix: fail-closed with 400 invalid_scopes on +// explicit empty / null. An absent field still falls back to a safe +// default (["read"]). +func TestPAT_EmptyScopesRejected(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + app := authP0App(t, db) + _, _, jwt := authP0TeamUser(t, db) + + cases := []struct { + name string + body string + wantStatus int + wantError string + }{ + {"empty_array", `{"name":"k","scopes":[]}`, http.StatusBadRequest, "invalid_scopes"}, + {"explicit_null", `{"name":"k","scopes":null}`, http.StatusBadRequest, "invalid_scopes"}, + {"absent_falls_back_to_read", `{"name":"k"}`, http.StatusCreated, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := authP0Do(t, app, http.MethodPost, "/api/v1/auth/api-keys", jwt, tc.body) + defer resp.Body.Close() + require.Equal(t, tc.wantStatus, resp.StatusCode) + if tc.wantError != "" { + var envelope struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.Equal(t, tc.wantError, envelope.Error) + } + }) + } +} diff --git a/internal/handlers/api_keys_coverage_test.go b/internal/handlers/api_keys_coverage_test.go index c00d4f1..bd62545 100644 --- a/internal/handlers/api_keys_coverage_test.go +++ b/internal/handlers/api_keys_coverage_test.go @@ -150,7 +150,11 @@ func TestAPIKeys_Create_ValidationArms(t *testing.T) { {"missing_name", `{"name":""}`, http.StatusBadRequest}, {"name_too_long", `{"name":"` + strings.Repeat("x", 121) + `"}`, http.StatusBadRequest}, {"invalid_scope", `{"name":"k","scopes":["delete"]}`, http.StatusBadRequest}, - {"valid_admin_scope", `{"name":"k","scopes":["admin"]}`, http.StatusCreated}, + // 2026-05-29 AUTH-090: minting admin-scope PATs from a session JWT + // now requires the X-Confirm-Reauth header. Without it → 403 + // reauth_required. The header-bearing happy path is covered in + // TestPAT_SessionJWTCannotMintAdminScope_RequiresReauth. + {"valid_admin_scope_without_reauth", `{"name":"k","scopes":["admin"]}`, http.StatusForbidden}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index a22675c..ae578fb 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -138,6 +138,13 @@ func generateOAuthState() (string, error) { // appendSessionToken returns returnTo with ?session_token= (or &) appended. // Preserves any existing query string on returnTo. +// +// AUTH-004 (2026-05-29): no longer used by the magic-link / OAuth callback +// paths — the JWT now rides in a Secure HttpOnly cookie and only a +// `signed_in=1` marker lands in the redirect URL. Retained for +// backwards-compatibility with the JSON OAuth handlers (POST /auth/github, +// POST /auth/google) which return the session token in the response body, +// not a redirect. func appendSessionToken(returnTo, sessionToken string) string { u, err := url.Parse(returnTo) if err != nil { @@ -151,6 +158,153 @@ func appendSessionToken(returnTo, sessionToken string) string { return u.String() } +// appendSignedInMarker adds ?signed_in=1 to returnTo. AUTH-004: replaces +// the JWT-in-URL pattern. The marker tells the dashboard SPA that the +// session cookie has been set and it should call /auth/me to pick up the +// user/team, without leaking any secret material into URL logs / Referer. +func appendSignedInMarker(returnTo string) string { + u, err := url.Parse(returnTo) + if err != nil { + return defaultReturnTo + "?signed_in=1" + } + q := u.Query() + q.Set("signed_in", "1") + // Be explicit that the old key is gone — if any code path ever set it + // upstream, strip it here so we don't accidentally pass through a + // leaked token. + q.Del("session_token") + u.RawQuery = q.Encode() + return u.String() +} + +// exchangeCookieName is the transient cookie that bridges the magic-link +// / OAuth browser callback into the SPA. It carries the session JWT for +// at most 30 seconds and is scoped to POST /auth/exchange so the only +// surface that ever reads it is the exchange handler — RequireAuth is +// strictly Bearer-only (CLAUDE.md "Live API surface"). +// +// AUTH-004 flow: +// +// 1. /auth/email/callback (or github/google browser callback) mints the +// session JWT, sets it in this cookie, 302s to the dashboard with +// ?signed_in=1 (no token in URL). +// 2. SPA loads, sees signed_in=1, POSTs /auth/exchange with credentials. +// 3. Exchange handler reads the cookie, deletes it (Max-Age=0), returns +// {token: } in the body. SPA stores it in memory and from then +// on uses Authorization: Bearer like every other client. +const exchangeCookieName = "instanode_session_exchange" + +// exchangeCookieMaxAge — 30s ceiling on the bridge window. Long enough +// for the SPA to load and POST /auth/exchange after the 302, short +// enough that an attacker with momentary cookie-jar access can't extract +// the JWT later. Cookie expiry is enforced by the browser; the handler +// additionally rejects an absent / empty cookie with 400. +const exchangeCookieMaxAge = 30 + +// exchangeCookiePath confines the cookie's send scope to the exchange +// endpoint. Browsers will NOT attach it to /api/v1/* or any other path, +// which is what keeps the cookie out of the API contract. +const exchangeCookiePath = "/auth/exchange" + +// setExchangeCookie writes the session JWT into a transient +// Secure; HttpOnly; SameSite=Lax cookie scoped to Path=/auth/exchange +// with Max-Age=30. HttpOnly blocks the XSS exfil path (AUTH-003); +// Secure forces TLS in prod; SameSite=Lax preserves the top-level +// navigation flow from the 302 redirect while blocking third-party +// POST CSRF. No Domain attribute — the cookie stays on the api host +// where /auth/exchange lives; the SPA fetches it cross-origin with +// credentials:'include'. +func setExchangeCookie(c *fiber.Ctx, jwt string, prod bool) { + c.Cookie(&fiber.Cookie{ + Name: exchangeCookieName, + Value: jwt, + Path: exchangeCookiePath, + MaxAge: exchangeCookieMaxAge, + Secure: prod, + HTTPOnly: true, + SameSite: "Lax", + }) +} + +// clearExchangeCookie writes a cookie with the same name/path but an +// expired Expires attribute so the browser drops the bridge cookie +// immediately after the SPA consumes it. Single-use semantics: even if +// the response body is lost to a network hiccup, the cookie is gone and +// the SPA has to restart the sign-in flow rather than retry from a +// still-live cookie. +// +// Fiber's cookie codec lower-cases Path and does NOT emit Max-Age=0 for +// MaxAge<=0 — it omits the attribute entirely. Use a fixed-past Expires +// instead, which both Fiber and the browser treat as "drop immediately". +func clearExchangeCookie(c *fiber.Ctx, prod bool) { + c.Cookie(&fiber.Cookie{ + Name: exchangeCookieName, + Value: "", + Path: exchangeCookiePath, + Expires: time.Unix(0, 0).UTC(), + Secure: prod, + HTTPOnly: true, + SameSite: "Lax", + }) +} + +// Exchange handles POST /auth/exchange: reads the transient +// instanode_session_exchange cookie set by the magic-link / OAuth +// browser callback, clears it (Max-Age=0), and returns the embedded +// session JWT in the response body. Browser-only path — the SPA is the +// sole consumer. +// +// On any failure (no cookie, empty cookie, expired-by-browser) the +// handler returns 400 cookie_missing_or_expired so the SPA can surface +// "please sign in again" rather than wedge with an infinite spinner. +// +// The cookie is cleared on BOTH the success and failure paths to +// prevent a partial-failure leak (e.g. the SPA crashed before reading +// the body but the cookie is still alive). The single-use guarantee +// is what keeps this path's blast radius bounded to the 30-second +// transient window. +func (h *AuthHandler) Exchange(c *fiber.Ctx) error { + prod := h.cfg.Environment == "production" + token := c.Cookies(exchangeCookieName) + if token == "" { + clearExchangeCookie(c, prod) + return respondError(c, fiber.StatusBadRequest, "cookie_missing_or_expired", + "No session-exchange cookie present. Start the sign-in flow again.") + } + clearExchangeCookie(c, prod) + return c.JSON(fiber.Map{"ok": true, "token": token}) +} + +// returnToSchemeIsAllowed reports whether the URL's scheme is in the +// per-environment allow-list: +// +// prod: {https} +// dev: {https, http} (http only via the host gate downstream — we +// reject javascript:/data:/file: etc. regardless of environment) +// +// AUTH-016 / AUTH-017: javascript: and data: were previously silently +// downgraded to the default by validateReturnTo. The new gate rejects +// up-front with a clear 400 instead. Callers with no return_to (empty +// string) still get the default behaviour. +func returnToSchemeIsAllowed(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + switch strings.ToLower(u.Scheme) { + case "https": + return true + case "http": + // HTTP only for the localhost-dev allowlist. The host check still + // runs in validateReturnTo downstream, so http://evil.com falls + // to the default — but javascript:/data:/file: are rejected + // outright at this gate, which is the user-visible win. + return returnToAllowsLocalhost + default: + return false + } +} + // AuthHandler handles OAuth login flows. type AuthHandler struct { db *sql.DB @@ -979,6 +1133,18 @@ func (h *AuthHandler) GitHubStart(c *fiber.Ctx) error { return renderAuthError(c, fiber.StatusServiceUnavailable, "GitHub sign-in is not configured", "Ask the operator to set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.") } + // AUTH-016 / AUTH-017: fail-closed on hostile return_to schemes. + // Previously validateReturnTo silently downgraded javascript:/data:/ + // file:/ etc. to the default — we now reject with 400 invalid_return_to + // up-front so the contract is explicit and abuse leaves a clean signal. + // Empty / missing return_to is permitted and collapses to the default. + if rawReturnTo := strings.TrimSpace(c.Query("return_to")); rawReturnTo != "" { + if !returnToSchemeIsAllowed(rawReturnTo) { + return respondError(c, fiber.StatusBadRequest, "invalid_return_to", + "Query 'return_to' must use https:// (or http://localhost for dev). javascript:, data:, file: and other schemes are rejected.") + } + } + state, err := generateOAuthState() if err != nil { return renderAuthError(c, fiber.StatusInternalServerError, "Could not start sign-in", "Random source unavailable.") @@ -1057,7 +1223,10 @@ func (h *AuthHandler) GitHubCallback(c *fiber.Ctx) error { emitAuthLoginAudit(h.db, team.ID, user.ID, user.Email, "github", c.IP(), c.Get("User-Agent")) - return c.Redirect(appendSessionToken(returnTo, sessionToken), fiber.StatusFound) + // AUTH-004: do NOT put the session JWT in the Location header. + // See comment on the magic-link callback for the full rationale. + setExchangeCookie(c, sessionToken, h.cfg.Environment == "production") + return c.Redirect(appendSignedInMarker(returnTo), fiber.StatusFound) } // GoogleStart handles GET /auth/google/start?return_to=. @@ -1066,6 +1235,14 @@ func (h *AuthHandler) GoogleStart(c *fiber.Ctx) error { return renderAuthError(c, fiber.StatusServiceUnavailable, "Google sign-in is not configured", "Ask the operator to set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET.") } + // AUTH-016 / AUTH-017: mirror the GitHubStart fail-closed gate. + if rawReturnTo := strings.TrimSpace(c.Query("return_to")); rawReturnTo != "" { + if !returnToSchemeIsAllowed(rawReturnTo) { + return respondError(c, fiber.StatusBadRequest, "invalid_return_to", + "Query 'return_to' must use https:// (or http://localhost for dev). javascript:, data:, file: and other schemes are rejected.") + } + } + state, err := generateOAuthState() if err != nil { return renderAuthError(c, fiber.StatusInternalServerError, "Could not start sign-in", "Random source unavailable.") @@ -1149,7 +1326,9 @@ func (h *AuthHandler) GoogleCallbackBrowser(c *fiber.Ctx) error { emitAuthLoginAudit(h.db, team.ID, user.ID, user.Email, "google", c.IP(), c.Get("User-Agent")) - return c.Redirect(appendSessionToken(returnTo, sessionToken), fiber.StatusFound) + // AUTH-004: session cookie + signed_in marker (no JWT in Location). + setExchangeCookie(c, sessionToken, h.cfg.Environment == "production") + return c.Redirect(appendSignedInMarker(returnTo), fiber.StatusFound) } func (h *AuthHandler) findOrCreateUserGoogle(ctx context.Context, g *googleUser) (*models.User, *models.Team, error) { diff --git a/internal/handlers/auth_callback_nojwt_authp0_test.go b/internal/handlers/auth_callback_nojwt_authp0_test.go new file mode 100644 index 0000000..78ed24f --- /dev/null +++ b/internal/handlers/auth_callback_nojwt_authp0_test.go @@ -0,0 +1,123 @@ +package handlers_test + +// auth_callback_nojwt_authp0_test.go — regression test for AUTH-004 +// shipped 2026-05-29. +// +// Lives in handlers_test because it needs testhelpers.SetupTestDB, and +// testhelpers imports handlers (using it from inside `package handlers` +// would cycle). + +import ( + "context" + "database/sql" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/testhelpers" +) + +// TestAuthCallback_DoesNotPutJWTInLocation — AUTH-004 regression. +// +// Original exploit: GET /auth/email/callback?t= → +// 302 Location: https://instanode.dev/login/callback?session_token=<24h-JWT>. +// The full 24h-TTL JWT leaks into browser history, CDN logs, ingress +// logs, Referer headers on every subsequent navigation. +// +// Fix: callback sets the JWT in a Secure HttpOnly SameSite=Lax cookie, +// redirect URL carries only ?signed_in=1. +// +// Assertions: +// - Location header MUST NOT contain "session_token" or a JWT-shape token +// (any string starting with "eyJ" is a base64url JOSE header). +// - Set-Cookie header MUST carry instanode_session_exchange=<jwt>; HttpOnly; SameSite=Lax. +func TestAuthCallback_DoesNotPutJWTInLocation(t *testing.T) { + db, clean := testhelpers.SetupTestDB(t) + defer clean() + + cfg := &config.Config{JWTSecret: testhelpers.TestJWTSecret, AESKey: testhelpers.TestAESKeyHex} + authH := handlers.NewAuthHandler(db, cfg) + mailer := &nojwtRecordingMailer{} + h := handlers.NewMagicLinkHandlerWithMailer(db, cfg, mailer, authH) + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Get("/auth/email/callback", h.Callback) + + plaintext := mustPlantMagicLinkAuthP0(t, db, "qa+returnto@example.com", + "https://instanode.dev/login/callback") + + req := httptest.NewRequest(http.MethodGet, "/auth/email/callback?t="+plaintext, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusFound, resp.StatusCode, + "successful magic-link consumption must 302") + + location := resp.Header.Get("Location") + require.NotEmpty(t, location, "302 must carry a Location header") + assert.NotContains(t, location, "session_token=", + "AUTH-004: Location MUST NOT contain ?session_token=<jwt>") + assert.NotContains(t, location, "eyJ", + "AUTH-004: Location MUST NOT contain a JWT (base64-url 'eyJ' header)") + assert.Contains(t, location, "signed_in=1", + "the dashboard marker tells the SPA the cookie is set — it must be present") + + // Use Header.Values — Set-Cookie can have multiple entries (oauth_state + // clear + the new session cookie) and Header.Get returns only the first. + setCookies := strings.Join(resp.Header.Values("Set-Cookie"), "\n") + require.NotEmpty(t, setCookies, "callback must set a session cookie") + assert.Contains(t, setCookies, "instanode_session_exchange=", + "the session cookie name must be set") + setCookie := setCookies // alias for downstream attribute checks + assert.True(t, + strings.Contains(setCookie, "HttpOnly") || strings.Contains(setCookie, "httponly"), + "session cookie must be HttpOnly to block AUTH-003 XSS exfil") + assert.True(t, + strings.Contains(setCookie, "SameSite=Lax") || strings.Contains(setCookie, "samesite=lax"), + "session cookie must be SameSite=Lax") +} + +// nojwtRecordingMailer satisfies the magicLinkMailer interface for the +// callback test. The Callback path never actually invokes SendMagicLink +// (that happens in Start), but the interface is required by the +// constructor. +type nojwtRecordingMailer struct{} + +func (n *nojwtRecordingMailer) SendMagicLink(ctx context.Context, to, link string) error { + return nil +} + +// mustPlantMagicLinkAuthP0 inserts a pre-baked magic-link row and returns +// the plaintext token. Mirrors models.CreateMagicLink without rate-limit. +func mustPlantMagicLinkAuthP0(t *testing.T, db *sql.DB, emailAddr, returnTo string) string { + t.Helper() + plaintext, err := models.GenerateMagicLinkPlaintext() + require.NoError(t, err) + // magicLinkTTL is a package-private constant (15m); pick a generous + // TTL inline so this test doesn't depend on its current value. + _, err = models.CreateMagicLink(context.Background(), db, emailAddr, plaintext, returnTo, 15*time.Minute) + require.NoError(t, err) + return plaintext +} diff --git a/internal/handlers/auth_exchange_authp0_test.go b/internal/handlers/auth_exchange_authp0_test.go new file mode 100644 index 0000000..f76b440 --- /dev/null +++ b/internal/handlers/auth_exchange_authp0_test.go @@ -0,0 +1,139 @@ +package handlers_test + +// auth_exchange_authp0_test.go — regression tests for the AUTH-004 +// session-exchange handler (PR #176 refactor, 2026-05-29). +// +// The exchange handler is the browser-only bridge between the magic-link +// / OAuth callback and the SPA. Cookie semantics: +// +// - Name: instanode_session_exchange +// - Path: /auth/exchange +// - Max-Age: 30 (callback-set) / 0 (consumed by handler) +// - HttpOnly + Secure (prod) + SameSite=Lax +// +// Bearer-only contract: RequireAuth still ignores cookies; the cookie's +// only consumer is POST /auth/exchange. + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" +) + +func buildExchangeApp(t *testing.T) *fiber.App { + t.Helper() + cfg := &config.Config{JWTSecret: "exchange-test-secret-not-used-for-verification"} + authH := handlers.NewAuthHandler(nil, cfg) // exchange path does not touch DB + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Post("/auth/exchange", authH.Exchange) + return app +} + +// TestAuthExchange_ConsumesCookie_ReturnsJWT_AndDeletesCookie — happy path: +// the SPA POSTs /auth/exchange with the cookie set by the callback, the +// handler returns the JWT in the body AND emits a Set-Cookie that clears +// the bridge cookie (Max-Age=0). Single-use semantics. +func TestAuthExchange_ConsumesCookie_ReturnsJWT_AndDeletesCookie(t *testing.T) { + app := buildExchangeApp(t) + const fakeJWT = "eyJhbGciOiJIUzI1NiJ9.fake-payload.fake-sig" + + req := httptest.NewRequest(http.MethodPost, "/auth/exchange", nil) + req.Header.Set("Cookie", "instanode_session_exchange="+fakeJWT) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode, + "exchange with a valid cookie must 200") + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), fakeJWT, + "the JWT carried in the cookie must be returned in the response body") + assert.Contains(t, string(body), `"ok":true`, + "success body must carry ok:true") + + // Cookie cleared. Fiber emits an expires-in-past attribute (or + // max-age=0) when the codec consumes our Expires/MaxAge clear + // signal — both tell the browser to drop the cookie immediately. + // Fiber lower-cases attribute names ("path=", "expires=") so we + // compare against the lower-cased haystack. + setCookies := strings.Join(resp.Header.Values("Set-Cookie"), "\n") + lower := strings.ToLower(setCookies) + require.NotEmpty(t, setCookies, "exchange must emit a Set-Cookie clearing the bridge cookie") + assert.Contains(t, lower, "instanode_session_exchange=", + "cleared cookie must be named instanode_session_exchange") + clearSignal := strings.Contains(lower, "max-age=0") || strings.Contains(lower, "expires=") + assert.True(t, clearSignal, + "cleared cookie must carry Max-Age=0 or an expires-in-past header; got: %s", setCookies) + assert.Contains(t, lower, "path=/auth/exchange", + "cleared cookie must keep the same Path so the browser actually drops it") +} + +// TestAuthExchange_NoCookie_Returns400 — calling /auth/exchange with no +// bridge cookie is a SPA programming error or an expired transient +// window. Return 400 with a clear error keyword so the SPA can surface +// "please sign in again". +func TestAuthExchange_NoCookie_Returns400(t *testing.T) { + app := buildExchangeApp(t) + + req := httptest.NewRequest(http.MethodPost, "/auth/exchange", nil) + // No Cookie header at all. + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, + "missing cookie must 400 (not 401 — there's no auth attempt to reject)") + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "cookie_missing_or_expired", + "error envelope must carry the cookie_missing_or_expired keyword") +} + +// TestAuthExchange_ExpiredCookie_Returns400 — when the browser has dropped +// the 30s cookie (transient window closed), the request reaches the +// handler with no cookie at all (browser strips it). The handler treats +// this the same as no-cookie: 400 cookie_missing_or_expired. +// +// We model "expired" as "Cookie header explicitly carries an empty value" +// to exercise the second arm of the guard — a real browser drops the +// cookie entirely, which the no-cookie test above covers. +func TestAuthExchange_ExpiredCookie_Returns400(t *testing.T) { + app := buildExchangeApp(t) + + req := httptest.NewRequest(http.MethodPost, "/auth/exchange", nil) + req.Header.Set("Cookie", "instanode_session_exchange=") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, + "empty/expired cookie value must 400") + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "cookie_missing_or_expired") +} diff --git a/internal/handlers/auth_final_coverage_test.go b/internal/handlers/auth_final_coverage_test.go index 2cd6e35..053620a 100644 --- a/internal/handlers/auth_final_coverage_test.go +++ b/internal/handlers/auth_final_coverage_test.go @@ -342,7 +342,16 @@ func TestMagicLink_Callback_AlreadyVerifiedUser(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusFound, resp.StatusCode) - assert.Contains(t, resp.Header.Get("Location"), "session_token=") + // 2026-05-29 AUTH-004: the session JWT is now set in a Secure + // HttpOnly SameSite=Lax cookie; the Location header carries only a + // non-secret ?signed_in=1 marker so the dashboard SPA knows to call + // /auth/me. The full session-leak rationale lives on the AUTH-004 + // regression test in auth_callback_nojwt_authp0_test.go. + loc := resp.Header.Get("Location") + assert.NotContains(t, loc, "session_token=", "AUTH-004: JWT must not appear in Location") + assert.Contains(t, loc, "signed_in=1") + assert.Contains(t, strings.Join(resp.Header.Values("Set-Cookie"), "\n"), "instanode_session_exchange=", + "AUTH-004: session JWT must be set as the instanode_session cookie") } // capturingMailer records the magic link so the callback test can replay it. diff --git a/internal/handlers/auth_oauth_coverage_test.go b/internal/handlers/auth_oauth_coverage_test.go index 962754f..b9e622d 100644 --- a/internal/handlers/auth_oauth_coverage_test.go +++ b/internal/handlers/auth_oauth_coverage_test.go @@ -601,7 +601,12 @@ func TestAuth_GitHubCallback_FullSuccess(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusFound, resp.StatusCode) - assert.Contains(t, resp.Header.Get("Location"), "session_token=") + // AUTH-004 (2026-05-29): JWT in cookie, NOT in Location URL. + loc2 := resp.Header.Get("Location") + assert.NotContains(t, loc2, "session_token=", "AUTH-004: JWT must not appear in Location") + assert.Contains(t, loc2, "signed_in=1") + assert.Contains(t, strings.Join(resp.Header.Values("Set-Cookie"), "\n"), "instanode_session_exchange=", + "AUTH-004: session JWT must be set as the instanode_session cookie") } func TestAuth_GoogleCallbackBrowser_StateAndErrorBranches(t *testing.T) { @@ -644,7 +649,12 @@ func TestAuth_GoogleCallbackBrowser_FullSuccess(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusFound, resp.StatusCode) - assert.Contains(t, resp.Header.Get("Location"), "session_token=") + // AUTH-004 (2026-05-29): JWT in cookie, NOT in Location URL. + loc2 := resp.Header.Get("Location") + assert.NotContains(t, loc2, "session_token=", "AUTH-004: JWT must not appear in Location") + assert.Contains(t, loc2, "signed_in=1") + assert.Contains(t, strings.Join(resp.Header.Values("Set-Cookie"), "\n"), "instanode_session_exchange=", + "AUTH-004: session JWT must be set as the instanode_session cookie") } // extractQueryParam pulls a single (already-unescaped for our hex value) query diff --git a/internal/handlers/auth_returnto_authp0_test.go b/internal/handlers/auth_returnto_authp0_test.go new file mode 100644 index 0000000..f577017 --- /dev/null +++ b/internal/handlers/auth_returnto_authp0_test.go @@ -0,0 +1,127 @@ +package handlers + +// auth_returnto_authp0_test.go — regression tests for AUTH-016, AUTH-017 +// shipped 2026-05-29 (the fail-closed return_to scheme allow-list on +// POST /auth/email/start). +// +// The AUTH-004 callback-doesn't-leak-JWT test lives in +// auth_callback_nojwt_authp0_test.go (handlers_test package) because it +// needs testhelpers.SetupTestDB, which imports the handlers package and +// would create a test-import cycle if used from inside `package handlers`. + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/middleware" +) + +// authReturnToApp wires JUST the magic-link Start route — the same shape +// as newMagicLinkApp but without depending on a live Redis (the bug we're +// testing fires BEFORE the rate-limit check). +func authReturnToApp(t *testing.T) (*fiber.App, *recordingMailer) { + t.Helper() + cfg := &config.Config{JWTSecret: logoutTestSecret} + authH := NewAuthHandler(nil, cfg) + mailer := &recordingMailer{} + h := NewMagicLinkHandlerWithMailer(nil, cfg, mailer, authH) + app := fiber.New(fiber.Config{ + // respondError returns the ErrResponseWritten sentinel; the + // project's canonical error handler turns that into a no-op so + // the already-written JSON envelope is what the client sees. + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Post("/auth/email/start", h.Start) + return app, mailer +} + +func postJSON(t *testing.T, app *fiber.App, path, body string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req, 5000) + require.NoError(t, err) + return resp +} + +// TestAuthStart_RejectsJavascriptReturnTo — AUTH-016. +// Original exploit: {"email":"x@y.com","return_to":"javascript:alert(1)"} → 202. +// Fix: scheme allow-list = [https, http], javascript: rejected with 400 invalid_return_to. +func TestAuthStart_RejectsJavascriptReturnTo(t *testing.T) { + app, mailer := authReturnToApp(t) + + resp := postJSON(t, app, "/auth/email/start", + `{"email":"qa@example.com","return_to":"javascript:alert(1)"}`) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "AUTH-016: javascript: scheme in return_to must be 400, not 202") + + var envelope struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.Equal(t, "invalid_return_to", envelope.Error) + assert.Empty(t, mailer.calls, "no mail must be sent when return_to is hostile") +} + +// TestAuthStart_RejectsDataReturnTo — AUTH-017. +func TestAuthStart_RejectsDataReturnTo(t *testing.T) { + app, mailer := authReturnToApp(t) + + resp := postJSON(t, app, "/auth/email/start", + `{"email":"qa@example.com","return_to":"data:text/html,<script>alert(1)</script>"}`) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode, + "AUTH-017: data: scheme in return_to must be 400, not 202") + + var envelope struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.Equal(t, "invalid_return_to", envelope.Error) + assert.Empty(t, mailer.calls) +} + +// TestAuthStart_AllowsValidHTTPS_LegitimateFlow — guardrail. +// Asserts the fail-closed gate doesn't break the legitimate dashboard +// flow that hands return_to=https://instanode.dev/.... Uses a malformed +// email so the request body fails BEFORE we hit the DB (which is nil in +// this fixture) — the assertion is that we get 400 invalid_email, NOT +// 400 invalid_return_to. That proves the return_to gate accepted the +// https URL. +func TestAuthStart_AllowsValidHTTPS_LegitimateFlow(t *testing.T) { + app, _ := authReturnToApp(t) + + resp := postJSON(t, app, "/auth/email/start", + `{"email":"not-an-email","return_to":"https://instanode.dev/login/callback"}`) + defer resp.Body.Close() + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + var envelope struct { + Error string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + // The email gate (which runs AFTER the new return_to gate) is what + // must fire. invalid_return_to firing would mean we rejected a + // legitimate https URL — a regression on the legitimate UI flow. + assert.Equal(t, "invalid_email", envelope.Error, + "valid https return_to must NOT hit the new fail-closed gate") +} diff --git a/internal/handlers/auth_returnto_coverage_authp0_test.go b/internal/handlers/auth_returnto_coverage_authp0_test.go new file mode 100644 index 0000000..8ea3198 --- /dev/null +++ b/internal/handlers/auth_returnto_coverage_authp0_test.go @@ -0,0 +1,172 @@ +package handlers + +// auth_returnto_coverage_authp0_test.go — patch-coverage backfill for +// the AUTH-016 / AUTH-017 / AUTH-004 helpers introduced in PR #176 and +// the same-shaped fail-closed gates added on GitHubStart + GoogleStart. +// +// Lives in `package handlers` so it can exercise the unexported helpers +// (returnToSchemeIsAllowed, appendSignedInMarker) directly. The +// scheme-rejection contract is also exercised end-to-end via the +// GitHubStart / GoogleStart handlers so the call-site branch — not just +// the helper — is covered. + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/middleware" +) + +// helper — build a minimal OAuth-Start app with the canonical error +// handler so respondError's sentinel is unwrapped to a plain JSON body. +func returnToCoverageApp(t *testing.T) *fiber.App { + t.Helper() + cfg := &config.Config{ + JWTSecret: "returnto-coverage-secret-32-bytes-minimum-len", + GitHubClientID: "gh-client", + GitHubClientSecret: "gh-secret", + GoogleClientID: "g-client", + GoogleClientSecret: "g-secret", + } + authH := NewAuthHandler(nil, cfg) + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"ok": false, "error": err.Error()}) + }, + }) + app.Use(middleware.RequestID()) + app.Get("/auth/github/start", authH.GitHubStart) + app.Get("/auth/google/start", authH.GoogleStart) + return app +} + +// TestReturnToSchemeIsAllowed_Branches covers every arm of +// returnToSchemeIsAllowed: +// - url.Parse error → false (line 292) +// - https → true +// - http → returnToAllowsLocalhost (line 302) +// - any other scheme → false (default) +func TestReturnToSchemeIsAllowed_Branches(t *testing.T) { + // url.Parse error path. A control character in the URL forces + // url.Parse to fail. + assert.False(t, returnToSchemeIsAllowed("ht\x7ftp://x"), + "unparseable URL must fall through to false (line 292)") + + assert.True(t, returnToSchemeIsAllowed("https://instanode.dev/x")) + assert.False(t, returnToSchemeIsAllowed("javascript:alert(1)")) + assert.False(t, returnToSchemeIsAllowed("data:text/html,x")) + + // http is gated by returnToAllowsLocalhost — assert both arms + // without touching package-global state for any other test. + prev := returnToAllowsLocalhost + defer func() { returnToAllowsLocalhost = prev }() + returnToAllowsLocalhost = true + assert.True(t, returnToSchemeIsAllowed("http://localhost:5173"), + "http must be allowed when localhost flag is on (line 302 true arm)") + returnToAllowsLocalhost = false + assert.False(t, returnToSchemeIsAllowed("http://localhost:5173"), + "http must be rejected when localhost flag is off (line 302 false arm)") +} + +// TestAppendSignedInMarker_MalformedFallback covers the url.Parse error +// branch of appendSignedInMarker (lines 168-169) — a malformed returnTo +// must fall back to defaultReturnTo + "?signed_in=1". +func TestAppendSignedInMarker_MalformedFallback(t *testing.T) { + got := appendSignedInMarker("%%%not-a-url") + assert.True(t, strings.HasPrefix(got, defaultReturnTo), + "malformed returnTo must collapse to defaultReturnTo, got %q", got) + assert.Contains(t, got, "signed_in=1", + "fallback must still carry the SPA signed_in marker") +} + +// TestAppendSignedInMarker_StripsSessionToken — defence-in-depth: even +// if upstream code path ever passed a returnTo that already carries +// ?session_token=, the marker helper strips it. This is asserted both +// because it's documented in the helper's comment AND because the +// stripping is what makes the helper safe to call on arbitrary returnTo +// values without regressing AUTH-004. +func TestAppendSignedInMarker_StripsSessionToken(t *testing.T) { + got := appendSignedInMarker("https://instanode.dev/x?session_token=leaked") + assert.NotContains(t, got, "session_token", + "appendSignedInMarker must strip any leaked session_token") + assert.Contains(t, got, "signed_in=1") +} + +// TestGitHubStart_RejectsJavascriptReturnTo covers the AUTH-016 fail-closed +// gate on the GitHub OAuth start (lines 1143-1145). Mirrors the magic-link +// regression test in auth_returnto_authp0_test.go. +func TestGitHubStart_RejectsJavascriptReturnTo(t *testing.T) { + app := returnToCoverageApp(t) + req := httptest.NewRequest(http.MethodGet, + "/auth/github/start?return_to=javascript:alert(1)", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, + "AUTH-016 (GitHub): javascript: in return_to must 400") + body, _ := readBodyAuthP0(resp) + assert.Contains(t, body, "invalid_return_to") +} + +// TestGoogleStart_RejectsJavascriptReturnTo covers the same fail-closed +// gate on the Google OAuth start (lines 1241-1243). Same shape, same +// rationale. +func TestGoogleStart_RejectsJavascriptReturnTo(t *testing.T) { + app := returnToCoverageApp(t) + req := httptest.NewRequest(http.MethodGet, + "/auth/google/start?return_to=javascript:alert(1)", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, + "AUTH-016 (Google): javascript: in return_to must 400") + body, _ := readBodyAuthP0(resp) + assert.Contains(t, body, "invalid_return_to") +} + +// TestScopesFieldKind_NonJSONBodyEarlyReturn covers the AUTH-164 fast +// path in scopesFieldKind (api_keys.go:275-276): a body that doesn't +// start with '{' is treated as "not present" so the canonical +// BodyParser path produces the existing invalid_body 400 rather than +// the new invalid_scopes envelope. +func TestScopesFieldKind_NonJSONBodyEarlyReturn(t *testing.T) { + for _, body := range []string{"", " ", "not-json", "[\"scopes\"]"} { + present, isNull := scopesFieldKind([]byte(body)) + assert.False(t, present, "non-object body %q must report present=false", body) + assert.False(t, isNull, "non-object body %q must report isNull=false", body) + } +} + +// readBodyAuthP0 is a tiny string-body helper that doesn't fight with +// io.ReadAll's error-return signature in assertions. +func readBodyAuthP0(resp *http.Response) (string, error) { + var sb strings.Builder + buf := make([]byte, 1024) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + sb.Write(buf[:n]) + } + if err != nil { + if err.Error() == "EOF" { + return sb.String(), nil + } + return sb.String(), err + } + } +} diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 9a6d748..ad5a0b6 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -215,6 +215,13 @@ var codeToAgentAction = map[string]errorCodeMeta{ "missing_token": { AgentAction: "Tell the user no INSTANODE_TOKEN was provided. Have them log in at https://instanode.dev/login and pass it via Authorization: Bearer <token>.", }, + // cookie_missing_or_expired — POST /auth/exchange (browser-only bridge + // from the magic-link / OAuth callback into the SPA) saw no bridge + // cookie. The 30-second transient window closed or the cookie was + // dropped. The remediation is to restart the sign-in flow. + "cookie_missing_or_expired": { + AgentAction: "Tell the user the sign-in handoff window expired. Have them start the login flow again at https://instanode.dev/login — the bridge cookie lives for 30 seconds.", + }, "vault_requires_auth": { AgentAction: "Tell the user vault access requires an authenticated session. Have them log in at https://instanode.dev/login to mint a token.", }, @@ -1054,6 +1061,20 @@ var codeToAgentAction = map[string]errorCodeMeta{ "subscription_cancel_failed": { AgentAction: "Tell the user cancelling the Razorpay subscription failed. The team-delete is paused; email support@instanode.dev so an operator can reconcile — see https://instanode.dev/support.", }, + + // ── Auth redirect + PAT trust-boundary walls (AUTH-001/002/016/017/090) ── + "invalid_return_to": { + AgentAction: "Tell the user the return_to URL is not https:// — javascript: and data: schemes are rejected to prevent open-redirect. Retry with a valid https URL — see https://instanode.dev/docs/auth.", + }, + "invalid_scopes": { + AgentAction: "Tell the user the requested PAT scopes are empty or unknown. Pass an explicit non-empty subset of {read,write,admin} — see https://instanode.dev/docs/api-tokens.", + }, + "pat_cannot_mint_pat": { + AgentAction: "Tell the user PATs cannot mint child PATs — only session-authenticated requests can create tokens. Sign in at https://instanode.dev/login and re-issue.", + }, + "reauth_required": { + AgentAction: "Tell the user this action requires a fresh session (admin-scope PAT mints need re-auth). Sign in again at https://instanode.dev/login — see https://instanode.dev/docs/auth.", + }, } // ErrorResponse is the canonical JSON shape for every 4xx/5xx response. diff --git a/internal/handlers/magic_link.go b/internal/handlers/magic_link.go index 841a40f..e7afd63 100644 --- a/internal/handlers/magic_link.go +++ b/internal/handlers/magic_link.go @@ -178,6 +178,21 @@ func (h *MagicLinkHandler) Start(c *fiber.Ctx) error { return respondError(c, fiber.StatusBadRequest, "invalid_email", "A valid email address is required") } + // AUTH-016 / AUTH-017: fail-closed on hostile return_to schemes. + // Previously validateReturnTo silently downgraded javascript:/data:/ + // file:/ etc. to the default — the resulting magic-link still landed + // (in the default origin) and the operator-side signal was lost. The + // fail-closed gate rejects with 400 invalid_return_to so callers + // learn the contract immediately and we get a clean abuse counter. + // An empty/missing return_to is still permitted and collapses to the + // allowlisted default downstream (the legitimate dashboard flow). + if rawReturnTo := strings.TrimSpace(body.ReturnTo); rawReturnTo != "" { + if !returnToSchemeIsAllowed(rawReturnTo) { + return respondError(c, fiber.StatusBadRequest, "invalid_return_to", + "Field 'return_to' must use https:// (or http://localhost for dev). javascript:, data:, file: and other schemes are rejected.") + } + } + // Per-email rate limit (A04). Fail-open on Redis error so a cache // outage never blocks sign-in. The 202 response on the limited path is // identical to the success path — the attacker gains no signal. @@ -358,7 +373,20 @@ func (h *MagicLinkHandler) Callback(c *fiber.Ctx) error { emitAuthLoginAudit(h.db, team.ID, user.ID, user.Email, "email", c.IP(), c.Get("User-Agent")) - return c.Redirect(appendSessionToken(returnTo, sessionToken), fiber.StatusFound) + // AUTH-004: do NOT put the session JWT in the Location header. The + // 24h-TTL JWT used to land in the URL query (?session_token=<jwt>), + // which leaked it to: + // - browser history (persistent device-side trail) + // - server-side access logs (ingress, CDN, dashboard nginx) + // - Referer headers on subsequent navigations + // - third-party analytics that capture full URLs + // Instead, set the JWT in a Secure; HttpOnly; SameSite=Lax cookie + // scoped to .instanode.dev so the dashboard origin can authenticate + // API calls via the cookie path in RequireAuth. The redirect URL + // carries only a non-secret `?signed_in=1` marker so the dashboard + // knows to call /auth/me and pick up the session. + setExchangeCookie(c, sessionToken, h.cfg.Environment == "production") + return c.Redirect(appendSignedInMarker(returnTo), fiber.StatusFound) } // looksLikeEmail performs the cheapest plausible check: must contain a single diff --git a/internal/handlers/magic_link_extra_test.go b/internal/handlers/magic_link_extra_test.go index 46ee343..9f7056d 100644 --- a/internal/handlers/magic_link_extra_test.go +++ b/internal/handlers/magic_link_extra_test.go @@ -177,7 +177,10 @@ func TestMagicLinkCallback_InvalidTokenReturns400HTML(t *testing.T) { // TestMagicLinkCallback_HappyPath_ConsumesAndRedirects walks the full // success flow: Start inserts a row, we extract the plaintext from the // stub mailer's recorded link, hit Callback, expect a 302 to -// <return_to>?session_token=... +// <return_to>?signed_in=1 plus a Secure HttpOnly session cookie. The +// previous "session_token=<jwt>" Location pattern was retired in +// AUTH-004 (2026-05-29) — see auth_callback_nojwt_authp0_test.go for +// the standalone regression test. func TestMagicLinkCallback_HappyPath_ConsumesAndRedirects(t *testing.T) { db, clean := testhelpers.SetupTestDB(t) defer clean() @@ -208,7 +211,12 @@ func TestMagicLinkCallback_HappyPath_ConsumesAndRedirects(t *testing.T) { defer resp2.Body.Close() assert.Equal(t, fiber.StatusFound, resp2.StatusCode) loc := resp2.Header.Get("Location") - assert.Contains(t, loc, "session_token=") + // AUTH-004: JWT in cookie, NOT in Location. The dashboard SPA gets + // a signed_in=1 marker and reads the cookie via /auth/me. + assert.NotContains(t, loc, "session_token=", "AUTH-004: JWT must not appear in Location") + assert.Contains(t, loc, "signed_in=1") + assert.Contains(t, strings.Join(resp2.Header.Values("Set-Cookie"), "\n"), "instanode_session_exchange=", + "AUTH-004: session JWT must be set as the instanode_session cookie") // Replay must fail — the row has been consumed. req3 := httptest.NewRequest(http.MethodGet, "/auth/email/callback?t="+plaintext, nil) diff --git a/internal/handlers/openapi_test.go b/internal/handlers/openapi_test.go index f09fd28..a377e0e 100644 --- a/internal/handlers/openapi_test.go +++ b/internal/handlers/openapi_test.go @@ -640,6 +640,15 @@ func TestOpenAPI_CoversAllRegisteredRoutes(t *testing.T) { "POST /internal/teams/{id}/backup-quota/refund": true, "GET /api/v1/usage/wall": true, "POST /api/v1/experiments/converted": true, + // POST /auth/exchange — browser-only bridge between the magic-link + // / OAuth callback and the SPA. The handler reads the transient + // instanode_session_exchange cookie (Path=/auth/exchange, + // Max-Age=30s) and returns the embedded JWT in the body so the + // SPA can swap into Bearer-only mode. Documenting it in the + // agent-facing OpenAPI would mislead CLI/MCP/SDK agents into + // thinking cookies are a valid auth mechanism — they're not + // (CLAUDE.md "Live API surface" + auth_beareronly_authp0_test.go). + "POST /auth/exchange": true, } var missing []string diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 3002788..75b83ee 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -255,6 +255,15 @@ func rejectAudienceMismatch(c *fiber.Ctx) error { // keyword so agents can branch "wrong server" from "bad credentials". func RequireAuth(cfg *config.Config) fiber.Handler { return func(c *fiber.Ctx) error { + // Bearer-only contract (CLAUDE.md "Live API surface"). The + // AUTH-004 fix uses a transient `instanode_session_exchange` + // cookie scoped to POST /auth/exchange to bridge the callback + // redirect into the SPA — RequireAuth deliberately does NOT + // honour it. Every CLI/MCP/SDK consumer is Bearer-only, and + // extending this middleware to accept the cookie would create + // a second auth mechanism that downstream surfaces (CSRF + // review, cookie-domain config, route-level auth-mode docs) + // would have to reason about. See PR #176 refactor note. header := c.Get("Authorization") if len(header) < 8 || header[:7] != "Bearer " { return respondUnauthorized(c) diff --git a/internal/middleware/auth_beareronly_authp0_test.go b/internal/middleware/auth_beareronly_authp0_test.go new file mode 100644 index 0000000..573ddf9 --- /dev/null +++ b/internal/middleware/auth_beareronly_authp0_test.go @@ -0,0 +1,86 @@ +package middleware_test + +// auth_beareronly_authp0_test.go — contract regression for the +// Bearer-only RequireAuth gate (PR #176 refactor, 2026-05-29). +// +// CLAUDE.md "Live API surface" documents Bearer-only auth for every +// route under RequireAuth — every CLI / MCP / SDK consumer is written +// against that contract. An earlier draft of AUTH-004 extended +// RequireAuth to fall back to the instanode_session cookie set by the +// magic-link / OAuth callbacks. That fallback was reverted before +// landing because: +// +// - it would add a second auth mechanism every downstream surface +// (CSRF review, cookie-domain config, route-level docs) would have +// to reason about +// - a future engineer might assume "cookie OR Bearer" is valid on +// API routes and design new endpoints against the wrong contract +// +// The current shape: the callback drops a transient +// `instanode_session_exchange` cookie (Path=/auth/exchange, 30s TTL), +// the SPA POSTs /auth/exchange to swap it for the JWT in the body, and +// every API call from then on is Bearer-only. RequireAuth must NOT +// honour ANY cookie — this test locks that contract. + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/middleware" +) + +// TestRequireAuth_BearerOnly_NoCookieFallback — a request carrying ONLY +// the legacy `instanode_session` cookie (the pre-refactor name) AND a +// request carrying the new `instanode_session_exchange` cookie MUST both +// 401 when no Authorization: Bearer header is present. Future +// engineers reading the contract should see "RequireAuth = Bearer-only, +// no exceptions" and route auth design accordingly. +func TestRequireAuth_BearerOnly_NoCookieFallback(t *testing.T) { + cfg := &config.Config{JWTSecret: "bearer-only-test-secret-32-bytes-min!"} + app := fiber.New() + app.Use(middleware.RequestID()) + app.Get("/api/v1/protected", + middleware.RequireAuth(cfg), + func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"ok": true}) }, + ) + + cases := []struct { + name string + cookie string + }{ + { + name: "legacy instanode_session cookie ignored", + cookie: "instanode_session=eyJhbGciOiJIUzI1NiJ9.fake.fake", + }, + { + name: "transient exchange cookie ignored on API routes", + cookie: "instanode_session_exchange=eyJhbGciOiJIUzI1NiJ9.fake.fake", + }, + { + name: "arbitrary cookie name ignored", + cookie: "session=eyJhbGciOiJIUzI1NiJ9.fake.fake", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/protected", nil) + req.Header.Set("Cookie", tc.cookie) + // Deliberately no Authorization header. + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, + "RequireAuth must 401 on cookie-only — Bearer-only contract") + assert.Equal(t, `Bearer realm="instanode"`, resp.Header.Get("WWW-Authenticate"), + "401 must carry RFC 6750 Bearer challenge — not Cookie") + }) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index e849851..c33a6af 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -612,6 +612,16 @@ func NewWithHooks(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *mid app.Get("/auth/cli/:id", cliAuthH.PollCLISession) app.Get("/auth/me", middleware.RequireAuth(cfg), cliAuthH.GetCurrentUser) + // AUTH-004: browser-only bridge between the magic-link / OAuth callback + // and the SPA. The callback sets a 30-second, Path=/auth/exchange cookie + // (instanode_session_exchange) carrying the session JWT and redirects + // with ?signed_in=1. The SPA POSTs /auth/exchange with credentials, the + // handler reads the cookie, clears it (Max-Age=0), and returns the JWT + // in the body. RequireAuth deliberately does NOT honour the cookie — + // every subsequent API call is Bearer-only, preserving the contract + // every CLI/MCP/SDK consumer already implements. + app.Post("/auth/exchange", authH.Exchange) + // A03 (P1): server-side session invalidation. POST /auth/logout stores the // JWT's jti in Redis so subsequent requests with the same token are rejected // by RequireAuth. RequireAuth checks the revocation set via IsJTIRevoked.