Files
MeshTender/internal/core/handoff_test.go
T
2026-07-30 20:55:52 -04:00

522 lines
20 KiB
Go

package core
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/jleight/meshtender/internal/auth"
"github.com/jleight/meshtender/internal/identity"
"github.com/jleight/meshtender/internal/store"
)
const (
testAuthHost = "auth.localhost"
testAppHost = "app.localhost"
testRootHost = "localhost"
testWWWHost = "www.localhost"
)
// hostEnv holds the host:port for each surface of a split-host test server.
type hostEnv struct{ auth, app, root, www string }
// splitServer stands up a server in full split-host mode (root/www/auth/app)
// against the test database, returning the store and the per-surface host:port.
// testPassword is the fixture password for sign-up/sign-in tests. It must satisfy
// auth.MinPasswordLen — asserted below — so raising the floor doesn't quietly break
// every credential test with a confusing "password too short" redirect.
const testPassword = "correct-horse-battery-staple"
// TestFixturePasswordMeetsFloor keeps the fixture honest if the floor moves again.
func TestFixturePasswordMeetsFloor(t *testing.T) {
t.Parallel()
if len(testPassword) < auth.MinPasswordLen {
t.Fatalf("testPassword is %d chars, below the %d-char floor — credential tests "+
"would fail with a misleading validation error", len(testPassword), auth.MinPasswordLen)
}
}
func splitServer(t *testing.T) (*store.Store, context.Context, *httptest.Server, hostEnv) {
t.Helper()
st, ctx, ts, h, _ := splitServerMail(t)
return st, ctx, ts, h
}
// splitServerMail is splitServer plus the captured outbound mail. Every test server
// gets a capturing sender (never a real provider, and never the log sender — which
// would print recovery links into test output), with mail reported as configured so
// the recovery UI is exercised rather than hidden.
func splitServerMail(t *testing.T) (*store.Store, context.Context, *httptest.Server, hostEnv, *fakeSender) {
t.Helper()
return splitServerWithMail(t, true)
}
// splitServerNoMail builds a server with no mail provider configured, for the tests
// that assert the email features hide themselves rather than offering recovery that
// can't be delivered.
func splitServerNoMail(t *testing.T) (*store.Store, context.Context, *httptest.Server, hostEnv) {
t.Helper()
st, ctx, ts, h, _ := splitServerWithMail(t, false)
return st, ctx, ts, h
}
func splitServerWithMail(t *testing.T, mailEnabled bool) (*store.Store, context.Context, *httptest.Server, hostEnv, *fakeSender) {
t.Helper()
st, ctx := coreStore(t)
masterKey := testMasterKey
idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey)
sender := &fakeSender{}
authSvc, err := auth.New(st, st.Pool(), auth.Config{
RPID: "localhost", RPDisplayName: "test",
RPOrigins: []string{"http://auth.localhost", "http://app.localhost"},
AppHost: testAppHost, AuthHost: testAuthHost, RootHost: testRootHost,
Mail: sender, MailEnabled: mailEnabled,
})
if err != nil {
t.Fatalf("auth: %v", err)
}
srv, err := NewServer(st, authSvc, idSvc, testConfig())
if err != nil {
t.Fatalf("server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
port := mustURL(t, ts.URL).Port()
hp := func(h string) string { return h + ":" + port }
return st, ctx, ts, hostEnv{auth: hp(testAuthHost), app: hp(testAppHost), root: hp(testRootHost), www: hp(testWWWHost)}, sender
}
// seedSession creates a user and establishes an authenticated app-host session
// by redeeming a handoff code at /session/callback, storing the session cookie
// in jar. Integration tests use this instead of the auth-host sign-in UI.
func seedSession(t *testing.T, ts *httptest.Server, st *store.Store, ctx context.Context, jar http.CookieJar, username string) *store.User {
t.Helper()
u, err := st.CreateUser(ctx, username, "")
if err != nil {
t.Fatalf("create user: %v", err)
}
loginID, err := st.CreateLogin(ctx, u.ID)
if err != nil {
t.Fatalf("create login: %v", err)
}
code, err := st.CreateAuthCode(ctx, u.ID, loginID, "/")
if err != nil {
t.Fatalf("create auth code: %v", err)
}
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/session/callback?code="+code+"&state=s1", nil)
req.AddCookie(&http.Cookie{Name: "mt_state", Value: "s1"})
client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("session callback: %v", err)
}
resp.Body.Close()
return u
}
// noRedirect is a client that surfaces redirects instead of following them.
func noRedirect() *http.Client {
return &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
}
// do issues a request to ts with an explicit Host header (so the dispatcher
// routes by hostname) and optional cookies.
func do(t *testing.T, ts *httptest.Server, host, path string, cookies ...*http.Cookie) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil)
if err != nil {
t.Fatalf("request: %v", err)
}
req.Host = host
for _, c := range cookies {
req.AddCookie(c)
}
resp, err := noRedirect().Do(req)
if err != nil {
t.Fatalf("do %s%s: %v", host, path, err)
}
return resp
}
// post issues a form POST with an explicit Host header and optional cookies.
func post(t *testing.T, ts *httptest.Server, host, path string, form url.Values, cookies ...*http.Cookie) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(form.Encode()))
if err != nil {
t.Fatalf("request: %v", err)
}
req.Host = host
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
resp, err := noRedirect().Do(req)
if err != nil {
t.Fatalf("post %s%s: %v", host, path, err)
}
return resp
}
func cookieByName(resp *http.Response, name string) *http.Cookie {
for _, c := range resp.Cookies() {
if c.Name == name {
return c
}
}
return nil
}
// TestRequireUserBouncesToAuthHost: an unauthenticated app-host request to a
// protected page redirects to the auth host's /login, carrying next and a state
// nonce that matches the host-only state cookie just set.
func TestRequireUserBouncesToAuthHost(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
resp := do(t, ts, h.app, "/repeaters")
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", resp.StatusCode)
}
loc, _ := url.Parse(resp.Header.Get("Location"))
if loc.Host != h.auth || loc.Path != "/login" {
t.Fatalf("Location = %q, want auth host /login", resp.Header.Get("Location"))
}
if got := loc.Query().Get("next"); got != "/repeaters" {
t.Fatalf("next = %q, want /repeaters", got)
}
state := loc.Query().Get("state")
c := cookieByName(resp, "mt_state")
if state == "" || c == nil || c.Value != state {
t.Fatalf("state cookie %v must match state param %q", c, state)
}
}
// TestAppHostLoginRedirectsToAuth: the credential UI does not live on the app
// host; /login there bounces to the auth host.
func TestAppHostLoginRedirectsToAuth(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
resp := do(t, ts, h.app, "/login")
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", resp.StatusCode)
}
if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.auth {
t.Fatalf("Location host = %q, want %q", loc.Host, h.auth)
}
}
// TestAuthHostServesLogin: the auth host renders the sign-in page in place.
func TestAuthHostServesLogin(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
resp := do(t, ts, h.auth, "/login")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
}
// TestHostRouting checks each surface is served on its own host: the root host
// serves public marketing + org discovery, www redirects to root, and the app
// host's / and /orgs require auth (bounce to the auth host).
func TestHostRouting(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
t.Run("root serves landing", func(t *testing.T) {
resp := do(t, ts, h.root, "/")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("root / = %d, want 200", resp.StatusCode)
}
})
t.Run("root serves public org directory", func(t *testing.T) {
resp := do(t, ts, h.root, "/orgs")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("root /orgs = %d, want 200", resp.StatusCode)
}
})
t.Run("www redirects to root", func(t *testing.T) {
resp := do(t, ts, h.www, "/orgs")
defer resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("www /orgs = %d, want 301", resp.StatusCode)
}
if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.root || loc.Path != "/orgs" {
t.Fatalf("www redirect = %q, want root /orgs", resp.Header.Get("Location"))
}
})
t.Run("app root bounces anonymous to auth", func(t *testing.T) {
resp := do(t, ts, h.app, "/")
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("app / = %d, want 303", resp.StatusCode)
}
if loc, _ := url.Parse(resp.Header.Get("Location")); loc.Host != h.auth {
t.Fatalf("app / redirect host = %q, want auth", loc.Host)
}
})
t.Run("app /orgs requires auth", func(t *testing.T) {
resp := do(t, ts, h.app, "/orgs")
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("app /orgs anon = %d, want 303 to auth", resp.StatusCode)
}
})
}
// TestSingleLogout verifies the single-revoke logout model: one POST to a host's
// /logout revokes the shared login row, dropping every host bound to it. An
// auth-host sign-in is handed off to the app so both sessions share one login row;
// logging out on the APP host then invalidates the AUTH host's SSO session on its
// next request — with no redirect chain between them. See docs/auth-cross-host.md.
func TestSingleLogout(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
// Sign up on the auth host: creates login row L and the auth SSO session, then
// hands off to the app with a single-use code carrying L.
su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"sharuser"}, "password": {testPassword}})
su.Body.Close()
sso := cookieByName(su, "meshtender_session")
if sso == nil {
t.Fatal("expected an SSO session from signup")
}
loc, _ := url.Parse(su.Header.Get("Location"))
code := loc.Query().Get("code")
if code == "" {
t.Fatalf("signup did not hand off to the app (Location %q)", su.Header.Get("Location"))
}
// Redeem the code on the app host, binding its session to the SAME row L.
cb := do(t, ts, h.app, "/session/callback?code="+code+"&state=s1", &http.Cookie{Name: "mt_state", Value: "s1"})
cb.Body.Close()
appSess := cookieByName(cb, "meshtender_session")
if appSess == nil {
t.Fatal("expected an app session from the callback")
}
// Both live: auth /login re-hands-off (303) rather than showing the form.
live := do(t, ts, h.auth, "/login?next=%2F&state=x", sso)
live.Body.Close()
if live.StatusCode != http.StatusSeeOther {
t.Fatalf("pre-logout auth /login = %d, want 303 handoff (SSO live)", live.StatusCode)
}
// Log out on the APP host: it revokes L and lands on the public root — no hop
// through the auth host.
out := post(t, ts, h.app, "/logout", url.Values{}, appSess)
out.Body.Close()
if loc, _ := url.Parse(out.Header.Get("Location")); out.StatusCode != http.StatusSeeOther || loc.Hostname() != testRootHost {
t.Fatalf("app logout = %d %q, want 303 to the root host", out.StatusCode, out.Header.Get("Location"))
}
// The AUTH host's SSO session is now dead too: /login shows the form (200)
// instead of re-handing-off, because ValidateSession dropped the revoked login.
after := do(t, ts, h.auth, "/login", sso)
after.Body.Close()
if after.StatusCode != http.StatusOK {
t.Fatalf("post-logout auth /login = %d, want 200 (SSO cleared via shared-login revoke)", after.StatusCode)
}
}
// TestAuthLogoutClearsSSO covers the auth host's own POST /logout: a visitor who
// authenticated on the auth host (e.g. for account settings) with no app session
// signs out there directly, revoking and clearing the SSO session.
func TestAuthLogoutClearsSSO(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"ssoonly"}, "password": {testPassword}})
su.Body.Close()
sso := cookieByName(su, "meshtender_session")
if sso == nil {
t.Fatal("expected an SSO session from signup")
}
out := post(t, ts, h.auth, "/logout", url.Values{}, sso)
out.Body.Close()
if loc, _ := url.Parse(out.Header.Get("Location")); out.StatusCode != http.StatusSeeOther || loc.Hostname() != testRootHost {
t.Fatalf("auth logout = %d %q, want 303 to the root host", out.StatusCode, out.Header.Get("Location"))
}
after := do(t, ts, h.auth, "/login", sso)
after.Body.Close()
if after.StatusCode != http.StatusOK {
t.Fatalf("post-logout auth /login = %d, want 200 (SSO cleared)", after.StatusCode)
}
}
// TestLogoutRejectsGet locks in that /logout is POST-only on both the auth and app
// hosts: a forged cross-site GET (e.g. <img src=".../logout">) must not sign
// anyone out. This is the rule (state-changing actions are POST) the old
// side-effecting GET /logout violated.
func TestLogoutRejectsGet(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
su := post(t, ts, h.auth, "/signup/password", url.Values{"username": {"getlogout"}, "password": {testPassword}})
su.Body.Close()
sso := cookieByName(su, "meshtender_session")
if sso == nil {
t.Fatal("expected an SSO session from signup")
}
for _, host := range []string{h.auth, h.app} {
g := do(t, ts, host, "/logout", sso)
g.Body.Close()
if g.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("GET %s/logout = %d, want 405 (POST-only)", host, g.StatusCode)
}
}
// The GET left the session untouched: auth /login still re-hands-off (SSO live).
after := do(t, ts, h.auth, "/login?next=%2F&state=x", sso)
after.Body.Close()
if after.StatusCode != http.StatusSeeOther {
t.Fatalf("auth /login after GET /logout = %d, want 303 (SSO still live)", after.StatusCode)
}
}
// TestAccountOnAuthHost verifies account/credential management lives on the auth
// host: the app host no longer serves /account, the auth host guards it with the
// SSO session, and an SSO-less visit returns LOCALLY to /account after login
// (rather than handing off to the app).
func TestAccountOnAuthHost(t *testing.T) {
t.Parallel()
_, _, ts, h := splitServer(t)
t.Run("app host no longer serves /account", func(t *testing.T) {
resp := do(t, ts, h.app, "/account")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("app /account = %d, want 404", resp.StatusCode)
}
})
t.Run("auth /account anon bounces to local login", func(t *testing.T) {
resp := do(t, ts, h.auth, "/account")
defer resp.Body.Close()
loc, _ := url.Parse(resp.Header.Get("Location"))
if resp.StatusCode != http.StatusSeeOther || loc.Path != "/login" || loc.Host != "" {
t.Fatalf("auth /account anon = %d %q, want 303 to local /login", resp.StatusCode, resp.Header.Get("Location"))
}
// Completing login carries the auth-local flag, so it returns to /account
// on the auth host — NOT a handoff to the app callback.
sess := cookieByName(resp, "meshtender_session")
if sess == nil {
t.Fatal("expected a session cookie carrying the auth-local flag")
}
fin := post(t, ts, h.auth, "/signup/password",
url.Values{"username": {"acctlocal"}, "password": {testPassword}}, sess)
fin.Body.Close()
if got := fin.Header.Get("Location"); got != "/account" {
t.Fatalf("post-login redirect = %q, want local /account (no handoff)", got)
}
})
t.Run("auth /account with SSO session renders", func(t *testing.T) {
su := post(t, ts, h.auth, "/signup/password",
url.Values{"username": {"acctsso"}, "password": {testPassword}})
su.Body.Close()
sso := cookieByName(su, "meshtender_session")
if sso == nil {
t.Fatal("expected an SSO session from signup")
}
resp := do(t, ts, h.auth, "/account", sso)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("auth /account with SSO = %d, want 200", resp.StatusCode)
}
})
}
// TestSessionCallback exercises the handoff redemption: a valid code + matching
// state establishes an app-host session; tampered state or an unknown code is
// rejected back to sign-in without a session.
func TestSessionCallback(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
appHost := h.app
u, err := st.CreateUser(ctx, "callbackuser", "")
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Run("happy path establishes a session", func(t *testing.T) {
loginID, _ := st.CreateLogin(ctx, u.ID)
code, _ := st.CreateAuthCode(ctx, u.ID, loginID, "/repeaters")
resp := do(t, ts, appHost, "/session/callback?code="+code+"&state=s1",
&http.Cookie{Name: "mt_state", Value: "s1"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", resp.StatusCode)
}
// The callback sets the app session, then bounces through the root host's
// identity beacon (carrying a fresh code) before landing on the app page.
sess := cookieByName(resp, "meshtender_session")
if sess == nil || sess.Value == "" {
t.Fatalf("expected an app session cookie to be set")
}
loc, _ := url.Parse(resp.Header.Get("Location"))
if loc.Host != h.root || loc.Path != "/session/beacon" || loc.Query().Get("code") == "" {
t.Fatalf("Location = %q, want root /session/beacon?code=...", resp.Header.Get("Location"))
}
// Driving the beacon on the root host drops a root identity cookie and
// forwards back to the app's requested page.
beacon := do(t, ts, h.root, "/session/beacon?code="+loc.Query().Get("code"))
defer beacon.Body.Close()
if beacon.StatusCode != http.StatusSeeOther {
t.Fatalf("beacon status = %d, want 303", beacon.StatusCode)
}
if rootSess := cookieByName(beacon, "meshtender_session"); rootSess == nil || rootSess.Value == "" {
t.Fatalf("expected a root identity cookie from the beacon")
}
if bloc, _ := url.Parse(beacon.Header.Get("Location")); bloc.Host != h.app || bloc.Path != "/repeaters" {
t.Fatalf("beacon Location = %q, want app /repeaters", beacon.Header.Get("Location"))
}
// The app session must actually authenticate: the protected page loads.
follow := do(t, ts, appHost, "/repeaters", sess)
defer follow.Body.Close()
if follow.StatusCode != http.StatusOK {
t.Fatalf("authenticated /repeaters = %d, want 200", follow.StatusCode)
}
})
t.Run("state mismatch is rejected", func(t *testing.T) {
code, _ := st.CreateAuthCode(ctx, u.ID, "", "/repeaters")
resp := do(t, ts, appHost, "/session/callback?code="+code+"&state=s1",
&http.Cookie{Name: "mt_state", Value: "different"})
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") == "/repeaters" {
t.Fatalf("status=%d loc=%q, want bounce to login", resp.StatusCode, resp.Header.Get("Location"))
}
if c := cookieByName(resp, "meshtender_session"); c != nil && c.Value != "" {
t.Fatalf("no session should be set on state mismatch")
}
// The code must NOT have been consumed, since state failed first.
if _, _, _, ok, _ := st.ConsumeAuthCode(ctx, code); !ok {
t.Fatalf("code should remain valid after a state-mismatch rejection")
}
})
t.Run("unknown code is rejected", func(t *testing.T) {
resp := do(t, ts, appHost, "/session/callback?code=bogus&state=s1",
&http.Cookie{Name: "mt_state", Value: "s1"})
defer resp.Body.Close()
if c := cookieByName(resp, "meshtender_session"); c != nil && c.Value != "" {
t.Fatalf("no session should be set for an unknown code")
}
})
}