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:
@@ -13,6 +13,10 @@ type Config struct {
|
||||
OIDC OIDCConfig
|
||||
SAML SAMLConfig
|
||||
SessionSigningKey []byte
|
||||
// PostLoginRedirectURL is where the browser lands after
|
||||
// internal/loginhandler sets a session cookie -- web's base URL in
|
||||
// a real deployment.
|
||||
PostLoginRedirectURL string
|
||||
}
|
||||
|
||||
type PostgresConfig struct {
|
||||
@@ -66,6 +70,7 @@ func Load() (Config, error) {
|
||||
ACSURL: getenv("SAML_ACS_URL", ""),
|
||||
IDPMetadataURL: getenv("SAML_IDP_METADATA_URL", ""),
|
||||
},
|
||||
PostLoginRedirectURL: getenv("POST_LOGIN_REDIRECT_URL", "http://localhost:3000"),
|
||||
}
|
||||
|
||||
// Required, unlike OIDC/SAML above: every enterprise-auth deployment
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
// Uses coreos/go-oidc's own oidctest package (a real fake OIDC IdP --
|
||||
// serves discovery + JWKS, signs real RS256 ID tokens) plus a small
|
||||
// local /token handler (oidctest doesn't implement the OAuth2 code
|
||||
// exchange itself, only ID token signing/verification) to exercise the
|
||||
// FULL login flow -- login redirect, a real signed-and-verified ID
|
||||
// token round trip, user upsert, tenant/role resolution, and session
|
||||
// cookie issuance -- with real cryptographic verification, not mocked.
|
||||
package loginhandler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc/oidctest"
|
||||
|
||||
"github.com/sentry/sentry/enterprise/internal/oidc"
|
||||
"github.com/sentry/sentry/enterprise/internal/rbacstore"
|
||||
"github.com/sentry/sentry/enterprise/internal/session"
|
||||
)
|
||||
|
||||
const testClientID = "sentry-test-client"
|
||||
const testKeyID = "test-key-1"
|
||||
|
||||
// fakeUserStore is an in-memory stand-in for *rbacstore.Store, keyed by
|
||||
// SSO subject -- enough to drive resolveIdentity's logic without a real
|
||||
// Postgres.
|
||||
type fakeUserStore struct {
|
||||
usersBySubject map[string]*rbacstore.User
|
||||
memberships map[string][]rbacstore.Membership // by user ID
|
||||
}
|
||||
|
||||
func newFakeUserStore() *fakeUserStore {
|
||||
return &fakeUserStore{usersBySubject: map[string]*rbacstore.User{}, memberships: map[string][]rbacstore.Membership{}}
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) UpsertUserBySSO(_ context.Context, ssoSubject, email, displayName string) (*rbacstore.User, error) {
|
||||
if u, ok := f.usersBySubject[ssoSubject]; ok {
|
||||
u.Email, u.DisplayName = email, displayName
|
||||
return u, nil
|
||||
}
|
||||
u := &rbacstore.User{ID: "user-" + ssoSubject, Email: email, DisplayName: displayName, SSOSubject: ssoSubject}
|
||||
f.usersBySubject[ssoSubject] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) ListMembershipsForUser(_ context.Context, userID string) ([]rbacstore.Membership, error) {
|
||||
return f.memberships[userID], nil
|
||||
}
|
||||
|
||||
// testIdP bundles a real oidctest.Server (discovery + JWKS) with a local
|
||||
// /token handler, and knows how to mint a validly-signed ID token for a
|
||||
// given subject/email -- everything a test needs to drive a real login
|
||||
// round trip.
|
||||
type testIdP struct {
|
||||
srv *httptest.Server
|
||||
priv *rsa.PrivateKey
|
||||
nextIDToken string
|
||||
}
|
||||
|
||||
func newTestIdP(t *testing.T) *testIdP {
|
||||
t.Helper()
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generating RSA key: %v", err)
|
||||
}
|
||||
idp := &testIdP{priv: priv}
|
||||
|
||||
oidcSrv := &oidctest.Server{
|
||||
PublicKeys: []oidctest.PublicKey{{PublicKey: priv.Public(), KeyID: testKeyID, Algorithm: "RS256"}},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "test-access-token",
|
||||
"id_token": idp.nextIDToken,
|
||||
"token_type": "Bearer",
|
||||
})
|
||||
})
|
||||
mux.Handle("/", oidcSrv)
|
||||
|
||||
idp.srv = httptest.NewServer(mux)
|
||||
oidcSrv.SetIssuer(idp.srv.URL)
|
||||
t.Cleanup(idp.srv.Close)
|
||||
return idp
|
||||
}
|
||||
|
||||
// setNextIDToken configures what /token returns on the next exchange --
|
||||
// a real RS256-signed JWT, verified for real by oidc.Provider.Exchange.
|
||||
func (idp *testIdP) setNextIDToken(t *testing.T, subject, email string, emailVerified bool, expiry time.Time) {
|
||||
t.Helper()
|
||||
claims := fmt.Sprintf(`{
|
||||
"iss": %q, "aud": %q, "sub": %q, "email": %q, "email_verified": %v,
|
||||
"iat": %d, "exp": %d
|
||||
}`, idp.srv.URL, testClientID, subject, email, emailVerified, time.Now().Unix(), expiry.Unix())
|
||||
idp.nextIDToken = oidctest.SignIDToken(idp.priv, testKeyID, "RS256", claims)
|
||||
}
|
||||
|
||||
func newTestOIDCProvider(t *testing.T, idp *testIdP) *oidc.Provider {
|
||||
t.Helper()
|
||||
p, err := oidc.New(context.Background(), oidc.Config{
|
||||
IssuerURL: idp.srv.URL, ClientID: testClientID, ClientSecret: "secret",
|
||||
RedirectURL: "http://sentry-test/auth/oidc/callback",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("oidc.New: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func newTestSessionManager(t *testing.T) *session.Manager {
|
||||
t.Helper()
|
||||
m, err := session.NewManager([]byte("this-is-a-32-byte-test-signing-key!"))
|
||||
if err != nil {
|
||||
t.Fatalf("session.NewManager: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestHandleLoginRedirectsAndSetsStateCookie(t *testing.T) {
|
||||
idp := newTestIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc == "" {
|
||||
t.Fatal("expected a Location header redirecting to the IdP")
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
var stateCookie *http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c.Name == stateCookieName {
|
||||
stateCookie = c
|
||||
}
|
||||
}
|
||||
if stateCookie == nil || stateCookie.Value == "" {
|
||||
t.Fatal("expected a non-empty state cookie to be set")
|
||||
}
|
||||
if !stateCookie.HttpOnly {
|
||||
t.Fatal("expected the state cookie to be HttpOnly")
|
||||
}
|
||||
}
|
||||
|
||||
// fullLoginFlow drives handleLogin then handleCallback end to end,
|
||||
// exactly the way a browser + IdP round trip would, and returns the
|
||||
// final response so callers can assert on it.
|
||||
func fullLoginFlow(t *testing.T, h *Handler, idp *testIdP) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
loginRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(loginRec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
|
||||
var stateCookie *http.Cookie
|
||||
for _, c := range loginRec.Result().Cookies() {
|
||||
if c.Name == stateCookieName {
|
||||
stateCookie = c
|
||||
}
|
||||
}
|
||||
if stateCookie == nil {
|
||||
t.Fatal("no state cookie from /auth/oidc/login")
|
||||
}
|
||||
|
||||
callbackReq := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state="+stateCookie.Value+"&code=test-code", nil)
|
||||
callbackReq.AddCookie(stateCookie)
|
||||
callbackRec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(callbackRec, callbackReq)
|
||||
return callbackRec
|
||||
}
|
||||
|
||||
func TestFullLoginFlowIssuesSessionForSingleMembership(t *testing.T) {
|
||||
idp := newTestIdP(t)
|
||||
store := newFakeUserStore()
|
||||
store.memberships["user-user-1"] = []rbacstore.Membership{{TenantID: "acme", UserID: "user-user-1", Role: rbacstore.RoleEditor}}
|
||||
sessionManager := newTestSessionManager(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), sessionManager, store, "http://web/")
|
||||
|
||||
idp.setNextIDToken(t, "user-1", "[email protected]", true, time.Now().Add(time.Hour))
|
||||
rec := fullLoginFlow(t, h, idp)
|
||||
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "http://web/" {
|
||||
t.Fatalf("Location = %q, want http://web/", loc)
|
||||
}
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "sentry_session" {
|
||||
sessionCookie = c
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil || sessionCookie.Value == "" {
|
||||
t.Fatal("expected a sentry_session cookie to be set")
|
||||
}
|
||||
claims, err := sessionManager.Validate(sessionCookie.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("validating issued session: %v", err)
|
||||
}
|
||||
if claims.TenantID != "acme" || claims.Role != "editor" || claims.UserID != "user-user-1" {
|
||||
t.Fatalf("unexpected session claims: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullLoginFlowRefusesNoMembership(t *testing.T) {
|
||||
idp := newTestIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
|
||||
idp.setNextIDToken(t, "user-2", "[email protected]", true, time.Now().Add(time.Hour))
|
||||
rec := fullLoginFlow(t, h, idp)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullLoginFlowRefusesMultipleMemberships(t *testing.T) {
|
||||
idp := newTestIdP(t)
|
||||
store := newFakeUserStore()
|
||||
store.memberships["user-user-3"] = []rbacstore.Membership{
|
||||
{TenantID: "acme", UserID: "user-user-3", Role: rbacstore.RoleViewer},
|
||||
{TenantID: "globex", UserID: "user-user-3", Role: rbacstore.RoleAdmin},
|
||||
}
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), store, "http://web/")
|
||||
|
||||
idp.setNextIDToken(t, "user-3", "[email protected]", true, time.Now().Add(time.Hour))
|
||||
rec := fullLoginFlow(t, h, idp)
|
||||
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("status = %d, want 501; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejectsStateMismatch(t *testing.T) {
|
||||
idp := newTestIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state=wrong&code=test-code", nil)
|
||||
req.AddCookie(&http.Cookie{Name: stateCookieName, Value: "correct"})
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejectsMissingStateCookie(t *testing.T) {
|
||||
idp := newTestIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/callback?state=whatever&code=test-code", nil))
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejectsExpiredIDToken(t *testing.T) {
|
||||
idp := newTestIdP(t)
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), newTestOIDCProvider(t, idp), newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
|
||||
idp.setNextIDToken(t, "user-4", "[email protected]", true, time.Now().Add(-time.Hour)) // already expired
|
||||
rec := fullLoginFlow(t, h, idp)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRoutesNoOpWhenOIDCNotConfigured(t *testing.T) {
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (no routes should be registered when oidc is nil)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterRoutesNoOpWithTypedNilProviderVariable is the regression
|
||||
// test for Go's typed-nil-interface trap: enterprise-auth's main.go
|
||||
// holds a `var provider *oidc.Provider` that stays nil when OIDC isn't
|
||||
// configured, then passes that *variable* (not a nil literal) into New.
|
||||
// If New ever goes back to assigning that pointer straight into the
|
||||
// oidcProvider interface field, this test starts failing -- the
|
||||
// interface would become non-nil (type=*oidc.Provider, value=nil) even
|
||||
// though the variable itself is nil, and RegisterRoutes' `h.oidc == nil`
|
||||
// check would stop working. TestRegisterRoutesNoOpWhenOIDCNotConfigured
|
||||
// above doesn't catch this: passing a nil literal directly never hits
|
||||
// the trap, only passing a nil-valued typed variable does.
|
||||
func TestRegisterRoutesNoOpWithTypedNilProviderVariable(t *testing.T) {
|
||||
var provider *oidc.Provider // stays nil -- exactly main.go's shape when OIDC_ISSUER_URL is unset
|
||||
h := New(slog.New(slog.NewTextHandler(io.Discard, nil)), provider, newTestSessionManager(t), newFakeUserStore(), "http://web/")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/oidc/login", nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404 (a typed-nil *oidc.Provider must still result in oidc routes being disabled)", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user