Files
cairnobs/enterprise/internal/loginhandler/loginhandler.go
T
jcoffey-dev 13cf9a30cb Rebrand: Sentry -> Cairn OBS
Full rebrand across cosmetic branding, code identifiers, and
infrastructure/data-plane naming, using the supplied Cairn OBS logo
package. Cosmetic: favicon/logo swap (also closes a stale license-audit
finding -- the old favicon was SvelteKit's unreplaced scaffold logo),
new centered welcome landing page, larger/legible sidebar logo, page
titles, CLAUDE.md/README/docs prose.

Code identifiers: Go module path github.com/sentry/sentry ->
github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc
regenerated); Rust crates sentry-agent/sentry-parser/sentry-search ->
cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully
renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type,
env vars); every session/auth cookie name; agent config paths and
Windows service identity.

Deliberately preserved: the gRPC wire protocol's protobuf packages
(sentry.logs.v1, sentry.agent.v1) and their Go import directory
(proto/sentry/...) -- renaming the wire-level package would break every
currently-deployed agent binary (confirmed two real hosts, including
mail.inbuxa.com, are actively streaming through this exact contract)
until rebuilt and redeployed in lockstep with an ingest cutover. Only
the Go module path wrapping the generated code changes.

Infrastructure: every docker-compose container name (root and three
component-level compose files); the Helm chart (directory, Chart.yaml,
named-template helpers, all templates, values.yaml image repos);
Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML
files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd
package. Caught and fixed real path-coupling bugs along the way: the
Helm chart's search/ingest volume mounts and the dev-only-credential
detection constant vs. docker-compose.yml's literal values had to move
together or a security warning would have silently stopped firing.

Data plane: Postgres database sentry_metadata -> cairnobs_metadata and
role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka
topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups.
Source-level defaults, docker-compose.yml, and every migrate.sh/
provision script default updated together; already-applied migration
files left untouched per this repo's immutable-migration convention.

Verified at every layer: all 13 Go modules build/vet/test clean, both
Rust workspaces (agent, search) build/clippy/test clean, npm run check/
build clean, docker compose config validates on all four compose files.
Live-verified against a real docker stack multiple times through this
work, including a final fresh-volume run confirming the actual renamed
Postgres database/role, ClickHouse database, and Kafka topic all work
end to end with a real login and query, zero console errors.
2026-08-21 20:53:32 -07:00

512 lines
22 KiB
Go

