Files
krow_backend/go-api/internal/httpserver/auth.go
Suriyakumarvijayanayagam 954ba9076f Add CORS credentials, transactional endpoints, and container deployment
CORS
  cors.go never set Access-Control-Allow-Credentials, so the
  cookie-authenticated API was unreadable from any cross-origin frontend:
  the server answered correctly and the browser blocked the page from
  reading it. Set for allowlisted origins on both the preflight and the
  actual response. Three tests added.

  HTTP_COOKIE_SAMESITE (lax|none|strict, default lax) is new. CORS is only
  half of what a cross-origin browser call needs; SameSite is judged on
  registrable domain, so a frontend on an unrelated domain gets perfect CORS
  headers and still no cookie. "none" is the only value that survives that,
  and validate() refuses it without the Secure flag.

  The "*" rejection now explains itself: browsers refuse Allow-Origin "*"
  together with credentials, so it would break every authenticated call
  rather than loosen anything.

Transactional endpoints (api-contract.md 12.1)
  POST /api/v1/job-applications/{id}/hire
  POST /api/v1/job-postings/{id}/assignments

  Replaces two client-side loops that wrote several records with no
  transaction and no rollback. Each is now one endpoint and one transaction,
  built over repo.Repo so org scoping, derived columns, type casts and error
  translation are not re-derived. Authorization reuses the existing policy
  table rather than adding a parallel one: a workflow is exactly as
  privileged as the writes it performs. 13 tests, including both rollback
  paths.

Bug fix in the repository layer
  repo.bindValue handled int64/int/float64/string but not int32, which is
  what pgx returns for a PostgreSQL `int` column. Nothing previously read a
  record and wrote one of its fields elsewhere, so it never surfaced; the
  hire flow does exactly that and failed with "ai_score must be a number".
  Both KindInt and KindFloat now accept the widths pgx actually produces.

Deployment
  infrastructure/Dockerfile.api  multi-stage, cross-compiling (BUILDPLATFORM
    + GOARCH) so linux/amd64 builds from arm64 are compiled rather than
    emulated. Alpine runtime, non-root uid 10001, 22.1 MB. Ships api, seed,
    setpassword and migrate, plus the migrations, so a Kubernetes
    initContainer can apply the schema from the same image and tag as the
    API. HEALTHCHECK keys on status code, not body, so a "degraded" instance
    is not pulled from rotation during a migration window.

  infrastructure/docker-compose.yml  migrations run to completion before the
    API starts. Assumes a managed PostgreSQL; the local-db overlay adds one
    with TLS enabled so APP_ENV=production is met rather than dodged.

  scripts/drop_public_tables.go  the one-off used to clear an unrelated
    schema from krowdb on 2026-08-24, kept for the record. Build-tagged
    ignore and gated on CONFIRM_DROP=yes.

Verified against PostgreSQL: 16/16 new tests pass, and the image was built,
run and exercised end to end (login, CORS preflight, authenticated reads,
transaction rollback).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmQiGq73Uyfq7J4yR8Vxxw
2026-08-25 11:33:01 +05:30

402 lines
16 KiB
Go

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" }
// sameSite resolves the configured SameSite mode.
//
// Lax remains the default and the recommendation. "none" exists for the one
// deployment shape that cannot work without it: a frontend on a different
// registrable domain from the API. In that case Lax withholds the cookie on
// every cross-site fetch, so the sign-in succeeds, the Set-Cookie arrives, and
// the next request carries nothing — which reads as a broken session rather
// than as a cookie policy.
//
// An unrecognised value falls back to Lax rather than to None. config.validate
// rejects those before startup, so this is only a belt-and-braces default in
// the safe direction.
func (s *Server) sameSite() http.SameSite {
switch s.cfg.HTTP.CookieSameSite {
case "none":
return http.SameSiteNoneMode
case "strict":
return http.SameSiteStrictMode
default:
return http.SameSiteLaxMode
}
}
// 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 by default, and Strict/None available through
// HTTP_COOKIE_SAMESITE. 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 sends it on cross-site requests,
// which is the CSRF hole Lax exists to close — and is nonetheless the
// only workable value when the frontend is on a different registrable
// domain. See Server.sameSite.
SameSite: s.sameSite(),
Secure: s.secureCookies(),
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,
// Must match the attributes it was set with, SameSite included, or the
// browser treats this as a different cookie and leaves the original.
SameSite: s.sameSite(),
Secure: s.secureCookies(),
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 := 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,
}
// 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)
}