Every auth cookie loginhandler.go sets (OIDC state, SAML request, pending-login, session) decided Secure from r.TLS != nil alone -- correct only if enterprise-auth terminates TLS itself, which it never does (it's a plain http.Server, same as every other service here). In any real deployment, TLS is terminated at a reverse proxy/ingress in front of it, so r.TLS is nil at this process even over a genuinely HTTPS client connection. Found live: SAML's request-tracking cookie is SameSite=None (required, since the ACS POST is cross-site from the IdP's origin), which the cookie spec requires to be paired with Secure. Behind a real TLS-terminating nginx proxy, the cookie came back without Secure and Chrome silently dropped it -- breaking the SAML login flow entirely, not just weakening it. Fixed with isSecureRequest(r), which also checks X-Forwarded-Proto: https -- not a new trust boundary, since this handler already assumes it sits behind exactly this kind of proxy, never directly internet-facing.
512 lines
22 KiB
Go
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/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/saml"
|
|
"github.com/sentry/sentry/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 = "sentry_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 = "sentry_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 = "sentry_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
|
|
}
|
|
}
|