// Package loginhandler is the piece named as missing throughout Phase 4:
// the actual HTTP login/callback flow that issues a *human* session,
// not just /alerting's RoleService credential (-mint-service-token) or
// the RBAC-enforcement plumbing that assumes a session already exists.
// enterprise/internal/oidc and enterprise/internal/saml do the protocol
// mechanics (discovery/AuthnRequest generation, code exchange/assertion
// parsing, signature verification); this package is the HTTP handlers
// that drive them and decide what happens with a verified identity: look
// up or create a users row, resolve which tenant/role that user belongs
// to, and issue a session.Manager-signed session cookie. Both protocols
// share that decision (resolveIdentity below) -- only how the identity
// gets verified differs.
//
// Multi-tenant users (one identity with memberships in more than one
// tenant) get a real tenant-selection step, not a guess: finishLogin
// issues a short-lived session.Manager "pending login" token (proves
// who they are, commits to no tenant yet) and redirects to
// selectTenantRedirectURL instead of issuing a session outright.
// GET /auth/memberships and POST /auth/select-tenant complete the round
// trip. web/src/routes/select-tenant is the frontend page that calls
// them, over credentialed cross-origin fetch (see
// httpserver.WithCredentialedCORS and config.CORSAllowedOrigin) -- see
// that route's own comments for the page itself.
package loginhandler
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/cairnobs/cairnobs/enterprise/internal/authhandler"
"github.com/cairnobs/cairnobs/enterprise/internal/oidc"
"github.com/cairnobs/cairnobs/enterprise/internal/rbacstore"
"github.com/cairnobs/cairnobs/enterprise/internal/saml"
"github.com/cairnobs/cairnobs/enterprise/internal/session"
)
// oidcStateCookieName carries OIDC's CSRF-protection state value between
// the login redirect and the callback -- a short-lived, scoped-to-the-
// callback-path cookie (the "double-submit cookie" pattern) rather than
// server-side state, since this service otherwise has no per-browser
// session store to put it in before a session exists.
const oidcStateCookieName = "cairnobs_oidc_state"
// samlRequestCookieName is SAML's analog -- carries the AuthnRequest ID
// LoginURL generated, so the ACS handler can pass it back to
// ParseResponse's possibleRequestIDs (SAML's actual replay/unsolicited-
// response defense -- see saml.ServiceProvider.LoginURL's doc comment).
const samlRequestCookieName = "cairnobs_saml_request"
// pendingLoginCookieName carries a PendingLoginClaims token from
// finishLogin's multi-membership branch through GET /auth/memberships
// and POST /auth/select-tenant -- Path "/auth" (not "/") so it's never
// sent on ordinary requests, only the two routes that need it.
const pendingLoginCookieName = "cairnobs_pending_login"
// loginCookieTTL bounds how long a user has to complete the IdP round
// trip -- generous enough for a real login form, short enough that a
// stale cookie isn't a long-lived CSRF token sitting in a browser.
// Shared by both protocols' cookies.
const loginCookieTTL = 10 * time.Minute
// userStore is the narrow interface Handler depends on -- *rbacstore.Store
// is the production implementation; tests use a fake, same pattern used
// throughout this codebase (api/dashboards, api/queryapi).
type userStore interface {
UpsertUserBySSO(ctx context.Context, ssoSubject, email, displayName string) (*rbacstore.User, error)
ListMembershipsForUser(ctx context.Context, userID string) ([]rbacstore.Membership, error)
// ListMembershipsWithTenantForUser backs GET /auth/memberships --
// the one caller that needs tenant display names, not just IDs/roles.
ListMembershipsWithTenantForUser(ctx context.Context, userID string) ([]rbacstore.MembershipWithTenant, error)
}
// oidcProvider is the narrow slice of *oidc.Provider Handler needs --
// letting tests substitute a provider pointed at a fake IdP without
// needing real OIDC discovery against something Handler's own tests
// would have to stand up twice.
type oidcProvider interface {
AuthCodeURL(state string) string
Exchange(ctx context.Context, code string) (*oidc.Claims, error)
}
// samlProvider mirrors oidcProvider's reasoning for SAML.
type samlProvider interface {
LoginURL(relayState string) (redirectURL, requestID string, err error)
ParseResponse(r *http.Request, possibleRequestIDs []string) (*saml.Claims, error)
}
type Handler struct {
logger *slog.Logger
oidc oidcProvider // nil if OIDC isn't configured -- RegisterRoutes registers nothing in that case
saml samlProvider // nil if SAML isn't configured -- same
session *session.Manager
users userStore
// postLoginRedirectURL is where the browser lands after a session
// cookie is set -- web's base URL in a real deployment.
postLoginRedirectURL string
// selectTenantRedirectURL is where the browser lands instead, when
// the identity has more than one tenant_memberships row -- see this
// package's doc comment.
selectTenantRedirectURL string
}
// New takes concrete *oidc.Provider/*saml.ServiceProvider (both
// nilable), not the narrower interfaces directly -- assigning a nil
// pointer straight into an interface-typed field would produce a
// non-nil interface wrapping a nil pointer (Go's classic typed-nil
// trap), which would silently break RegisterRoutes'/the handlers'
// `h.oidc == nil`/`h.saml == nil` checks the moment a caller
// (enterprise-auth's main.go) passes a `var p *oidc.Provider` that's
// legitimately still nil because that protocol isn't configured.
// Checking the concrete pointers here, before they ever become the
// interface fields, is what keeps those checks meaningful -- see
// loginhandler_test.go's TestRegisterRoutesNoOpWithTypedNilProviderVariable
// for the regression test that caught this the first time (OIDC; SAML
// follows the same fix from day one).
func New(logger *slog.Logger, oidcProvider *oidc.Provider, samlProvider *saml.ServiceProvider, sessionManager *session.Manager, users userStore, postLoginRedirectURL, selectTenantRedirectURL string) *Handler {
h := &Handler{logger: logger, session: sessionManager, users: users, postLoginRedirectURL: postLoginRedirectURL, selectTenantRedirectURL: selectTenantRedirectURL}
if oidcProvider != nil {
h.oidc = oidcProvider
}
if samlProvider != nil {
h.saml = samlProvider
}
return h
}
// RegisterRoutes registers each protocol's routes only if that protocol
// is actually configured -- matches the "absent, not broken" default
// every other optional-config path in this codebase follows (e.g.
// api/authz.RequireRole's nil-authorizer no-op).
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
if h.oidc != nil {
mux.HandleFunc("GET /auth/oidc/login", h.handleOIDCLogin)
mux.HandleFunc("GET /auth/oidc/callback", h.handleOIDCCallback)
}
if h.saml != nil {
mux.HandleFunc("GET /auth/saml/login", h.handleSAMLLogin)
mux.HandleFunc("POST /auth/saml/acs", h.handleSAMLACS)
}
if h.oidc != nil || h.saml != nil {
mux.HandleFunc("GET /auth/memberships", h.handleListMemberships)
mux.HandleFunc("POST /auth/select-tenant", h.handleSelectTenant)
}
}
func (h *Handler) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
state, err := oidc.NewState()
if err != nil {
h.logger.Error("generating oidc state", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: oidcStateCookieName, Value: state, Path: "/auth/oidc/callback",
HttpOnly: true, Secure: isSecureRequest(r), SameSite: http.SameSiteLaxMode,
MaxAge: int(loginCookieTTL.Seconds()),
})
http.Redirect(w, r, h.oidc.AuthCodeURL(state), http.StatusFound)
}
// clearCookie is called on every path out of the two callback handlers
// below -- the state/request cookie is single-use regardless of whether
// the login ultimately succeeds, same reasoning a CSRF token gets
// discarded after one use rather than left around for reuse.
func clearCookie(w http.ResponseWriter, r *http.Request, name, path string) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: "", Path: path,
HttpOnly: true, Secure: isSecureRequest(r), SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// isSecureRequest decides every cookie's Secure attribute in this file.
// r.TLS != nil alone is wrong for the deployment shape this handler
// actually runs in: enterprise-auth doesn't terminate TLS itself (see
// its own README/Dockerfile -- it's a plain http.Server, same as every
// other service here), so in any real deployment TLS is terminated at a
// reverse proxy/ingress in front of it, and r.TLS is nil at this process
// even though the original client connection was HTTPS. Confirmed live:
// the SAML ACS cookie (SameSite=None, which the cookie spec requires to
// be paired with Secure) came back without Secure behind a real
// TLS-terminating nginx proxy, and Chrome silently drops such a cookie
// -- breaking the entire SAML flow, not just weakening it. Trusting
// X-Forwarded-Proto here doesn't introduce a new trust boundary: this
// handler already trusts its network position (it's meant to sit behind
// exactly this kind of proxy, never directly internet-facing -- see
// /docs/security/threat-model.md's deployment/network assumptions).
func isSecureRequest(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
func (h *Handler) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
defer clearCookie(w, r, oidcStateCookieName, "/auth/oidc/callback")
stateCookie, err := r.Cookie(oidcStateCookieName)
if err != nil || stateCookie.Value == "" {
http.Error(w, "missing or expired login state -- start over at /auth/oidc/login", http.StatusBadRequest)
return
}
if r.URL.Query().Get("state") != stateCookie.Value {
http.Error(w, "state mismatch -- possible CSRF, start over at /auth/oidc/login", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing code parameter", http.StatusBadRequest)
return
}
claims, err := h.oidc.Exchange(r.Context(), code)
if err != nil {
h.logger.Error("exchanging oidc code", "error", err)
http.Error(w, "login failed", http.StatusUnauthorized)
return
}
if claims.Email == "" {
http.Error(w, "identity provider did not return an email claim", http.StatusUnauthorized)
return
}
h.finishLogin(w, r, claims.Subject, claims.Email)
}
func (h *Handler) handleSAMLLogin(w http.ResponseWriter, r *http.Request) {
// relayState isn't used to carry anything here (postLoginRedirectURL
// is a fixed server-side config, not per-request) -- still generated
// fresh per login and round-tripped, since crewjam/saml's API expects
// one and an empty/constant value would be a needless deviation from
// how a real SP-initiated flow looks.
relayState, err := oidc.NewState() // same random-value generator, protocol-agnostic despite the package name
if err != nil {
h.logger.Error("generating saml relay state", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
redirectURL, requestID, err := h.saml.LoginURL(relayState)
if err != nil {
h.logger.Error("building saml login url", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// SameSiteNoneMode, not Lax like OIDC's state cookie: SAML's
// HTTP-POST binding means the browser POSTs to /auth/saml/acs
// *from the IdP's origin* -- a cross-site POST, which SameSite=Lax
// cookies are never sent on (Lax only exempts top-level GET
// navigations, which is what OIDC's redirect-based callback is, but
// SAML's response delivery isn't). SameSite=None requires Secure
// per the cookie spec, so this genuinely needs the deployment to be
// on HTTPS -- realistic for any real SAML IdP integration (they
// require it too), but worth stating plainly: unlike OIDC, SAML
// login will not work correctly over plain HTTP.
http.SetCookie(w, &http.Cookie{
Name: samlRequestCookieName, Value: requestID, Path: "/auth/saml/acs",
HttpOnly: true, Secure: isSecureRequest(r), SameSite: http.SameSiteNoneMode,
MaxAge: int(loginCookieTTL.Seconds()),
})
http.Redirect(w, r, redirectURL, http.StatusFound)
}
func (h *Handler) handleSAMLACS(w http.ResponseWriter, r *http.Request) {
defer clearCookie(w, r, samlRequestCookieName, "/auth/saml/acs")
requestCookie, err := r.Cookie(samlRequestCookieName)
if err != nil || requestCookie.Value == "" {
http.Error(w, "missing or expired login state -- start over at /auth/saml/login", http.StatusBadRequest)
return
}
claims, err := h.saml.ParseResponse(r, []string{requestCookie.Value})
if err != nil {
h.logger.Error("parsing saml response", "error", err)
http.Error(w, "login failed", http.StatusUnauthorized)
return
}
if claims.Email == "" {
http.Error(w, "identity provider did not return an email attribute", http.StatusUnauthorized)
return
}
if claims.NameID == "" {
http.Error(w, "identity provider did not return a NameID", http.StatusUnauthorized)
return
}
h.finishLogin(w, r, claims.NameID, claims.Email)
}
// finishLogin is the point both protocols converge on: a verified
// (subject, email) pair, still needing tenant/role resolution --
// everything from here down is protocol-agnostic.
func (h *Handler) finishLogin(w http.ResponseWriter, r *http.Request, subject, email string) {
outcome, status, err := h.resolveIdentity(r.Context(), subject, email)
if err != nil {
h.logger.Error("resolving identity after login", "error", err, "email", email)
http.Error(w, err.Error(), status)
return
}
if outcome.ambiguous {
h.startTenantSelection(w, r, outcome.userID)
return
}
h.issueSessionAndRedirect(w, r, outcome.identity.tenantID, outcome.identity.userID, outcome.identity.role)
}
// issueSessionAndRedirect is finishLogin's single-membership path and
// handleSelectTenant's success path converging on the same "issue a
// real session, set the cookie, send the browser on" logic -- the only
// difference between the two callers is how they got a
// (tenantID, userID, role) tuple to issue a session for.
func (h *Handler) issueSessionAndRedirect(w http.ResponseWriter, r *http.Request, tenantID, userID, role string) {
token, err := h.session.IssueUserSession(tenantID, userID, role)
if err != nil {
h.logger.Error("issuing session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: authhandler.SessionCookieName, Value: token, Path: "/",
HttpOnly: true, Secure: isSecureRequest(r), SameSite: http.SameSiteLaxMode,
MaxAge: int(session.HumanSessionTTL.Seconds()),
})
http.Redirect(w, r, h.postLoginRedirectURL, http.StatusFound)
}
// startTenantSelection issues a pending-login token for an identity
// with more than one tenant_memberships row and sends the browser to
// selectTenantRedirectURL instead of completing the login -- the real
// tenant-selection step named as a gap throughout Phase 4's docs, not a
// refusal anymore (see this package's doc comment).
func (h *Handler) startTenantSelection(w http.ResponseWriter, r *http.Request, userID string) {
token, err := h.session.IssuePendingLogin(userID)
if err != nil {
h.logger.Error("issuing pending login", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: pendingLoginCookieName, Value: token, Path: "/auth",
HttpOnly: true, Secure: isSecureRequest(r), SameSite: http.SameSiteLaxMode,
MaxAge: int(session.PendingLoginTTL.Seconds()),
})
http.Redirect(w, r, h.selectTenantRedirectURL, http.StatusFound)
}
// pendingUserID validates the pending-login cookie GET /auth/memberships
// and POST /auth/select-tenant both require, writing an error response
// and returning ok=false if it's missing, expired, or forged.
func (h *Handler) pendingUserID(w http.ResponseWriter, r *http.Request) (userID string, ok bool) {
cookie, err := r.Cookie(pendingLoginCookieName)
if err != nil || cookie.Value == "" {
http.Error(w, "missing or expired pending login -- start over at /auth/oidc/login or /auth/saml/login", http.StatusBadRequest)
return "", false
}
userID, err = h.session.ValidatePendingLogin(cookie.Value)
if err != nil {
http.Error(w, "missing or expired pending login -- start over at /auth/oidc/login or /auth/saml/login", http.StatusUnauthorized)
return "", false
}
return userID, true
}
// membershipOption is one GET /auth/memberships response entry.
type membershipOption struct {
TenantID string `json:"tenant_id"`
TenantDisplayName string `json:"tenant_display_name"`
Role string `json:"role"`
}
// handleListMemberships lists the pending login's tenants to choose
// from -- a tenant-picker UI's first call, once one exists (see this
// package's doc comment).
func (h *Handler) handleListMemberships(w http.ResponseWriter, r *http.Request) {
userID, ok := h.pendingUserID(w, r)
if !ok {
return
}
memberships, err := h.users.ListMembershipsWithTenantForUser(r.Context(), userID)
if err != nil {
h.logger.Error("listing memberships with tenant", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
options := make([]membershipOption, 0, len(memberships))
for _, m := range memberships {
options = append(options, membershipOption{TenantID: m.TenantID, TenantDisplayName: m.TenantDisplayName, Role: string(m.Role)})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(options)
}
type selectTenantRequest struct {
TenantID string `json:"tenant_id"`
}
type selectTenantResponse struct {
RedirectURL string `json:"redirect_url"`
}
// handleSelectTenant completes the tenant-selection round trip: given a
// still-valid pending login and a chosen tenant_id, re-derives the
// membership/role for that specific tenant server-side (never trusting
// a client-supplied role -- only which tenant they claim to want,
// checked against their actual memberships) and issues the real
// session. Responds with JSON, not a redirect: this is a POST a
// tenant-picker page would call via fetch, which should decide for
// itself how to navigate afterward, not have a redirect response
// imposed on it the way the GET-based OIDC/SAML callbacks reasonably
// can.
func (h *Handler) handleSelectTenant(w http.ResponseWriter, r *http.Request) {
userID, ok := h.pendingUserID(w, r)
if !ok {
return
}
var body selectTenantRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.TenantID == "" {
http.Error(w, `invalid request body -- expected {"tenant_id": "..."}`, http.StatusBadRequest)
return
}
memberships, err := h.users.ListMembershipsForUser(r.Context(), userID)
if err != nil {
h.logger.Error("listing memberships", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var role string
found := false
for _, m := range memberships {
if m.TenantID == body.TenantID {
role = string(m.Role)
found = true
break
}
}
if !found {
http.Error(w, "no membership in the requested tenant", http.StatusForbidden)
return
}
clearCookie(w, r, pendingLoginCookieName, "/auth")
token, err := h.session.IssueUserSession(body.TenantID, userID, role)
if err != nil {
h.logger.Error("issuing session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: authhandler.SessionCookieName, Value: token, Path: "/",
HttpOnly: true, Secure: isSecureRequest(r), SameSite: http.SameSiteLaxMode,
MaxAge: int(session.HumanSessionTTL.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(selectTenantResponse{RedirectURL: h.postLoginRedirectURL})
}
var (
// ErrNoMembership is exported so tests (and any future caller that
// wants to distinguish this from other failures) don't have to
// string-match the HTTP error body.
ErrNoMembership = errors.New("loginhandler: this identity has no tenant membership -- contact your administrator")
)
type resolvedIdentity struct {
tenantID string
userID string
role string
}
// identityOutcome is resolveIdentity's result: exactly one membership
// resolves identity directly; more than one sets ambiguous (userID is
// always populated so the caller can start tenant selection without a
// second lookup).
type identityOutcome struct {
identity resolvedIdentity
userID string
ambiguous bool
}
// resolveIdentity is the policy decision this whole package exists to
// make: given a verified external identity (subject, email -- OIDC's
// "sub"/"email" claims or SAML's NameID/email attribute, already
// protocol-normalized by the caller), which tenant/role does it map to.
// Zero memberships refuses outright (ErrNoMembership) -- there's
// nothing to choose between. More than one is no longer a refusal (see
// this package's doc comment): finishLogin routes an ambiguous outcome
// into tenant selection instead of erroring.
func (h *Handler) resolveIdentity(ctx context.Context, subject, email string) (identityOutcome, int, error) {
user, err := h.users.UpsertUserBySSO(ctx, subject, email, email)
if err != nil {
return identityOutcome{}, http.StatusInternalServerError, fmt.Errorf("loginhandler: upserting user: %w", err)
}
memberships, err := h.users.ListMembershipsForUser(ctx, user.ID)
if err != nil {
return identityOutcome{}, http.StatusInternalServerError, fmt.Errorf("loginhandler: listing memberships: %w", err)
}
switch len(memberships) {
case 0:
return identityOutcome{}, http.StatusForbidden, ErrNoMembership
case 1:
return identityOutcome{identity: resolvedIdentity{tenantID: memberships[0].TenantID, userID: user.ID, role: string(memberships[0].Role)}, userID: user.ID}, 0, nil
default:
return identityOutcome{userID: user.ID, ambiguous: true}, 0, nil
}
}