package auth import ( "encoding/json" "errors" "log/slog" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // Handler is the HTTP surface for auth. It owns cookie issuance and the public/tenant route split. type Handler struct { svc *Service csrf *security.CSRF cookieSecure bool allowedOrigins []string trustedProxyHeader string } // NewHandler builds the auth handler. trustedProxyHeader names the header carrying the real client IP // when the app sits behind a trusted reverse proxy (the app must not be directly exposed); empty uses // the TCP peer address. func NewHandler(svc *Service, csrf *security.CSRF, cookieSecure bool, allowedOrigins []string, trustedProxyHeader string) *Handler { return &Handler{svc: svc, csrf: csrf, cookieSecure: cookieSecure, allowedOrigins: allowedOrigins, trustedProxyHeader: trustedProxyHeader} } // SessionCookieName and CSRFCookieName return the cookie names that match the Secure flag, so the // __Host- prefix is only used when Secure is set (the prefix requires it). func (h *Handler) SessionCookieName() string { if h.cookieSecure { return SessionCookie } return sessionCookieInsecure } // CSRFCookieName returns the CSRF cookie name; the CSRF middleware must be built with the same value. func (h *Handler) CSRFCookieName() string { if h.cookieSecure { return CSRFCookie } return csrfCookieInsecure } // PublicRoutes mounts the pre tenant endpoints. They run OUTSIDE the session middleware (they operate // on a pre MFA session or no session) but the caller wraps them in the CSRF Origin allowlist. The // login, mfa, and accept handlers read the session cookie directly where they need a pending session. func (h *Handler) PublicRoutes(r chi.Router) { r.Get("/auth/csrf", h.issueCSRF) r.Post("/auth/login", h.login) r.Post("/auth/mfa/enroll", h.mfaEnroll) r.Post("/auth/mfa/activate", h.mfaActivate) r.Post("/auth/mfa/verify", h.mfaVerify) r.Post("/auth/mfa/recover", h.mfaRecover) r.Post("/auth/invite/accept", h.inviteAccept) r.Post("/auth/logout", h.logout) } // TenantRoutes mounts the authenticated endpoints. The caller mounts these INSIDE the session // middleware and the CSRF middleware; per route capabilities are applied here. func (h *Handler) TenantRoutes(r chi.Router) { r.Get("/auth/session", h.session) r.Post("/auth/logout-all", h.logoutAll) r.Post("/auth/password/change", h.passwordChange) r.With(security.RequireRole(security.RoleAdmin)).Get("/users", h.listUsers) r.With(security.RequireCapability(security.CapUserInvite)).Post("/users/invite", h.invite) r.With(security.RequireCapability(security.CapUserRoleChange)).Patch("/users/{id}/role", h.changeRole) r.With(security.RequireCapability(security.CapUserDisable)).Patch("/users/{id}/status", h.changeStatus) r.With(security.RequireCapability(security.CapUserDisable)).Post("/users/{id}/mfa/reset", h.resetMFA) r.With(security.RequireCapability(security.CapUserInvite)).Post("/users/{id}/recovery", h.reissueRecovery) } // SessionMiddleware resolves a validated active session into tenant context, replacing the dev header // resolver. It reads the __Host- session cookie, requires an active MFA satisfied session, and stashes // the authoritative org and the principal. Any failure is 403 before a handler runs (fail closed). func (h *Handler) SessionMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(h.SessionCookieName()) if err != nil || cookie.Value == "" { http.Error(w, "forbidden", http.StatusForbidden) return } principal, org, verr := h.svc.Validate(r.Context(), cookie.Value) if verr != nil { // The request is denied regardless, but the rejection audit must not vanish silently. if aerr := h.svc.auditSessionRejected(r.Context(), h.clientIP(r), r.UserAgent()); aerr != nil { slog.Error("auth: session rejection audit write failed", "error", aerr) } http.Error(w, "forbidden", http.StatusForbidden) return } ctx := tenant.WithUser(tenant.WithOrg(r.Context(), org), principal) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // ---- public handlers ---- func (h *Handler) issueCSRF(w http.ResponseWriter, r *http.Request) { token, err := h.csrf.Issue() if err != nil { writeError(w, http.StatusInternalServerError, "internal error") return } h.setCSRFCookie(w, token) writeJSON(w, http.StatusOK, map[string]string{"csrf_token": token}) } func (h *Handler) login(w http.ResponseWriter, r *http.Request) { var in LoginInput if !decode(w, r, &in) { return } res, err := h.svc.Login(r.Context(), in, h.clientIP(r), r.UserAgent()) if err != nil { writeAuthError(w, err) return } h.setSessionCookie(w, res.Token) writeJSON(w, http.StatusOK, res) } func (h *Handler) mfaEnroll(w http.ResponseWriter, r *http.Request) { token, ok := h.sessionToken(r) if !ok { writeError(w, http.StatusForbidden, "no session") return } res, err := h.svc.EnrollMFA(r.Context(), token) if err != nil { writeAuthError(w, err) return } writeJSON(w, http.StatusOK, res) } func (h *Handler) mfaActivate(w http.ResponseWriter, r *http.Request) { token, ok := h.sessionToken(r) if !ok { writeError(w, http.StatusForbidden, "no session") return } var in CodeInput if !decode(w, r, &in) { return } res, err := h.svc.ActivateMFA(r.Context(), token, in, h.clientIP(r), r.UserAgent()) if err != nil { writeAuthError(w, err) return } h.setSessionCookie(w, res.Token) writeJSON(w, http.StatusOK, res) } func (h *Handler) mfaVerify(w http.ResponseWriter, r *http.Request) { token, ok := h.sessionToken(r) if !ok { writeError(w, http.StatusForbidden, "no session") return } var in CodeInput if !decode(w, r, &in) { return } res, err := h.svc.VerifyTOTP(r.Context(), token, in, h.clientIP(r), r.UserAgent()) if err != nil { writeAuthError(w, err) return } h.setSessionCookie(w, res.Token) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (h *Handler) mfaRecover(w http.ResponseWriter, r *http.Request) { token, ok := h.sessionToken(r) if !ok { writeError(w, http.StatusForbidden, "no session") return } var in CodeInput if !decode(w, r, &in) { return } res, err := h.svc.RecoverMFA(r.Context(), token, in, h.clientIP(r), r.UserAgent()) if err != nil { writeAuthError(w, err) return } h.setSessionCookie(w, res.Token) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (h *Handler) inviteAccept(w http.ResponseWriter, r *http.Request) { var in AcceptInput if !decode(w, r, &in) { return } res, err := h.svc.AcceptInvite(r.Context(), in, h.clientIP(r), r.UserAgent()) if err != nil { writeAuthError(w, err) return } h.setSessionCookie(w, res.Token) writeJSON(w, http.StatusOK, res) } func (h *Handler) logout(w http.ResponseWriter, r *http.Request) { if token, ok := h.sessionToken(r); ok { _ = h.svc.Logout(r.Context(), token, h.clientIP(r), r.UserAgent()) } h.clearSessionCookie(w) writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) } // ---- tenant handlers ---- func (h *Handler) session(w http.ResponseWriter, r *http.Request) { info, err := h.svc.SessionInfo(r.Context()) writeOrError(w, info, err) } func (h *Handler) logoutAll(w http.ResponseWriter, r *http.Request) { token, _ := h.sessionToken(r) if err := h.svc.LogoutAll(r.Context(), token); err != nil { writeAuthError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) passwordChange(w http.ResponseWriter, r *http.Request) { token, ok := h.sessionToken(r) if !ok { writeError(w, http.StatusForbidden, "no session") return } var in ChangePasswordInput if !decode(w, r, &in) { return } res, err := h.svc.ChangePassword(r.Context(), token, in, h.clientIP(r), r.UserAgent()) if err != nil { writeAuthError(w, err) return } h.setSessionCookie(w, res.Token) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (h *Handler) listUsers(w http.ResponseWriter, r *http.Request) { users, err := h.svc.ListUsers(r.Context()) writeOrError(w, users, err) } func (h *Handler) invite(w http.ResponseWriter, r *http.Request) { var in InviteInput if !decode(w, r, &in) { return } res, err := h.svc.Invite(r.Context(), in, h.clientIP(r), r.UserAgent()) writeOrError(w, res, err) } func (h *Handler) changeRole(w http.ResponseWriter, r *http.Request) { var in RoleChangeInput if !decode(w, r, &in) { return } err := h.svc.ChangeRole(r.Context(), chi.URLParam(r, "id"), in, h.clientIP(r), r.UserAgent()) writeStatusOrError(w, err) } func (h *Handler) changeStatus(w http.ResponseWriter, r *http.Request) { var in StatusChangeInput if !decode(w, r, &in) { return } err := h.svc.ChangeStatus(r.Context(), chi.URLParam(r, "id"), in, h.clientIP(r), r.UserAgent()) writeStatusOrError(w, err) } func (h *Handler) resetMFA(w http.ResponseWriter, r *http.Request) { err := h.svc.ResetMFA(r.Context(), chi.URLParam(r, "id"), h.clientIP(r), r.UserAgent()) writeStatusOrError(w, err) } func (h *Handler) reissueRecovery(w http.ResponseWriter, r *http.Request) { res, err := h.svc.ReissueRecovery(r.Context(), chi.URLParam(r, "id"), h.clientIP(r), r.UserAgent()) writeOrError(w, res, err) } // ---- cookies ---- func (h *Handler) setSessionCookie(w http.ResponseWriter, token string) { http.SetCookie(w, &http.Cookie{ Name: h.SessionCookieName(), Value: token, Path: "/", HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode, }) } func (h *Handler) clearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: h.SessionCookieName(), Value: "", Path: "/", HttpOnly: true, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1, }) } // setCSRFCookie sets the companion CSRF cookie. It is NOT HttpOnly because the SPA reads it to echo // the token into the X-CSRF-Token header (the signed double submit pattern). func (h *Handler) setCSRFCookie(w http.ResponseWriter, token string) { http.SetCookie(w, &http.Cookie{ Name: h.CSRFCookieName(), Value: token, Path: "/", HttpOnly: false, Secure: h.cookieSecure, SameSite: http.SameSiteStrictMode, }) } // ---- helpers ---- func (h *Handler) sessionToken(r *http.Request) (string, bool) { c, err := r.Cookie(h.SessionCookieName()) if err != nil || c.Value == "" { return "", false } return c.Value, true } func decode(w http.ResponseWriter, r *http.Request, v any) bool { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return false } return true } func writeOrError(w http.ResponseWriter, v any, err error) { if err != nil { writeAuthError(w, err) return } writeJSON(w, http.StatusOK, v) } func writeStatusOrError(w http.ResponseWriter, err error) { if err != nil { writeAuthError(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // writeAuthError maps service errors to status codes. Credential and session failures are 401, never // leaking which factor failed; authorization failures are 403; missing tenant is 403; no database is // 503; bad input is 400. func writeAuthError(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrInvalidSession), errors.Is(err, ErrInvalidState): writeError(w, http.StatusUnauthorized, "unauthorized") case errors.Is(err, ErrAccountLocked): writeError(w, http.StatusTooManyRequests, "account temporarily locked") case errors.Is(err, ErrForbidden): writeError(w, http.StatusForbidden, "forbidden") case errors.Is(err, tenant.ErrNoTenant): writeError(w, http.StatusForbidden, "no validated tenant") case errors.Is(err, ErrNotFound): writeError(w, http.StatusNotFound, "not found") case errors.Is(err, ErrEmailInUse): writeError(w, http.StatusConflict, "email already in use") case errors.Is(err, security.ErrPasswordTooLong), errors.Is(err, ErrBadRequest): writeError(w, http.StatusBadRequest, "bad request") case errors.Is(err, ErrNoDatabase): writeError(w, http.StatusServiceUnavailable, "database not configured") default: writeError(w, http.StatusInternalServerError, "internal error") } } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // clientIP returns the caller's IP for throttling and the audit trail. When trustedProxyHeader is set // (the app is behind a trusted reverse proxy that overwrites it, and is not directly exposed) it reads // that header, otherwise it uses the unspoofable TCP peer address. It does NOT trust the leftmost // X-Forwarded-For (the spoofable value chi RealIP used). func (h *Handler) clientIP(r *http.Request) string { if h.trustedProxyHeader != "" { if v := strings.TrimSpace(r.Header.Get(h.trustedProxyHeader)); v != "" { return v } } host := r.RemoteAddr if i := strings.LastIndex(host, ":"); i >= 0 && !strings.Contains(host[i:], "]") { host = host[:i] } return strings.Trim(host, "[]") }