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).
This commit is contained in:
2026-08-13 23:00:35 -07:00
parent 1d57e697b1
commit 1fab02abd5
10 changed files with 752 additions and 50 deletions
+37 -14
View File
@@ -2,17 +2,17 @@
// service (commercial license, not AGPL) -- see
// /docs/phase-4-isolation-design.md and /docs/phase-4-rbac-design.md.
//
// Phase 4 task 5 adds session issuance/validation (internal/session) and
// the POST /internal/authorize endpoint api/authz.HTTPAuthorizer
// calls -- the piece that actually turns on RBAC enforcement in /api.
// Still deliberately missing: the OIDC/SAML login/callback HTTP handlers
// that would issue a *human* session after a real IdP round trip, and
// internal/rbacstore (the org/tenant/user/role Postgres storage those
// handlers need to look up a role from). Both depend on RBAC storage
// that wasn't built in task 3's scope and are called out as deferred
// rather than half-built -- see the task 5 summary. What IS wired end to
// end: minting and validating the RoleService credential /alerting
// presents, via -mint-service-token below.
// Wires session issuance/validation (internal/session), the
// POST /internal/authorize endpoint api/authz.HTTPAuthorizer calls (the
// piece that turns on RBAC enforcement in /api), and -- since
// internal/loginhandler -- the real GET /auth/oidc/login and
// GET /auth/oidc/callback handlers that issue a *human* session after
// an actual IdP round trip, resolving tenant/role via internal/rbacstore.
// Still deliberately missing: SAML's equivalent (ACS endpoint) -- same
// shape, not yet built, following internal/loginhandler's OIDC pattern
// once it is. What's fully wired: -mint-service-token (the RoleService
// credential /alerting presents) and, when OIDC_ISSUER_URL is
// configured, a real human login flow.
package main
import (
@@ -27,9 +27,13 @@ import (
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/sentry/sentry/enterprise/internal/authhandler"
"github.com/sentry/sentry/enterprise/internal/config"
"github.com/sentry/sentry/enterprise/internal/loginhandler"
"github.com/sentry/sentry/enterprise/internal/oidc"
"github.com/sentry/sentry/enterprise/internal/rbacstore"
"github.com/sentry/sentry/enterprise/internal/session"
)
@@ -78,18 +82,36 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
pgDSN := fmt.Sprintf("postgres://%s:%s@%s/%s", cfg.Postgres.Username, cfg.Postgres.Password, cfg.Postgres.Addr, cfg.Postgres.Database)
pgPool, err := pgxpool.New(ctx, pgDSN)
if err != nil {
logger.Error("opening postgres pool", "error", err)
os.Exit(1)
}
defer pgPool.Close()
if err := pgPool.Ping(ctx); err != nil {
logger.Error("pinging postgres", "error", err)
os.Exit(1)
}
rbac := rbacstore.NewStore(pgPool)
// oidcProvider stays nil (loginhandler.RegisterRoutes then registers
// nothing) unless OIDC is actually configured -- matches every other
// optional-config path in this codebase.
var oidcProvider *oidc.Provider
if cfg.OIDC.IssuerURL != "" {
if _, err := oidc.New(ctx, oidc.Config{
oidcProvider, err = oidc.New(ctx, oidc.Config{
IssuerURL: cfg.OIDC.IssuerURL, ClientID: cfg.OIDC.ClientID,
ClientSecret: cfg.OIDC.ClientSecret, RedirectURL: cfg.OIDC.RedirectURL,
Scopes: []string{"email", "profile"},
}); err != nil {
})
if err != nil {
logger.Error("discovering OIDC issuer", "error", err)
os.Exit(1)
}
logger.Info("OIDC provider configured", "issuer", cfg.OIDC.IssuerURL)
} else {
logger.Info("OIDC not configured (OIDC_ISSUER_URL unset) -- skipping discovery")
logger.Info("OIDC not configured (OIDC_ISSUER_URL unset) -- skipping discovery, /auth/oidc/* routes disabled")
}
mux := http.NewServeMux()
@@ -101,6 +123,7 @@ func main() {
SAMLEnabled: cfg.SAML.IDPMetadataURL != "",
}
authhandler.New(logger, sessionManager, features).RegisterRoutes(mux)
loginhandler.New(logger, oidcProvider, sessionManager, rbac, cfg.PostLoginRedirectURL).RegisterRoutes(mux)
srv := &http.Server{Addr: cfg.HTTPListenAddr, Handler: mux}