A multi-membership identity (belongs to more than one tenant) used to
get a flat 501 refusal -- named as undesigned future work across
CLAUDE.md/threat-model.md/the runbook since early Phase 4. Scope for
this change was agreed via AskUserQuestion: backend protocol only,
fully verified via real HTTP round trips, not the actual picker page --
web has zero session/cookie-handling code today (confirmed while
researching this), so building that is separately-scoped, unverifiable
frontend work in this environment (no live backend, no browser).
session.Manager gains IssuePendingLogin/ValidatePendingLogin, a second
JWT token type proving identity without committing to a tenant yet
(10-minute TTL). PendingLoginClaims is deliberately a distinct Go type
from Claims, and -- caught by this change's own test suite before it
shipped -- needed a JSON field name disjoint from Claims.UserID's
"user_id" too: go-jose's unmarshal is happy to populate a struct from
any token whose claims happen to share a key, so a real session token
would otherwise have parsed successfully as a pending login. Fixed via
"pending_user_id" instead; both directions (session-as-pending,
pending-as-session) now have regression tests.
rbacstore.ListMembershipsWithTenantForUser joins tenant_memberships
with tenants, since a picker needs display names, not just IDs.
loginhandler.resolveIdentity's multiple-membership branch no longer
errors -- finishLogin routes it into startTenantSelection instead,
which issues a pending-login cookie (Path=/auth, so it's never sent on
ordinary requests) and redirects to a new configurable
SelectTenantRedirectURL (defaults to {POST_LOGIN_REDIRECT_URL}/select-
tenant). Two new routes complete the round trip: GET /auth/memberships
lists the pending identity's real tenant options, and POST
/auth/select-tenant re-derives the role for the chosen tenant
server-side (never trusts a client-supplied role, refuses a tenant_id
outside the identity's actual memberships with 403) before issuing the
real session -- responding with JSON {"redirect_url": ...}, not a
redirect, since a POST/fetch caller should control its own navigation.
Verified with the same real-fake-IdP tests the rest of this package
uses (coreos/go-oidc's oidctest, crewjam/saml's samlidp): the full
login -> pending cookie -> GET /auth/memberships -> POST
/auth/select-tenant -> real session round trip for both protocols, plus
negative paths (missing/expired pending cookie, a tenant_id outside
membership, a real session token rejected as a pending login and vice
versa). ErrMultipleMemberships is removed -- it's not an error path
anymore.
Docs updated in lockstep: CLAUDE.md, threat-model.md (including its
summary table), phase-4-runbook.md (new §12), enterprise/README.md
(new "Tenant selection" section, explicit about what's still not built
and why: no session handling in web, no CORS on enterprise-auth).
206 lines
8.1 KiB
Go
206 lines
8.1 KiB
Go
// Package session issues and validates the signed tokens enterprise-auth
|
|
// hands back to /api's authz.HTTPAuthorizer -- both human sessions
|
|
// (issued after a successful OIDC/SAML login) and the long-lived
|
|
// RoleService credential /alerting's queryclient presents as a Bearer
|
|
// token. HS256/JWT rather than a bespoke format: boring, well-understood,
|
|
// and go-jose is already a dependency via oidc.
|
|
//
|
|
// One shared signing key (ENTERPRISE_SESSION_SIGNING_KEY) issues and
|
|
// validates both kinds of token -- there is deliberately no separate key
|
|
// per token type, since the Role claim (not the key used) is what
|
|
// authz.Role.Satisfies enforces downstream.
|
|
package session
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
josev4 "github.com/go-jose/go-jose/v4"
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
)
|
|
|
|
// Claims mirrors api/authz.Identity's fields (TenantID, UserID,
|
|
// Role as a string) plus the standard registered JWT claims. Role is
|
|
// deliberately a plain string, not enterprise's own type, since its only
|
|
// consumer -- authz.Role -- is defined in core and this package must not
|
|
// import it (core must not import enterprise/, but the reverse also
|
|
// stays a network boundary here: this package has no reason to depend on
|
|
// api's Go types either).
|
|
type Claims struct {
|
|
TenantID string `json:"tenant_id,omitempty"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
Role string `json:"role"`
|
|
jwt.Claims
|
|
}
|
|
|
|
const (
|
|
// HumanSessionTTL matches a typical browser-session lifetime; re-auth
|
|
// happens via a fresh OIDC/SAML round trip, not silent refresh (no
|
|
// refresh-token flow is built yet -- named future work).
|
|
HumanSessionTTL = 12 * time.Hour
|
|
// ServiceTokenTTL is long-lived by design: /alerting runs as a
|
|
// continuously-deployed workload with no interactive re-auth path.
|
|
// Rotation is by redeploying alerting with a freshly issued token,
|
|
// not automatic refresh.
|
|
ServiceTokenTTL = 24 * 365 * time.Hour
|
|
// PendingLoginTTL is intentionally short -- a pending login only
|
|
// bridges the gap between "the IdP round trip proved who you are"
|
|
// and "you picked which tenant to act as" for a multi-membership
|
|
// identity (see loginhandler's package doc comment), a single-page
|
|
// interaction, not a session lifetime.
|
|
PendingLoginTTL = 10 * time.Minute
|
|
// MinSigningKeyBytes: HS256 wants a key at least as long as its
|
|
// output (32 bytes/256 bits) to not weaken the MAC.
|
|
MinSigningKeyBytes = 32
|
|
)
|
|
|
|
// ErrInvalidToken covers every validation failure (bad signature,
|
|
// malformed token, expired) -- deliberately not distinguished further so
|
|
// callers can't be tempted to treat "expired" as a softer case than
|
|
// "forged"; both mean "do not trust this caller."
|
|
var ErrInvalidToken = errors.New("session: invalid or expired token")
|
|
|
|
type Manager struct {
|
|
signer josev4.Signer
|
|
key []byte
|
|
}
|
|
|
|
func NewManager(signingKey []byte) (*Manager, error) {
|
|
if len(signingKey) < MinSigningKeyBytes {
|
|
return nil, fmt.Errorf("session: signing key must be at least %d bytes, got %d", MinSigningKeyBytes, len(signingKey))
|
|
}
|
|
signer, err := josev4.NewSigner(
|
|
josev4.SigningKey{Algorithm: josev4.HS256, Key: signingKey},
|
|
(&josev4.SignerOptions{}).WithType("JWT"),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("session: creating signer: %w", err)
|
|
}
|
|
return &Manager{signer: signer, key: signingKey}, nil
|
|
}
|
|
|
|
// IssueUserSession issues a human session token for a resolved
|
|
// tenant/user/role -- called only after a successful OIDC/SAML callback
|
|
// validates the caller's identity; this function trusts its inputs
|
|
// completely, same "one production call site, verified by review" shape
|
|
// as tenant.TrustFromValidatedSession.
|
|
func (m *Manager) IssueUserSession(tenantID, userID, role string) (string, error) {
|
|
now := time.Now()
|
|
claims := Claims{
|
|
TenantID: tenantID,
|
|
UserID: userID,
|
|
Role: role,
|
|
Claims: jwt.Claims{
|
|
Subject: userID,
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Expiry: jwt.NewNumericDate(now.Add(HumanSessionTTL)),
|
|
},
|
|
}
|
|
return jwt.Signed(m.signer).Claims(claims).Serialize()
|
|
}
|
|
|
|
// IssueServiceToken issues a RoleService credential for a named machine
|
|
// caller (subject identifies which one, e.g. "alerting", for audit/
|
|
// revocation bookkeeping). TenantID/UserID are deliberately left empty:
|
|
// per /docs/phase-4-isolation-design.md's alerting↔api gap, the caller's
|
|
// tenant is resolved server-side per-request from the resource being
|
|
// acted on (alert_rules.tenant_id), never taken from the token or the
|
|
// request body -- a service token proves "this caller is alerting," not
|
|
// "this caller may act as tenant X."
|
|
func (m *Manager) IssueServiceToken(subject string) (string, error) {
|
|
now := time.Now()
|
|
claims := Claims{
|
|
Role: "service",
|
|
Claims: jwt.Claims{
|
|
Subject: subject,
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Expiry: jwt.NewNumericDate(now.Add(ServiceTokenTTL)),
|
|
},
|
|
}
|
|
return jwt.Signed(m.signer).Claims(claims).Serialize()
|
|
}
|
|
|
|
// PendingLoginClaims carries a proven-but-not-yet-tenant-scoped identity
|
|
// through the multi-membership tenant-selection round trip -- see
|
|
// loginhandler.startTenantSelection/handleSelectTenant. Deliberately a
|
|
// different Go type from Claims, not the same struct with an empty
|
|
// TenantID/Role: a pending token's JSON body never has those keys at
|
|
// all, so there's no field a caller could mistake for a real session's
|
|
// tenant/role, and no risk of this token type ever satisfying a
|
|
// role-gated check by accident (see ValidatePendingLogin -- callers
|
|
// that mistakenly feed a pending token to Validate instead just get a
|
|
// Claims with an empty Role, which authz.Role.Satisfies already treats
|
|
// as satisfying nothing).
|
|
//
|
|
// The json tag is deliberately "pending_user_id", not the "user_id" a
|
|
// real Claims token also carries -- found while writing this package's
|
|
// own test for "a real session token must not work as a pending
|
|
// login": go-jose's Claims() unmarshal is happy to populate any struct
|
|
// field whose json tag matches a key present in the token, key overlap
|
|
// included, so reusing "user_id" would have let a full session token
|
|
// parse successfully as a PendingLoginClaims too (extracting UserID
|
|
// from the session's own user_id field) -- exactly the token-type
|
|
// confusion this type's separate-Go-type design was supposed to
|
|
// prevent. A disjoint field name closes that regardless of which
|
|
// fields either struct happens to add later.
|
|
type PendingLoginClaims struct {
|
|
UserID string `json:"pending_user_id"`
|
|
jwt.Claims
|
|
}
|
|
|
|
// IssuePendingLogin issues a short-lived token proving userID's identity
|
|
// (already resolved by resolveIdentity's UpsertUserBySSO) without
|
|
// committing to a tenant yet -- called only when that identity has more
|
|
// than one tenant_memberships row.
|
|
func (m *Manager) IssuePendingLogin(userID string) (string, error) {
|
|
now := time.Now()
|
|
claims := PendingLoginClaims{
|
|
UserID: userID,
|
|
Claims: jwt.Claims{
|
|
Subject: userID,
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
Expiry: jwt.NewNumericDate(now.Add(PendingLoginTTL)),
|
|
},
|
|
}
|
|
return jwt.Signed(m.signer).Claims(claims).Serialize()
|
|
}
|
|
|
|
// ValidatePendingLogin is IssuePendingLogin's counterpart -- same
|
|
// signature/expiry checks as Validate, collapsed to ErrInvalidToken for
|
|
// the same reasoning (see that method's doc comment).
|
|
func (m *Manager) ValidatePendingLogin(token string) (userID string, err error) {
|
|
parsed, err := jwt.ParseSigned(token, []josev4.SignatureAlgorithm{josev4.HS256})
|
|
if err != nil {
|
|
return "", ErrInvalidToken
|
|
}
|
|
var claims PendingLoginClaims
|
|
if err := parsed.Claims(m.key, &claims); err != nil {
|
|
return "", ErrInvalidToken
|
|
}
|
|
if err := claims.Claims.Validate(jwt.Expected{}); err != nil {
|
|
return "", ErrInvalidToken
|
|
}
|
|
if claims.UserID == "" {
|
|
return "", ErrInvalidToken
|
|
}
|
|
return claims.UserID, nil
|
|
}
|
|
|
|
// Validate verifies signature and expiry and returns the token's claims.
|
|
// Every failure mode collapses to ErrInvalidToken -- see its doc comment.
|
|
func (m *Manager) Validate(token string) (Claims, error) {
|
|
parsed, err := jwt.ParseSigned(token, []josev4.SignatureAlgorithm{josev4.HS256})
|
|
if err != nil {
|
|
return Claims{}, ErrInvalidToken
|
|
}
|
|
var claims Claims
|
|
if err := parsed.Claims(m.key, &claims); err != nil {
|
|
return Claims{}, ErrInvalidToken
|
|
}
|
|
if err := claims.Claims.Validate(jwt.Expected{}); err != nil {
|
|
return Claims{}, ErrInvalidToken
|
|
}
|
|
return claims, nil
|
|
}
|