From c3486c5339a9ff8d465935576b2e624c2d1d3415 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 29 May 2026 16:30:11 +0530 Subject: [PATCH 1/4] fix(auth): close PAT mint chain + redirect-open + cookie session (P0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth P0 chain found by QA 2026-05-29. Six findings; all gated on the same login surface, ship as one PR. Findings closed in this PR: AUTH-001 (P0): PATs could mint child PATs (live returned 201; OpenAPI contract says 403). A leaked PAT could spawn additional keys that outlived the parent's revocation, defeating rotation/detection. Fix: handlers/api_keys.go branches on middleware.IsAuthedViaAPIKey and rejects with 403 pat_cannot_mint_pat regardless of scope. AUTH-002 (P0): read-only PAT minted admin-scope child (201 with scopes:["admin"]). Complete privilege escalation from a leaked read-only key. Fix: the AUTH-001 structural gate catches this as a subset — no PAT can mint a PAT at all, so subset-of-parent is trivially satisfied. AUTH-090 (P0): session JWT could mint admin-scope PAT with no re-auth. Last hop in the phishing → session cookie → admin PAT chain. Fix: minting an admin-scope PAT from a session JWT requires X-Confirm-Reauth: 1 (set by the dashboard after a fresh password/MFA prompt). Without the header → 403 reauth_required. AUTH-164 (P1): scopes:[] and scopes:null silently defaulted to ["read","write"]. Caller asked for "no scopes" and got broad write. Fix: distinguish absent (→ safe default ["read"]) from explicit empty/null (→ 400 invalid_scopes). AUTH-016 / AUTH-017 (P0): return_to=javascript:alert(1) and return_to=data:text/html,"}`) + 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/magic_link.go b/internal/handlers/magic_link.go index 841a40ff..74dac2f6 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=), + // 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. + setSessionCookie(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 46ee3438..42054494 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 -// ?session_token=... +// ?signed_in=1 plus a Secure HttpOnly session cookie. The +// previous "session_token=" 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=", + "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/middleware/auth.go b/internal/middleware/auth.go index 3002788d..b98e415e 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -11,6 +11,14 @@ import ( "instant.dev/internal/urls" ) +// SessionCookieName is the cookie name carrying the session JWT after a +// successful magic-link / OAuth callback (AUTH-004, 2026-05-29). It MUST +// match the constant of the same name in handlers/auth.go — they're kept +// separate to avoid a middleware → handlers import cycle. The two +// constants are tested for equality in middleware/auth_test.go to catch +// drift. +const SessionCookieName = "instanode_session" + const ( // LocalKeyUserID is the fiber.Locals key for the authenticated user ID. LocalKeyUserID = "auth_user_id" @@ -255,11 +263,21 @@ 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 { + // AUTH-004: prefer Authorization: Bearer when present; fall back + // to the instanode_session cookie set by the magic-link / OAuth + // callbacks. Without this fallback the dashboard couldn't + // authenticate API calls after the callback path stopped + // embedding the session JWT in the redirect URL — the dashboard + // has the cookie but no Bearer header. + var tokenStr string header := c.Get("Authorization") - if len(header) < 8 || header[:7] != "Bearer " { + if len(header) >= 8 && header[:7] == "Bearer " { + tokenStr = header[7:] + } else if cookieJWT := c.Cookies(SessionCookieName); cookieJWT != "" { + tokenStr = cookieJWT + } else { return respondUnauthorized(c) } - tokenStr := header[7:] // Dispatch on token shape. PATs (ink_) hit the api_keys // table; JWTs go through HMAC validation. Both populate the same From 99bb65faf56cf45c5c49399515f2cc8a4fccdf52 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 29 May 2026 18:44:53 +0530 Subject: [PATCH 2/4] refactor(auth): keep AUTH-004 fix, drop cookie-fallback contract drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #176 review caught one drift from the business contract: the original AUTH-004 fix added a long-lived `instanode_session` cookie AND extended middleware/RequireAuth to honour it as a fallback when no Authorization: Bearer header is present. That extension added a SECOND auth mechanism alongside Bearer-only, which is the contract every CLI/MCP/SDK consumer and CLAUDE.md "Live API surface" already implement. Knock-on costs would have landed across CSRF coverage for cookie-auth routes, cookie-domain config, and per-route auth-mode docs — a contract change disguised as a security fix. This refactor keeps the security fix (no JWT in URL) but confines the cookie to a transient 30-second bridge for the browser callback: 1. /auth/email/callback (and /auth/github/callback, /auth/google/callback/browser) mint the JWT and drop it into `instanode_session_exchange` — HttpOnly, Secure, SameSite=Lax, Max-Age=30, Path=/auth/exchange. 2. 302 to dashboard with only `?signed_in=1` in the URL — no token. 3. SPA POSTs /auth/exchange with credentials. New handler reads the cookie, clears it (Expires=epoch + same Path so the browser drops it), returns {ok, token} in the body. SPA stores the JWT in memory and from then on uses Authorization: Bearer like every other client. middleware/RequireAuth is back to Bearer-only — the cookie's only consumer is POST /auth/exchange. The new TestRequireAuth_BearerOnly_NoCookieFallback locks the contract: neither the legacy `instanode_session` nor the new `instanode_session_exchange` cookie ever satisfies RequireAuth on its own. Tests: + TestAuthExchange_ConsumesCookie_ReturnsJWT_AndDeletesCookie + TestAuthExchange_NoCookie_Returns400 + TestAuthExchange_ExpiredCookie_Returns400 + TestRequireAuth_BearerOnly_NoCookieFallback (3 subcases) ~ TestAuthCallback_DoesNotPutJWTInLocation (cookie name + path updated to the transient form) ~ TestMagicLinkExtra_FullSuccess (same — cookie name update) ~ TestAuth_GitHubCallback_FullSuccess (same) ~ TestAuth_GoogleCallbackBrowser_FullSuccess (same) Other AUTH-001 / AUTH-002 / AUTH-090 / AUTH-164 / AUTH-016 / AUTH-017 fixes are unchanged — all still pass. OpenAPI: POST /auth/exchange added to intentionallyHidden in openapi_test.go (browser-only, not an agent-facing surface — the whole point of this refactor is that agents only see Bearer). The new `cookie_missing_or_expired` error code added to codeToAgentAction with the U3-contract remediation sentence. Surface checklist: - api/internal/handlers/auth.go (cookie semantics + Exchange) - api/internal/handlers/magic_link.go (callsite renamed) - api/internal/middleware/auth.go (cookie fallback reverted) - api/internal/router/router.go (POST /auth/exchange) - api/internal/handlers/helpers.go (agent_action for new code) - api/internal/handlers/openapi_test.go (route allowlist) - SPA (instanode-web/dashboard): follow-up PR in the dashboard repo to swap the existing `?session_token=` consumer for a `signed_in=1`-triggered POST /auth/exchange + in-memory store. Until that ships, the dashboard SPA will still work via /auth/me on the next session — the API contract is forward- compatible. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/auth.go | 135 +++++++++++------ .../auth_callback_nojwt_authp0_test.go | 4 +- .../handlers/auth_exchange_authp0_test.go | 139 ++++++++++++++++++ internal/handlers/auth_final_coverage_test.go | 2 +- internal/handlers/auth_oauth_coverage_test.go | 4 +- internal/handlers/helpers.go | 7 + internal/handlers/magic_link.go | 2 +- internal/handlers/magic_link_extra_test.go | 2 +- internal/handlers/openapi_test.go | 9 ++ internal/middleware/auth.go | 31 ++-- .../middleware/auth_beareronly_authp0_test.go | 86 +++++++++++ internal/router/router.go | 10 ++ 12 files changed, 363 insertions(+), 68 deletions(-) create mode 100644 internal/handlers/auth_exchange_authp0_test.go create mode 100644 internal/middleware/auth_beareronly_authp0_test.go diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 1ce4deec..ae578fba 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -177,49 +177,102 @@ func appendSignedInMarker(returnTo string) string { return u.String() } -// sessionCookieName is the cross-host session cookie set by the magic-link -// and OAuth callback paths after a successful sign-in (AUTH-004). The -// RequireAuth middleware accepts it as a fallback when no -// Authorization: Bearer header is present, so the dashboard at -// instanode.dev can authenticate API calls to api.instanode.dev via the -// cookie alone — no JWT in URLs, history, or logs. -const sessionCookieName = "instanode_session" - -// sessionCookieDomain is the parent Domain attribute for the session -// cookie. Setting it explicitly to .instanode.dev makes the cookie -// readable on api.instanode.dev (where it's set) AND on instanode.dev -// (where the dashboard runs) so the SPA picks up the session without -// any token-in-URL gymnastics. In non-production environments the Domain -// attribute is omitted (defaults to current host) so local dev / tests -// with httptest hosts don't get rejected by the browser cookie store. -const sessionCookieDomain = ".instanode.dev" - -// sessionCookieMaxAge mirrors the JWT lifetime so the cookie expires no -// later than the embedded JWT exp claim. Keeping the JWT TTL as the -// source of truth (see issueSessionJWT) and the cookie as the carrier. -const sessionCookieMaxAge = 24 * 60 * 60 // 24h, matches JWT TTL - -// setSessionCookie writes the session JWT into a Secure; HttpOnly; -// SameSite=Lax cookie scoped to .instanode.dev (prod) or current host -// (dev). HttpOnly blocks the XSS exfil path that AUTH-003 documented; -// Secure forces TLS so the cookie never rides a cleartext fallback; -// SameSite=Lax preserves the top-level navigation flow (the user lands -// on the dashboard via 302 from the callback) while blocking -// third-party-origin POST CSRF. -func setSessionCookie(c *fiber.Ctx, jwt string, prod bool) { - cookie := &fiber.Cookie{ - Name: sessionCookieName, +// 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: "/", - MaxAge: sessionCookieMaxAge, + Path: exchangeCookiePath, + MaxAge: exchangeCookieMaxAge, Secure: prod, HTTPOnly: true, SameSite: "Lax", - } - if prod { - cookie.Domain = sessionCookieDomain - } - c.Cookie(cookie) + }) +} + +// 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 @@ -1172,7 +1225,7 @@ func (h *AuthHandler) GitHubCallback(c *fiber.Ctx) error { // AUTH-004: do NOT put the session JWT in the Location header. // See comment on the magic-link callback for the full rationale. - setSessionCookie(c, sessionToken, h.cfg.Environment == "production") + setExchangeCookie(c, sessionToken, h.cfg.Environment == "production") return c.Redirect(appendSignedInMarker(returnTo), fiber.StatusFound) } @@ -1274,7 +1327,7 @@ func (h *AuthHandler) GoogleCallbackBrowser(c *fiber.Ctx) error { emitAuthLoginAudit(h.db, team.ID, user.ID, user.Email, "google", c.IP(), c.Get("User-Agent")) // AUTH-004: session cookie + signed_in marker (no JWT in Location). - setSessionCookie(c, sessionToken, h.cfg.Environment == "production") + setExchangeCookie(c, sessionToken, h.cfg.Environment == "production") return c.Redirect(appendSignedInMarker(returnTo), fiber.StatusFound) } diff --git a/internal/handlers/auth_callback_nojwt_authp0_test.go b/internal/handlers/auth_callback_nojwt_authp0_test.go index 89807599..78ed24ff 100644 --- a/internal/handlers/auth_callback_nojwt_authp0_test.go +++ b/internal/handlers/auth_callback_nojwt_authp0_test.go @@ -41,7 +41,7 @@ import ( // 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=; HttpOnly; SameSite=Lax. +// - Set-Cookie header MUST carry instanode_session_exchange=; HttpOnly; SameSite=Lax. func TestAuthCallback_DoesNotPutJWTInLocation(t *testing.T) { db, clean := testhelpers.SetupTestDB(t) defer clean() @@ -88,7 +88,7 @@ func TestAuthCallback_DoesNotPutJWTInLocation(t *testing.T) { // 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=", + assert.Contains(t, setCookies, "instanode_session_exchange=", "the session cookie name must be set") setCookie := setCookies // alias for downstream attribute checks assert.True(t, diff --git a/internal/handlers/auth_exchange_authp0_test.go b/internal/handlers/auth_exchange_authp0_test.go new file mode 100644 index 00000000..f76b440d --- /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 ee2ee5c7..053620a7 100644 --- a/internal/handlers/auth_final_coverage_test.go +++ b/internal/handlers/auth_final_coverage_test.go @@ -350,7 +350,7 @@ func TestMagicLink_Callback_AlreadyVerifiedUser(t *testing.T) { 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=", + 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") } diff --git a/internal/handlers/auth_oauth_coverage_test.go b/internal/handlers/auth_oauth_coverage_test.go index 510b18a2..b9e622d7 100644 --- a/internal/handlers/auth_oauth_coverage_test.go +++ b/internal/handlers/auth_oauth_coverage_test.go @@ -605,7 +605,7 @@ func TestAuth_GitHubCallback_FullSuccess(t *testing.T) { 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=", + 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") } @@ -653,7 +653,7 @@ func TestAuth_GoogleCallbackBrowser_FullSuccess(t *testing.T) { 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=", + 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") } diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 9ab5874e..05de7ad9 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -164,6 +164,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 .", }, + // 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.", }, diff --git a/internal/handlers/magic_link.go b/internal/handlers/magic_link.go index 74dac2f6..e7afd63d 100644 --- a/internal/handlers/magic_link.go +++ b/internal/handlers/magic_link.go @@ -385,7 +385,7 @@ func (h *MagicLinkHandler) Callback(c *fiber.Ctx) error { // 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. - setSessionCookie(c, sessionToken, h.cfg.Environment == "production") + setExchangeCookie(c, sessionToken, h.cfg.Environment == "production") return c.Redirect(appendSignedInMarker(returnTo), fiber.StatusFound) } diff --git a/internal/handlers/magic_link_extra_test.go b/internal/handlers/magic_link_extra_test.go index 42054494..9f7056d0 100644 --- a/internal/handlers/magic_link_extra_test.go +++ b/internal/handlers/magic_link_extra_test.go @@ -215,7 +215,7 @@ func TestMagicLinkCallback_HappyPath_ConsumesAndRedirects(t *testing.T) { // 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=", + 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. diff --git a/internal/handlers/openapi_test.go b/internal/handlers/openapi_test.go index 07f9413e..3c50f23a 100644 --- a/internal/handlers/openapi_test.go +++ b/internal/handlers/openapi_test.go @@ -634,6 +634,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 b98e415e..75b83eee 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -11,14 +11,6 @@ import ( "instant.dev/internal/urls" ) -// SessionCookieName is the cookie name carrying the session JWT after a -// successful magic-link / OAuth callback (AUTH-004, 2026-05-29). It MUST -// match the constant of the same name in handlers/auth.go — they're kept -// separate to avoid a middleware → handlers import cycle. The two -// constants are tested for equality in middleware/auth_test.go to catch -// drift. -const SessionCookieName = "instanode_session" - const ( // LocalKeyUserID is the fiber.Locals key for the authenticated user ID. LocalKeyUserID = "auth_user_id" @@ -263,21 +255,20 @@ 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 { - // AUTH-004: prefer Authorization: Bearer when present; fall back - // to the instanode_session cookie set by the magic-link / OAuth - // callbacks. Without this fallback the dashboard couldn't - // authenticate API calls after the callback path stopped - // embedding the session JWT in the redirect URL — the dashboard - // has the cookie but no Bearer header. - var tokenStr string + // 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 " { - tokenStr = header[7:] - } else if cookieJWT := c.Cookies(SessionCookieName); cookieJWT != "" { - tokenStr = cookieJWT - } else { + if len(header) < 8 || header[:7] != "Bearer " { return respondUnauthorized(c) } + tokenStr := header[7:] // Dispatch on token shape. PATs (ink_) hit the api_keys // table; JWTs go through HMAC validation. Both populate the same diff --git a/internal/middleware/auth_beareronly_authp0_test.go b/internal/middleware/auth_beareronly_authp0_test.go new file mode 100644 index 00000000..573ddf92 --- /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 9b1b4c93..0e90e0e3 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. From fe5d671e8b9349e26c06388032c82e84edcc0700 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Fri, 29 May 2026 18:58:30 +0530 Subject: [PATCH 3/4] test(auth): patch-coverage backfill for AUTH-016/017/164 helpers CI's 100%-patch-coverage gate flagged 18 missing lines in PR #176's diff. Most live on the AUTH-016/AUTH-017/AUTH-164 helpers shipped before the refactor commit and never had unit-level tests; the remaining lines are the refactor's appendSignedInMarker parse-error fallback. - returnToSchemeIsAllowed: every branch (parse error, https, http under both states of returnToAllowsLocalhost, default reject) - appendSignedInMarker: malformed-URL fallback + session_token strip - GitHubStart / GoogleStart: AUTH-016 fail-closed gate for the OAuth start paths - scopesFieldKind: non-JSON / empty / non-object early-return (AUTH-164 fall-through to canonical invalid_body 400) No production code changed. All assertions describe behaviour already documented in PR #176; this commit just locks it in test-form so the patch-coverage gate clears. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auth_returnto_coverage_authp0_test.go | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 internal/handlers/auth_returnto_coverage_authp0_test.go 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 00000000..8ea31987 --- /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 + } + } +} From 9af719266bb4195e085fca4b30a1b677808e7262 Mon Sep 17 00:00:00 2001 From: "Claude (Manas)" Date: Fri, 29 May 2026 21:07:19 +0530 Subject: [PATCH 4/4] fix(auth): add agent_action for 4 pre-existing codes flagged by registry test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invalid_return_to, invalid_scopes, pat_cannot_mint_pat, reauth_required were introduced earlier in this branch but the codeToAgentAction registry entries were left as a 'follow-up polish' per the original commit message. The registry-iterating TestErrorCode_HasAgentAction in CI blocks merge — closing the gap with U3-compliant copy here. Verified locally: TestErrorCode_HasAgentAction + TestAgentActionContract PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/helpers.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index b09051e6..ad5a0b6e 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -1061,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.