package httpserver import ( "errors" "net/http" "strconv" "strings" "time" "github.com/krow/krow-backend/go-api/internal/auth" "github.com/krow/krow-backend/go-api/internal/authctx" "github.com/krow/krow-backend/go-api/internal/domain" "github.com/krow/krow-backend/go-api/internal/orgctx" ) // The authentication surface: sign in, sign out, and the middleware that turns // a cookie into an identity. // // The shape of the whole thing is one sentence: the browser holds an opaque // random string it cannot read, the database holds SHA-256 of that string, and // every protected request is a lookup from one to the other. No claim travels // in the request. There is no token in a JSON body, no user id in a query // string, no organization in a header — those are all things a client can // write, and a client writing its own identity is the bug this replaces. // sessionCookieName is the cookie the browser holds. // // The "__Host-" prefix would be stronger — browsers enforce Secure, Path=/ and // no Domain on it — but it also *requires* Secure, which cannot be set over // plain HTTP on localhost. A cookie name that only works in production is worse // than a plain one that works everywhere, so the hardening is done by the // attributes below instead, where it can be conditional. const sessionCookieName = "krow_session" /* ── Cookie ─────────────────────────────────────────────────────────────── */ // secureCookies reports whether Secure may be set. // // Secure means "only ever send this over HTTPS". Setting it in development // would mean the browser silently declines to send the cookie back to // http://localhost, and the symptom is an endless loop of successful logins // that never authenticate anything. func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" } // sessionSameSite reports the SameSite mode the session cookie must carry. // // Lax is the default and the safer value: it closes the CSRF hole by refusing // to travel on cross-site subresource requests. That is exactly right when the // page and the API share an origin, which is the supported deployment. // // When the API is configured with a CORS allowlist, the deployment is by // definition the other one: a page on some other origin calls this API // directly. A Lax cookie is never sent on those requests, so login would // succeed once and every request after it would arrive anonymous. None is the // only mode a browser will send cross-site, and it requires Secure — which is // why an origin allowlist forces Secure on regardless of AppEnv. func (s *Server) sessionSameSite() http.SameSite { // An explicit HTTP_COOKIE_SAMESITE wins, because the derivation below // cannot see the one thing that decides the answer: whether the frontend // is on the same SITE as this API. // // CORS is about ORIGIN and SameSite is about SITE, and they are not the // same question. platform.krowforce.com calling mcp.krowforce.com is // cross-origin — so it needs the CORS allowlist — and same-site, so a Lax // cookie is sent on its requests anyway. Deriving None from "CORS is // configured" gives up the only CSRF protection this API has, in exchange // for nothing that deployment needed. // // So the allowlist decides the DEFAULT and an operator decides the value. // This also closes a trap: config.Load has always parsed and validated // HTTP_COOKIE_SAMESITE, and nothing read it — a deployment that set it saw // it silently ignored. switch s.cfg.HTTP.CookieSameSite { case "none": return http.SameSiteNoneMode case "strict": return http.SameSiteStrictMode case "lax": return http.SameSiteLaxMode } // Unset. A configured CORS allowlist means a browser on another origin is // expected, and None is the only mode that survives a genuinely cross-site // one. Safe as a default because it is only reached when nobody has said // otherwise. if len(s.cfg.HTTP.CORSOrigins) > 0 { return http.SameSiteNoneMode } return http.SameSiteLaxMode } // crossSiteCookies reports whether the cookie must be marked Secure because it // has to travel cross-site. SameSite=None without Secure is rejected outright // by every current browser. func (s *Server) crossSiteCookies() bool { return s.sessionSameSite() == http.SameSiteNoneMode } // setSessionCookie writes the raw token to the browser. // // This is the only place the raw token is written to a response, and it goes // into a Set-Cookie header rather than a body: HttpOnly means no script on the // page can read it, which is what makes an XSS bug stop short of session theft. // // maxAge matches the session's own lifetime so the browser drops the cookie at // roughly the moment the server would refuse it. The server is still the // authority — a cookie the browser keeps too long is simply rejected — but a // cookie that expires with its session keeps the two honest. func (s *Server) setSessionCookie(w http.ResponseWriter, token string, lifetime time.Duration) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", // HttpOnly: script cannot read it. HttpOnly: true, // Lax, not Strict and not None. Strict would drop the cookie on any // cross-site navigation, so following a link into the app would land on // a login page despite a live session. None would require Secure and // would send the cookie on cross-site POSTs, which is the CSRF hole Lax // exists to close. SameSite: s.sessionSameSite(), Secure: s.secureCookies() || s.crossSiteCookies(), MaxAge: int(lifetime.Seconds()), }) } // clearSessionCookie expires the cookie in the browser. // // The attributes must match the ones it was set with — a cookie is identified // by name, domain and path, so clearing it with a different Path leaves the // original in place and the browser keeps sending a token the server has // already deleted. func (s *Server) clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: s.sessionSameSite(), Secure: s.secureCookies() || s.crossSiteCookies(), MaxAge: -1, }) } // sessionToken reads the raw token out of the request, if there is one. func sessionToken(r *http.Request) string { c, err := r.Cookie(sessionCookieName) if err != nil || c == nil { return "" } return strings.TrimSpace(c.Value) } /* ── Routes ─────────────────────────────────────────────────────────────── */ func (s *Server) routeAuth(mux *http.ServeMux) int { mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) return 2 } // loginRequest is the body of POST /api/v1/auth/login. type loginRequest struct { Email string `json:"email"` Password string `json:"password"` RememberMe bool `json:"remember_me"` } // handleLogin verifies a password and issues a session. // // The order of operations is deliberate: // // 1. Parse and validate the *shape* of the request. A missing field is a // malformed request, not a failed login, and saying so reveals nothing. // 2. Check the rate limit, before any expensive work. Refusing early is the // point — an attacker must not be able to make the server hash for them. // 3. Verify the credentials, which takes the same measurable time whether the // email exists or not (see auth.Credentials). // 4. Issue the session and set the cookie. // // Every failure in step 3 produces one identical response. The reason goes to // the log, at warn, with the email — which is already in the request — and // never the password. func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := decodeInto(r, &req); err != nil { writeError(w, s.log, err) return } email := strings.TrimSpace(req.Email) details := map[string]string{} if email == "" { details["email"] = "an email address is required" } if req.Password == "" { details["password"] = "a password is required" } if len(details) > 0 { writeError(w, s.log, domain.Validation("email and password are required", details)) return } // Two budgets, both consulted, both counted. The per-email budget stops one // account being ground down from many addresses; the per-address budget, // which is wider, stops one host working through many accounts. They are // separate limiters because they are deliberately different sizes — see the // note on Server. addr := s.trust.clientAddr(r) emailKey := strings.ToLower(email) for _, check := range []struct { limiter *attemptLimiter key string scope string }{ {s.loginByEmail, emailKey, "email"}, {s.loginByAddr, addr, "address"}, } { if ok, retryAfter := check.limiter.Allow(check.key); !ok { w.Header().Set("Retry-After", retryAfterSeconds(retryAfter)) s.log.Warn("login rate limited", "scope", check.scope, "email", email, "addr", addr, "retry_after_seconds", retryAfterSeconds(retryAfter)) writeError(w, s.log, domain.RateLimited( "too many sign-in attempts; wait a few minutes and try again")) return } } user, reason, err := s.credentials.Verify(r.Context(), email, req.Password) if errors.Is(err, auth.ErrInvalidCredentials) { s.loginByEmail.Fail(emailKey) s.loginByAddr.Fail(addr) // The reason is the whole value of this line and must never leave it. s.log.Warn("login failed", "email", email, "addr", addr, "reason", string(reason)) writeError(w, s.log, domain.Unauthenticated()) return } if err != nil { // The database is down, or a stored hash is unreadable. The caller's // credentials were never judged, so this is a 500 and not a 401. writeError(w, s.log, domain.Internal(err)) return } token, sess, err := s.sessions.Issue(r.Context(), user.ID, req.RememberMe) if err != nil { writeError(w, s.log, domain.Internal(err)) return } // A correct password clears the email's penalty, so two typos followed by a // success leave nothing behind. The address counter is left alone: one // correct login should not wipe the budget for every other account being // tried from the same host. s.loginByEmail.Reset(emailKey) s.setSessionCookie(w, token, time.Until(sess.ExpiresAt)) // Best effort, deliberately after the session exists: a failure to stamp // last_login_at is a lost diagnostic, not a reason to refuse a sign-in that // has already succeeded. if err := s.users.MarkLoggedIn(r.Context(), user.ID, s.now()); err != nil { s.log.Warn("could not record last_login_at", "user_id", user.ID, "error", err) } s.log.Info("login", "user_id", user.ID, "email", user.Email, "remember_me", req.RememberMe, "session_id", sess.ID, "expires_at", sess.ExpiresAt, "absolute_expires_at", sess.AbsoluteExpiresAt) // The body is the user, in exactly the shape GET /me returns, so the // frontend can render the signed-in state without a second round trip. // // The token is NOT here and must never be. It went out in a Set-Cookie // header the page cannot read; putting it in the body would hand it to // every script on the page and undo HttpOnly entirely. record, err := s.userRecord(r.Context(), s.db.Pool, user.ID) if err != nil { writeError(w, s.log, err) return } writeRecord(w, http.StatusOK, record) } // handleLogout revokes the session behind the cookie and clears the cookie. // // Idempotent by construction: no cookie, an unknown token and a live session // all end the same way — the cookie is cleared and the answer is 200. Logging // out is a request to not be signed in, and the caller is not signed in // afterwards in every one of those cases. // // It is deliberately public. Requiring a valid session to log out means a user // whose session has already expired gets a 401 from the one action that would // have tidied up their stale cookie. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if token := sessionToken(r); token != "" { if err := s.sessions.Revoke(r.Context(), token); err != nil { // Revoke already treats "no such session" as success, so this is a // real failure — the database, most likely. Clearing the cookie is // still the right thing to do, and reporting a 500 for a logout // would leave the caller signed in with no way to fix it. s.log.Error("could not revoke session on logout", "error", err) } } s.clearSessionCookie(w) writeJSON(w, http.StatusOK, envelope{Data: map[string]any{"status": "signed_out"}}) } /* ── Middleware ─────────────────────────────────────────────────────────── */ // publicPaths are the only endpoints reachable without a session. // // An allowlist rather than a list of protected prefixes, so the failure mode of // forgetting to update it is a route that refuses everyone — not one that // serves everyone. A new endpoint is private until someone deliberately says // otherwise, which is the direction a mistake should fall in. var publicPaths = map[string]bool{ "/health": true, "/api/v1/auth/login": true, "/api/v1/auth/logout": true, // ── The OAuth surface for MCP clients ────────────────────────────────── // // Four paths, each public for a specific reason rather than because // "/oauth/*" is convenient. The namespace is deliberately NOT wildcarded: // /oauth/authorize is not here, because it renders a consent screen for a // signed-in person and must keep requiring a session. // // These routes are registered only when OAUTH_ISSUER and MCP_RESOURCE are // configured. Listing them here is harmless otherwise — an unregistered // path still 404s, it simply does so without being asked for a cookie. // RFC 9728 and RFC 8414. A client with no token cannot read a document // that requires one, and these are how it discovers where to get a token. // They contain public endpoint URLs and nothing else. "/.well-known/oauth-protected-resource": true, "/.well-known/oauth-authorization-server": true, // RFC 7591. A client that has never registered has no credential to // present; that is what dynamic registration is for. "/oauth/register": true, // The client authenticates here with an authorization code or a refresh // token in the BODY. This is a back-channel call from the MCP client's own // servers — there is no browser and no cookie to send. "/oauth/token": true, // Revocation authenticates by presenting the token being revoked, for the // same back-channel reason. "/oauth/revoke": true, // /mcp is listed here, and it is the entry that most deserves explaining, // because "public" is the opposite of what it means for this path. // // The MCP endpoint authenticates its OWN callers, from the Authorization // header, inside mcpserver — every method but the handshake requires a // valid bearer token, and the transport ignores whatever identity this // middleware may have put in the context. So listing it here does not make // it reachable without a credential; it makes THIS middleware step aside // so the one that knows how to answer can. // // It has to step aside. An MCP client discovers how to authenticate by // calling the endpoint with no token and reading the WWW-Authenticate // header of the 401 — RFC 9728, and the first step of the whole flow. // This middleware's 401 carries no such header, so guarding /mcp here // would mean a client received a refusal with nowhere to go and the // connection could never be established. That is not a hypothetical: it is // what TestMCPWithoutBearerReturns401AndDiscoveryPointer caught. // // What stops a cookie authenticating an MCP call is therefore NOT this // allowlist — it is mcpserver taking its identity as a parameter rather // than from the request context. See mcpserver/auth.go, and // TestMCPRejectsACookieSession below. "/mcp": true, // /oauth/authorize is here for the same reason as /mcp, and it took a live // client to show why. // // It was withheld on the reasoning that consent needs a signed-in person, // so the route "genuinely wants the cookie". That reasoning was right about // the requirement and wrong about who enforces it. THE HANDLER already // enforces it — authserver.go asks sessions.CurrentUser, refuses to render // consent without an identity, and redirects an anonymous visitor to the // login with the authorization request preserved in returnTo. Guarding the // path HERE meant that handler was never reached, so the redirect it // performs could never run: every signed-out visitor got this middleware's // JSON 401 instead of a login page. // // That is not a cosmetic difference. A first-time connector user is signed // out by definition, so OAuth's browser leg was unreachable for exactly the // people who needed it. Claude Web stopped here — discovery, registration, // then a 401 with nowhere to go. Claude Desktop only got past it because a // session had been established by hand beforehand. // // Listing it grants nothing: no session still means no consent screen and // no authorization code, and the consent POST still requires the // session-bound CSRF token. What changes is only WHICH layer says no, and // therefore whether it can say "sign in here" instead of "no". "/oauth/authorize": true, } // authenticate resolves the session cookie into an identity, or refuses. // // This replaces devOrgMiddleware, which put a fixed organization on every // request with no credential behind it. The seam is the same one that comment // promised: everything downstream still reads the organization from // orgctx, and not one service or repository changed. // // What the request cannot influence: nothing here reads the body, the query // string or any header other than Cookie. The user id, the organization and the // role are all read from the sessions and users tables, keyed by a token the // client cannot forge without already holding it. func (s *Server) authenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if publicPaths[r.URL.Path] { next.ServeHTTP(w, r) return } token := sessionToken(r) if token == "" { writeError(w, s.log, domain.Unauthenticated()) return } sess, err := s.sessions.Authenticate(r.Context(), token) if err != nil { // Not found and expired are logged apart and answered identically. // Clearing the cookie stops the browser re-sending a token that // will never work again. s.log.Debug("session rejected", "reason", sessionRejection(err), "path", r.URL.Path) if errors.Is(err, auth.ErrSessionNotFound) || errors.Is(err, auth.ErrSessionExpired) || errors.Is(err, auth.ErrEmptyToken) { s.clearSessionCookie(w) writeError(w, s.log, domain.Unauthenticated()) return } writeError(w, s.log, domain.Internal(err)) return } // The user is re-read on every request rather than cached in the // session row, so suspending an account takes effect on the account's // next request instead of whenever its session happens to lapse. user, err := s.users.FindByID(r.Context(), sess.UserID) if err != nil { if errors.Is(err, auth.ErrUserNotFound) { // The FK cascades, so this should be unreachable. If it happens // the session is orphaned and worth destroying. s.log.Warn("session references a missing user", "session_id", sess.ID) _ = s.sessions.RevokeID(r.Context(), sess.ID) s.clearSessionCookie(w) writeError(w, s.log, domain.Unauthenticated()) return } writeError(w, s.log, domain.Internal(err)) return } if !user.IsActive() { // Suspension revokes on contact. Leaving the session alive would // mean a suspended account keeps a working cookie for up to thirty // days, refused one request at a time. s.log.Warn("session for an inactive user revoked", "user_id", user.ID, "status", user.Status) _ = s.sessions.RevokeID(r.Context(), sess.ID) s.clearSessionCookie(w) writeError(w, s.log, domain.Unauthenticated()) return } id := authctx.Identity{ UserID: user.ID, OrgID: user.OrgID, Email: user.Email, FullName: user.FullName, Role: user.Role, AccountType: user.AccountType, Status: user.Status, SessionID: sess.ID, ExpiresAt: sess.ExpiresAt, } ctx := authctx.With(r.Context(), id) // The organization comes from the user's row, never from the request. // Every service and repository already takes it as a parameter, so this // one line is the whole of the tenancy change. ctx = orgctx.With(ctx, user.OrgID) next.ServeHTTP(w, r.WithContext(ctx)) }) } // sessionRejection names why a session was refused, for the log only. func sessionRejection(err error) string { switch { case errors.Is(err, auth.ErrSessionNotFound): return "not_found" case errors.Is(err, auth.ErrSessionExpired): return "expired" case errors.Is(err, auth.ErrEmptyToken): return "empty_token" default: return "error" } } // retryAfterSeconds renders a duration for the Retry-After header, rounded up // and never below one second — "Retry-After: 0" invites an immediate retry. func retryAfterSeconds(d time.Duration) string { secs := int(d.Round(time.Second) / time.Second) if secs < 1 { secs = 1 } return strconv.Itoa(secs) }