Files
cairnobs/enterprise/internal/loginhandler/loginhandler.go
T
jcoffey-dev 1fab02abd5 Phase 4: real OIDC human login (enterprise/internal/loginhandler)
Closes the other major named gap from this phase: until now, there was
no way for a human to actually log in -- only /alerting's RoleService
credential could be minted. GET /auth/oidc/login and GET
/auth/oidc/callback drive the real coreos/go-oidc flow already wired in
enterprise/internal/oidc: CSRF state in a short-lived cookie, code
exchange, ID token verification, upserting a users row, resolving
tenant/role from exactly one tenant_memberships row (refusing outright
on zero or more than one, rather than guessing), and issuing a real
session cookie.

Unlike everything else built this phase, this one is genuinely verified
end to end: the tests spin up coreos/go-oidc's own oidctest fake IdP,
which signs real RS256 ID tokens, and drive the full login->callback->
session-cookie round trip through actual signature verification -- no
live database or Docker needed, so nothing here is asserted without
having actually been run in this session. Also fixes a real bug caught
while wiring this into enterprise-auth's main.go: assigning a nil
*oidc.Provider to the handler's interface field would have produced a
non-nil interface wrapping a nil pointer (Go's classic typed-nil trap),
silently breaking the "OIDC not configured" no-op path -- New() now
takes the concrete pointer type and checks it before ever converting to
the interface, with a regression test pinning the fix down.

Still missing: SAML's equivalent (ACS endpoint), a tenant-picker UI for
multi-membership identities, and any admin UI to actually create a
tenant_memberships row (today that's manual SQL, documented in the
runbook's new bootstrap walkthrough).
2026-08-13 23:00:35 -07:00

219 lines
8.9 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 does the OAuth2/OIDC protocol mechanics
// (discovery, the auth-code redirect, code exchange, ID token
// verification); this package is the two HTTP handlers that drive it
// 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.
//
// Deliberately out of scope here: SAML's equivalent (ACS endpoint) --
// same shape, not yet built, following this package's pattern once it
// is. Multi-tenant users (one identity with memberships in more than
// one tenant) are refused with a clear error rather than guessing which
// tenant to log them into -- a tenant-selection step is real,
// undesigned future work, not silently approximated.
package loginhandler
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/sentry/sentry/enterprise/internal/authhandler"
"github.com/sentry/sentry/enterprise/internal/oidc"
"github.com/sentry/sentry/enterprise/internal/rbacstore"
"github.com/sentry/sentry/enterprise/internal/session"
)
// stateCookieName carries the 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 stateCookieName = "sentry_oidc_state"
// stateCookieTTL bounds how long a user has to complete the IdP round
// trip -- generous enough for a real login form, short enough that a
// stale state cookie isn't a long-lived CSRF token sitting in a browser.
const stateCookieTTL = 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)
}
// 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)
}
type Handler struct {
logger *slog.Logger
oidc oidcProvider // nil if OIDC isn't configured -- RegisterRoutes registers nothing in that case
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
}
// New takes a concrete *oidc.Provider (nilable), not the oidcProvider
// interface directly -- a nil *oidc.Provider assigned 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'/handleLogin's `h.oidc == nil` checks the moment a
// caller (enterprise-auth's main.go) passes a `var p *oidc.Provider`
// that's legitimately still nil because OIDC isn't configured. Checking
// the concrete pointer here, before it ever becomes the interface
// field, is what keeps that check meaningful.
func New(logger *slog.Logger, provider *oidc.Provider, sessionManager *session.Manager, users userStore, postLoginRedirectURL string) *Handler {
h := &Handler{logger: logger, session: sessionManager, users: users, postLoginRedirectURL: postLoginRedirectURL}
if provider != nil {
h.oidc = provider
}
return h
}
// RegisterRoutes registers OIDC's two routes only if OIDC is actually
// configured (h.oidc != nil) -- 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 {
return
}
mux.HandleFunc("GET /auth/oidc/login", h.handleLogin)
mux.HandleFunc("GET /auth/oidc/callback", h.handleCallback)
}
func (h *Handler) handleLogin(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: stateCookieName, Value: state, Path: "/auth/oidc/callback",
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
MaxAge: int(stateCookieTTL.Seconds()),
})
http.Redirect(w, r, h.oidc.AuthCodeURL(state), http.StatusFound)
}
// clearStateCookie is called on every path out of handleCallback --
// the state 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 clearStateCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: stateCookieName, Value: "", Path: "/auth/oidc/callback",
HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
defer clearStateCookie(w, r)
stateCookie, err := r.Cookie(stateCookieName)
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
}
identity, status, err := h.resolveIdentity(r.Context(), claims)
if err != nil {
h.logger.Error("resolving identity after oidc login", "error", err, "email", claims.Email)
http.Error(w, err.Error(), status)
return
}
token, err := h.session.IssueUserSession(identity.tenantID, identity.userID, identity.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: r.TLS != nil, SameSite: http.SameSiteLaxMode,
MaxAge: int(session.HumanSessionTTL.Seconds()),
})
http.Redirect(w, r, h.postLoginRedirectURL, http.StatusFound)
}
var (
// ErrNoMembership and ErrMultipleMemberships are exported so tests
// (and any future caller that wants to distinguish these outcomes,
// e.g. to render a real tenant-picker UI instead of a flat error
// page) don't have to string-match handleCallback's HTTP error body.
ErrNoMembership = errors.New("loginhandler: this identity has no tenant membership -- contact your administrator")
ErrMultipleMemberships = errors.New("loginhandler: this identity belongs to multiple tenants -- tenant selection is not supported yet")
)
type resolvedIdentity struct {
tenantID string
userID string
role string
}
// resolveIdentity is the policy decision this whole package exists to
// make: given a verified external identity, which tenant/role does it
// map to. Deliberately conservative -- exactly one tenant_memberships
// row is the only case handled; zero or multiple both refuse rather
// than guess (see this package's doc comment).
func (h *Handler) resolveIdentity(ctx context.Context, claims *oidc.Claims) (resolvedIdentity, int, error) {
user, err := h.users.UpsertUserBySSO(ctx, claims.Subject, claims.Email, claims.Email)
if err != nil {
return resolvedIdentity{}, http.StatusInternalServerError, fmt.Errorf("loginhandler: upserting user: %w", err)
}
memberships, err := h.users.ListMembershipsForUser(ctx, user.ID)
if err != nil {
return resolvedIdentity{}, http.StatusInternalServerError, fmt.Errorf("loginhandler: listing memberships: %w", err)
}
switch len(memberships) {
case 0:
return resolvedIdentity{}, http.StatusForbidden, ErrNoMembership
case 1:
return resolvedIdentity{tenantID: memberships[0].TenantID, userID: user.ID, role: string(memberships[0].Role)}, 0, nil
default:
return resolvedIdentity{}, http.StatusNotImplemented, ErrMultipleMemberships
}
}