Files
cairnobs/enterprise/internal/session/session_test.go
T
jcoffey-dev d2c76aa3a4 Build the tenant-picker backend protocol (no frontend yet, by design)
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).
2026-08-14 14:04:37 -07:00

220 lines
6.5 KiB
Go

package session
import (
"strings"
"testing"
"time"
"github.com/go-jose/go-jose/v4/jwt"
)
func testKey() []byte {
return []byte("this-is-a-32-byte-test-signing-key!")
}
func TestNewManagerRejectsShortKey(t *testing.T) {
if _, err := NewManager([]byte("too-short")); err == nil {
t.Fatal("expected an error for a signing key under 32 bytes")
}
}
func TestIssueAndValidateUserSession(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m.IssueUserSession("acme", "u1", "editor")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
claims, err := m.Validate(token)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if claims.TenantID != "acme" || claims.UserID != "u1" || claims.Role != "editor" {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestIssueAndValidateServiceToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m.IssueServiceToken("alerting")
if err != nil {
t.Fatalf("IssueServiceToken: %v", err)
}
claims, err := m.Validate(token)
if err != nil {
t.Fatalf("Validate: %v", err)
}
if claims.Role != "service" || claims.Subject != "alerting" {
t.Fatalf("unexpected claims: %+v", claims)
}
if claims.TenantID != "" || claims.UserID != "" {
t.Fatalf("service token must not carry a tenant/user -- tenant is resolved server-side per request, got %+v", claims)
}
}
func TestValidateRejectsTamperedToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m.IssueUserSession("acme", "u1", "viewer")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
// Flip a character in the payload segment to simulate tampering.
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("expected a 3-segment JWT, got %d segments", len(parts))
}
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := m.Validate(tampered); err != ErrInvalidToken {
t.Fatalf("Validate(tampered) error = %v, want ErrInvalidToken", err)
}
}
func TestValidateRejectsWrongKey(t *testing.T) {
m1, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
m2, err := NewManager([]byte("a-completely-different-32-byte-key!"))
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m1.IssueUserSession("acme", "u1", "viewer")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
if _, err := m2.Validate(token); err != ErrInvalidToken {
t.Fatalf("Validate with wrong key error = %v, want ErrInvalidToken", err)
}
}
func TestIssueAndValidatePendingLogin(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m.IssuePendingLogin("u1")
if err != nil {
t.Fatalf("IssuePendingLogin: %v", err)
}
userID, err := m.ValidatePendingLogin(token)
if err != nil {
t.Fatalf("ValidatePendingLogin: %v", err)
}
if userID != "u1" {
t.Fatalf("userID = %q, want u1", userID)
}
}
func TestValidatePendingLoginRejectsTamperedToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
token, err := m.IssuePendingLogin("u1")
if err != nil {
t.Fatalf("IssuePendingLogin: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("expected a 3-segment JWT, got %d segments", len(parts))
}
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := m.ValidatePendingLogin(tampered); err != ErrInvalidToken {
t.Fatalf("ValidatePendingLogin(tampered) error = %v, want ErrInvalidToken", err)
}
}
// TestValidatePendingLoginRejectsRealSessionToken is the regression test
// for PendingLoginClaims.UserID's "pending_user_id" json tag (see that
// field's doc comment): a real user-session token must not parse as a
// valid pending login just because both structs happen to be signed by
// the same key.
func TestValidatePendingLoginRejectsRealSessionToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
sessionToken, err := m.IssueUserSession("acme", "u1", "editor")
if err != nil {
t.Fatalf("IssueUserSession: %v", err)
}
if _, err := m.ValidatePendingLogin(sessionToken); err != ErrInvalidToken {
t.Fatalf("ValidatePendingLogin(a real session token) error = %v, want ErrInvalidToken", err)
}
}
// TestValidateRejectsPendingLoginToken is the same regression in the
// other direction: a pending-login token must not validate as a usable
// session either (it carries no tenant_id/role at all, so it would be
// inert even if it somehow parsed, but this proves that directly rather
// than relying on downstream role checks alone).
func TestValidateRejectsPendingLoginToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
pendingToken, err := m.IssuePendingLogin("u1")
if err != nil {
t.Fatalf("IssuePendingLogin: %v", err)
}
claims, err := m.Validate(pendingToken)
if err != nil {
t.Fatalf("Validate(pending token): %v", err)
}
if claims.TenantID != "" || claims.UserID != "" || claims.Role != "" {
t.Fatalf("a pending-login token must not carry any tenant/user/role claims when read as a session, got %+v", claims)
}
}
func TestValidatePendingLoginRejectsExpiredToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
now := time.Now()
claims := PendingLoginClaims{
UserID: "u1",
Claims: jwt.Claims{
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Hour)),
Expiry: jwt.NewNumericDate(now.Add(-30 * time.Minute)),
},
}
token, err := jwt.Signed(m.signer).Claims(claims).Serialize()
if err != nil {
t.Fatalf("building an already-expired pending token: %v", err)
}
if _, err := m.ValidatePendingLogin(token); err != ErrInvalidToken {
t.Fatalf("ValidatePendingLogin(expired) error = %v, want ErrInvalidToken", err)
}
}
func TestValidateRejectsExpiredToken(t *testing.T) {
m, err := NewManager(testKey())
if err != nil {
t.Fatalf("NewManager: %v", err)
}
now := time.Now()
claims := Claims{
TenantID: "acme", UserID: "u1", Role: "viewer",
Claims: jwt.Claims{
IssuedAt: jwt.NewNumericDate(now.Add(-2 * time.Hour)),
Expiry: jwt.NewNumericDate(now.Add(-1 * time.Hour)),
},
}
token, err := jwt.Signed(m.signer).Claims(claims).Serialize()
if err != nil {
t.Fatalf("building an already-expired token: %v", err)
}
if _, err := m.Validate(token); err != ErrInvalidToken {
t.Fatalf("Validate(expired) error = %v, want ErrInvalidToken", err)
}
}