diff --git a/.env.example b/.env.example index abda81d..93a82e8 100644 --- a/.env.example +++ b/.env.example @@ -11,37 +11,33 @@ MESHTENDER_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000 # Set it to the ROOT registrable domain (e.g. meshtender.com) so passkeys stay # valid across every subdomain — this is permanent, so don't pin it to a # subdomain. RP_ORIGIN is a comma-separated list of every origin a ceremony may -# run from (the auth and app hosts). -MESHTENDER_RP_ID=localhost +# run from (the auth and app hosts). These must line up with the hosts below. +MESHTENDER_RP_ID=leighthaus.dev MESHTENDER_RP_NAME=MeshTender -MESHTENDER_RP_ORIGIN=http://localhost:8080 +MESHTENDER_RP_ORIGIN=https://auth.leighthaus.dev:8080,https://app.leighthaus.dev:8080 -# Optional split-host topology, all served by one binary on one port (browsers -# route *.localhost to loopback). Roles: +# Host topology — REQUIRED. One binary serves all three on one port; the server +# refuses to start unless AUTH_HOST and ROOT_HOST are set. Roles: # ROOT_HOST — public marketing + organization discovery (no session). # WWW_HOST — redirects to ROOT_HOST (defaults to "www." + ROOT_HOST). # AUTH_HOST — login/signup + WebAuthn ceremonies; hands off to the app host. # PRIMARY_HOST — the product/app host (dashboard at /, authenticated area). -# Leave AUTH_HOST empty for single-host mode (everything on PRIMARY_HOST). # -# NOTE: don't use *.localhost for the split — "localhost" is a public suffix, so -# browsers reject RP ID "localhost" from a subdomain and passkeys won't work. Use -# a real registrable dev domain with its subdomains pointed at 127.0.0.1, so RP -# ID can be the registrable parent. Example (leighthaus.dev): +# NOTE: don't use *.localhost — "localhost" is a public suffix, so browsers reject +# RP ID "localhost" from a subdomain and passkeys won't work. Use a real +# registrable dev domain with its subdomains pointed at 127.0.0.1, so RP ID can be +# the registrable parent. Example (leighthaus.dev — also update RP_ID/RP_ORIGIN): # MESHTENDER_RP_ID=leighthaus.dev # MESHTENDER_RP_ORIGIN=https://auth.leighthaus.dev:8080,https://app.leighthaus.dev:8080 -# MESHTENDER_ROOT_HOST=leighthaus.dev -# MESHTENDER_AUTH_HOST=auth.leighthaus.dev -# MESHTENDER_PRIMARY_HOST=app.leighthaus.dev -# MESHTENDER_ROOT_HOST= -# MESHTENDER_WWW_HOST= -# MESHTENDER_PRIMARY_HOST= -# MESHTENDER_AUTH_HOST= +MESHTENDER_ROOT_HOST=leighthaus.dev +MESHTENDER_AUTH_HOST=auth.leighthaus.dev +MESHTENDER_PRIMARY_HOST=app.leighthaus.dev +# MESHTENDER_WWW_HOST= # defaults to "www." + ROOT_HOST # # HSTS-preloaded TLDs (.dev, .app, …) force HTTPS, so plain-HTTP dev won't load. # Serve TLS in-process with a locally-trusted mkcert cert (origins above are # https for this reason): # brew install mkcert && mkcert -install # mkcert -cert-file ./certs/dev.pem -key-file ./certs/dev-key.pem "*.leighthaus.dev" leighthaus.dev -# MESHTENDER_TLS_CERT=./certs/dev.pem -# MESHTENDER_TLS_KEY=./certs/dev-key.pem +MESHTENDER_TLS_CERT=./certs/dev.pem +MESHTENDER_TLS_KEY=./certs/dev-key.pem diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index 272b5e8..300fc86 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -129,12 +129,12 @@ func (rec *Recorder) record(r *http.Request, status int) { // surface classifies a request host into one of the known surfaces. func (rec *Recorder) surface(host string) string { switch { - case rec.cfg.AuthHost != "" && strings.EqualFold(host, rec.cfg.AuthHost): + case strings.EqualFold(host, rec.cfg.AuthHost): return "auth" - case rec.cfg.RootHost != "" && strings.EqualFold(host, rec.cfg.RootHost), + case strings.EqualFold(host, rec.cfg.RootHost), rec.cfg.WWWHost != "" && strings.EqualFold(host, rec.cfg.WWWHost): return "root" - case rec.cfg.PrimaryHost != "" && strings.EqualFold(host, rec.cfg.PrimaryHost): + case strings.EqualFold(host, rec.cfg.PrimaryHost): return "app" default: return "custom" diff --git a/internal/auth/handlers.go b/internal/auth/handlers.go index 1c34f2f..12d7c2c 100644 --- a/internal/auth/handlers.go +++ b/internal/auth/handlers.go @@ -14,9 +14,8 @@ import ( "github.com/jleight/meshtender/internal/store" ) -// RequireUser is middleware that sends unauthenticated requests to the sign-in -// page — local in single-host mode, or the auth host (with a handoff back) when -// auth lives on a dedicated host. +// RequireUser is middleware that sends unauthenticated requests to the auth +// host's sign-in page (with a handoff back to where they were going). func (s *Service) RequireUser(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.CurrentUserID(r.Context()) == 0 { diff --git a/internal/auth/handoff.go b/internal/auth/handoff.go index f28044d..6b090a8 100644 --- a/internal/auth/handoff.go +++ b/internal/auth/handoff.go @@ -27,10 +27,6 @@ const ( maxStateLen = 256 // bound stored/echoed state length ) -// SplitHost returns true when this Service runs the auth front door on a -// separate host from the app (cross-host handoff mode). -func (s *Service) SplitHost() bool { return s.authHost != "" } - // scheme is the URL scheme matching the cookie Secure setting. func (s *Service) scheme() string { if s.secure { @@ -79,12 +75,11 @@ func (s *Service) popAuthState(ctx context.Context) string { return state } -// PostAuthRedirect is the destination after a successful sign-in. In single-host -// mode it's the stored post-auth path. In split-host mode (on the auth host) it -// mints a handoff code and points at the app host's callback — UNLESS the login -// was initiated for an auth-host-local page (e.g. account settings), in which -// case it returns that local path with no handoff. The caller must already have -// run login() for the auth host's own (SSO) session. +// PostAuthRedirect is the destination after a successful sign-in on the auth +// host: it mints a single-use handoff code and points at the app host's callback +// — UNLESS the login was initiated for an auth-host-local page (e.g. account +// settings), in which case it returns that local path with no handoff. The caller +// must already have run login() for the auth host's own (SSO) session. func (s *Service) PostAuthRedirect(r *http.Request, userID int64) string { ctx := r.Context() next := s.PopNext(ctx) @@ -93,7 +88,9 @@ func (s *Service) PostAuthRedirect(r *http.Request, userID int64) string { if s.Sessions.PopBool(ctx, sessKeyAuthLocal) { return next } - if !s.SplitHost() || !s.onAuthHost(r) { + // Defensive: ceremonies always finish on the auth host, but if somehow not, + // there's nothing to hand off — just return the local path. + if !s.onAuthHost(r) { return next } // Thread this host's login row into the code so the app callback reuses it @@ -137,19 +134,14 @@ func (s *Service) StartSignup(w http.ResponseWriter, r *http.Request, next strin s.startAuth(w, r, next, "/signup") } -// startAuth begins a sign-in/sign-up. In split-host mode it redirects to the -// auth host's page, first dropping a host-only state cookie on the app host that -// the returning callback must match — which is why auth entry must always go -// through the app host, never a direct link to the auth host. In single-host -// mode it just redirects to the local page. +// startAuth begins a sign-in/sign-up: it redirects to the auth host's page, first +// dropping a host-only state cookie on the app host that the returning callback +// must match — which is why auth entry must always go through the app host, never +// a direct link to the auth host. func (s *Service) startAuth(w http.ResponseWriter, r *http.Request, next, page string) { if !SafeLocalPath(next) { next = "/" } - if !s.SplitHost() { - http.Redirect(w, r, page+"?next="+url.QueryEscape(next), http.StatusSeeOther) - return - } state, err := randomState() if err != nil { http.Error(w, "could not start sign-in", http.StatusInternalServerError) @@ -204,11 +196,9 @@ func (s *Service) SessionCallback(w http.ResponseWriter, r *http.Request) { // land on the requested app page. The beacon code carries the same login row // and the app-local next; if minting fails we just skip the root cookie this // round (discovery renders anonymous until the next sign-in). - if s.rootHost != "" { - if code, err := s.store.CreateAuthCode(ctx, userID, loginID, next); err == nil { - http.Redirect(w, r, s.rootOrigin(r)+"/session/beacon?code="+url.QueryEscape(code), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin - return - } + if code, err := s.store.CreateAuthCode(ctx, userID, loginID, next); err == nil { + http.Redirect(w, r, s.rootOrigin(r)+"/session/beacon?code="+url.QueryEscape(code), http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin + return } http.Redirect(w, r, next, http.StatusSeeOther) //nolint:gosec // G710: local path or config-pinned origin } diff --git a/internal/auth/service.go b/internal/auth/service.go index 64b107c..18b0fd6 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -30,9 +30,8 @@ type Service struct { store *store.Store Sessions *scs.SessionManager - // Host split: when authHost is set, sign-in happens on a dedicated host and - // hands off to appHost via a single-use code. Empty authHost ⇒ single-host - // mode (auth served from appHost, no cross-host handoff). + // Sign-in happens on the dedicated authHost and hands off to appHost via a + // single-use code; both are always configured. appHost string authHost string // rootHost is the public discovery host. When set, a fresh app sign-in @@ -47,14 +46,14 @@ type Config struct { RPID string RPDisplayName string RPOrigins []string - // AppHost serves the product; AuthHost (optional) serves the login UI and - // runs ceremonies, handing off to AppHost. Both are bare hostnames (no - // scheme/port). Empty AuthHost selects single-host mode. + // AppHost serves the product; AuthHost serves the login UI and runs + // ceremonies, handing off to AppHost. Both are bare hostnames (no + // scheme/port) and are always set. AppHost string AuthHost string - // RootHost (optional) serves public discovery; a fresh app sign-in drops a - // minimal identity cookie there via its beacon so it can render - // logged-in-aware UI without sharing a session. + // RootHost serves public discovery; a fresh app sign-in drops a minimal + // identity cookie there via its beacon so it can render logged-in-aware UI + // without sharing a session. Always set. RootHost string // Secure marks cookies Secure (set false for plain-HTTP localhost dev). Secure bool diff --git a/internal/auth/templates/account.html b/internal/auth/templates/account.html index c40d0fe..72f3ef1 100644 --- a/internal/auth/templates/account.html +++ b/internal/auth/templates/account.html @@ -56,7 +56,7 @@

Public profile

-

These appear on your public page{{if .RootURL}} at {{.RootURL}}/u/{{.User.Username}}{{end}}, which anyone can view. Leave a field blank to keep it off your page.

+

These appear on your public page at {{.RootURL}}/u/{{.User.Username}}, which anyone can view. Leave a field blank to keep it off your page.

diff --git a/internal/config/config.go b/internal/config/config.go index 12e83da..67e81bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,14 +38,13 @@ type Config struct { // AuthHost is the dedicated hostname that serves the login/signup UI and // runs WebAuthn ceremonies (e.g. "auth.meshtender.com"). A successful - // sign-in there hands off to PrimaryHost via a single-use code. When - // empty, auth is served from PrimaryHost (single-host mode). + // sign-in there hands off to PrimaryHost via a single-use code. Required. AuthHost string // RootHost is the public marketing + organization-discovery hostname (the // bare apex, e.g. "meshtender.com" / dev "localhost"). It carries no - // session (cookies are host-only), so it serves only public content. When - // empty, that content stays on PrimaryHost. + // session (cookies are host-only), so it serves only public content. + // Required. RootHost string // WWWHost redirects to RootHost (e.g. "www.meshtender.com"). Defaults to @@ -101,7 +100,15 @@ func Load() (*Config, error) { TrustedProxies: parseTrustedProxies(os.Getenv("MESHTENDER_TRUSTED_PROXIES")), } - if c.RootHost != "" && c.WWWHost == "" { + // MeshTender runs across three hosts (auth + app + root). Require the two that + // have no sane default (PrimaryHost falls back to RPID above). + if c.AuthHost == "" { + return nil, fmt.Errorf("MESHTENDER_AUTH_HOST is required") + } + if c.RootHost == "" { + return nil, fmt.Errorf("MESHTENDER_ROOT_HOST is required") + } + if c.WWWHost == "" { c.WWWHost = "www." + c.RootHost } // HTTPS deployments advertise https:// origins; this drives Secure cookies diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c25f1b1 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "strings" + "testing" +) + +// validEnv sets every variable Load needs for a successful split-host load. Tests +// override individual vars afterward. t.Setenv fully controls the environment +// (overriding any ambient .env that `mise` may have sourced) and restores it. +func validEnv(t *testing.T) { + t.Helper() + t.Setenv("MESHTENDER_DATABASE_URL", "postgres://x/y") + t.Setenv("MESHTENDER_MASTER_KEY", strings.Repeat("00", 32)) + t.Setenv("MESHTENDER_RP_ID", "example.dev") + t.Setenv("MESHTENDER_RP_ORIGIN", "https://auth.example.dev,https://app.example.dev") + t.Setenv("MESHTENDER_PRIMARY_HOST", "app.example.dev") + t.Setenv("MESHTENDER_AUTH_HOST", "auth.example.dev") + t.Setenv("MESHTENDER_ROOT_HOST", "example.dev") + t.Setenv("MESHTENDER_WWW_HOST", "") +} + +func TestLoadSplitHost(t *testing.T) { + validEnv(t) + c, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.AuthHost != "auth.example.dev" || c.RootHost != "example.dev" || c.PrimaryHost != "app.example.dev" { + t.Fatalf("hosts = %q/%q/%q", c.AuthHost, c.RootHost, c.PrimaryHost) + } + // WWWHost defaults to www. when unset. + if c.WWWHost != "www.example.dev" { + t.Fatalf("WWWHost = %q, want www.example.dev", c.WWWHost) + } + // https origin ⇒ Secure. + if !c.Secure { + t.Fatal("Secure = false for https origins") + } +} + +func TestLoadRequiresAuthAndRootHost(t *testing.T) { + // AUTH_HOST and ROOT_HOST are required; Load must fail fast when either is + // missing rather than silently falling back. + for _, missing := range []string{"MESHTENDER_AUTH_HOST", "MESHTENDER_ROOT_HOST"} { + t.Run(missing, func(t *testing.T) { + validEnv(t) + t.Setenv(missing, "") + _, err := Load() + if err == nil { + t.Fatalf("Load succeeded with %s unset, want an error", missing) + } + if !strings.Contains(err.Error(), missing) { + t.Fatalf("error %q does not name %s", err, missing) + } + }) + } +} diff --git a/internal/core/confirm_integration_test.go b/internal/core/confirm_integration_test.go index 971f34f..556677e 100644 --- a/internal/core/confirm_integration_test.go +++ b/internal/core/confirm_integration_test.go @@ -18,7 +18,6 @@ import ( "github.com/meshcore-go/meshcore-go/hardware" "github.com/jleight/meshtender/internal/auth" - "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/store" ) @@ -38,13 +37,11 @@ func TestConfirmRoundTrip(t *testing.T) { t.Fatalf("identity: %v", err) } - authSvc, err := auth.New(st, st.Pool(), auth.Config{ - RPID: "localhost", RPDisplayName: "test", RPOrigins: []string{"http://localhost"}, - }) + authSvc, err := auth.New(st, st.Pool(), testAuthConfig()) if err != nil { t.Fatalf("auth: %v", err) } - cfg := &config.Config{} + cfg := testConfig() srv, err := NewServer(st, authSvc, idSvc, cfg) if err != nil { t.Fatalf("server: %v", err) diff --git a/internal/core/confirm_location_test.go b/internal/core/confirm_location_test.go index d334d5a..a8ff9da 100644 --- a/internal/core/confirm_location_test.go +++ b/internal/core/confirm_location_test.go @@ -17,7 +17,6 @@ import ( "github.com/meshcore-go/meshcore-go/hardware" "github.com/jleight/meshtender/internal/auth" - "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/store" ) @@ -51,8 +50,8 @@ func TestConfirmFetchesLocation(t *testing.T) { var masterKey [32]byte _, _ = rand.Read(masterKey[:]) idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey) - authSvc, _ := auth.New(st, st.Pool(), auth.Config{RPID: "localhost", RPDisplayName: "t", RPOrigins: []string{"http://localhost"}}) - srv, _ := NewServer(st, authSvc, idSvc, &config.Config{}) + authSvc, _ := auth.New(st, st.Pool(), testAuthConfig()) + srv, _ := NewServer(st, authSvc, idSvc, testConfig()) ts := httptest.NewServer(srv.Handler()) defer ts.Close() diff --git a/internal/core/confirm_retry_test.go b/internal/core/confirm_retry_test.go index 711bf0e..921ee55 100644 --- a/internal/core/confirm_retry_test.go +++ b/internal/core/confirm_retry_test.go @@ -17,7 +17,6 @@ import ( "github.com/meshcore-go/meshcore-go/hardware" "github.com/jleight/meshtender/internal/auth" - "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/store" ) @@ -31,8 +30,8 @@ func TestConfirmLoginRetry(t *testing.T) { var masterKey [32]byte _, _ = rand.Read(masterKey[:]) idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey) - authSvc, _ := auth.New(st, st.Pool(), auth.Config{RPID: "localhost", RPDisplayName: "t", RPOrigins: []string{"http://localhost"}}) - srv, _ := NewServer(st, authSvc, idSvc, &config.Config{}) + authSvc, _ := auth.New(st, st.Pool(), testAuthConfig()) + srv, _ := NewServer(st, authSvc, idSvc, testConfig()) ts := httptest.NewServer(srv.Handler()) defer ts.Close() diff --git a/internal/core/console_integration_test.go b/internal/core/console_integration_test.go index 029f936..df8d847 100644 --- a/internal/core/console_integration_test.go +++ b/internal/core/console_integration_test.go @@ -17,7 +17,6 @@ import ( "github.com/meshcore-go/meshcore-go/hardware" "github.com/jleight/meshtender/internal/auth" - "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/identity" "github.com/jleight/meshtender/internal/store" ) @@ -35,11 +34,11 @@ func TestConsoleRoundTrip(t *testing.T) { if err != nil { t.Fatalf("identity: %v", err) } - authSvc, err := auth.New(st, st.Pool(), auth.Config{RPID: "localhost", RPDisplayName: "t", RPOrigins: []string{"http://localhost"}}) + authSvc, err := auth.New(st, st.Pool(), testAuthConfig()) if err != nil { t.Fatalf("auth: %v", err) } - srv, err := NewServer(st, authSvc, idSvc, &config.Config{}) + srv, err := NewServer(st, authSvc, idSvc, testConfig()) if err != nil { t.Fatalf("server: %v", err) } diff --git a/internal/core/dbtest_test.go b/internal/core/dbtest_test.go index 233444a..8711770 100644 --- a/internal/core/dbtest_test.go +++ b/internal/core/dbtest_test.go @@ -6,10 +6,33 @@ import ( "testing" "time" + "github.com/jleight/meshtender/internal/auth" + "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/store" "github.com/jleight/meshtender/internal/testdb" ) +// testConfig/testAuthConfig give the integration tests the app/auth/root hosts, +// using the same constants as splitServer (testAuthHost/testAppHost/testRootHost/ +// testWWWHost, defined in handoff_test.go). Requests to a plain httptest listener +// arrive with Host 127.0.0.1, which matches none of these, so they route to the +// app surface by default — while the handoff/beacon still target real, distinct +// origins. +func testConfig() *config.Config { + return &config.Config{ + PrimaryHost: testAppHost, AuthHost: testAuthHost, + RootHost: testRootHost, WWWHost: testWWWHost, + } +} + +func testAuthConfig() auth.Config { + return auth.Config{ + RPID: "localhost", RPDisplayName: "t", + RPOrigins: []string{"http://" + testAuthHost, "http://" + testAppHost}, + AppHost: testAppHost, AuthHost: testAuthHost, RootHost: testRootHost, + } +} + // coreStore returns a Store backed by a fresh, throwaway database cloned from // the migrated template (see internal/testdb). Each call is fully isolated — // command_catalog seeded, everything else empty — so the integration tests need diff --git a/internal/core/login_revoke_test.go b/internal/core/login_revoke_test.go index 3a8a710..76900a1 100644 --- a/internal/core/login_revoke_test.go +++ b/internal/core/login_revoke_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/jleight/meshtender/internal/auth" - "github.com/jleight/meshtender/internal/config" "github.com/jleight/meshtender/internal/identity" ) @@ -23,8 +22,8 @@ func TestRevokedLoginLogsOut(t *testing.T) { var masterKey [32]byte _, _ = rand.Read(masterKey[:]) idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey) - authSvc, _ := auth.New(st, st.Pool(), auth.Config{RPID: "localhost", RPDisplayName: "t", RPOrigins: []string{"http://localhost"}}) - srv, _ := NewServer(st, authSvc, idSvc, &config.Config{}) + authSvc, _ := auth.New(st, st.Pool(), testAuthConfig()) + srv, _ := NewServer(st, authSvc, idSvc, testConfig()) ts := httptest.NewServer(srv.Handler()) defer ts.Close() diff --git a/internal/e2e/harness_test.go b/internal/e2e/harness_test.go index d2c99ee..a5b3873 100644 --- a/internal/e2e/harness_test.go +++ b/internal/e2e/harness_test.go @@ -111,15 +111,25 @@ func newE2EServer(t *testing.T) *e2eServer { var masterKey [32]byte _, _ = rand.Read(masterKey[:]) idSvc, _ := identity.LoadOrCreate(ctx, st, masterKey) + + // The server runs across three hosts. The browser only ever navigates the APP + // surface, which it reaches at browserHost() (host.docker.internal) — so that + // is PrimaryHost, and requests there route to the app by default. The auth/root + // hosts are distinct names the browser never navigates (cross-host links exist + // on pages but aren't clicked); they only need to differ from PrimaryHost so the + // Dispatcher can tell surfaces apart. + appHost := browserHost() + authHost, rootHost := "auth."+browserHost(), "root."+browserHost() authSvc, err := auth.New(st, st.Pool(), auth.Config{ RPID: "localhost", RPDisplayName: "test", RPOrigins: []string{"http://localhost"}, + AppHost: appHost, AuthHost: authHost, RootHost: rootHost, }) if err != nil { t.Fatalf("auth: %v", err) } - // Empty config → the Dispatcher serves every host from the app surface, so - // no Host-header juggling is needed (the browser hits host.docker.internal). - srv, err := core.NewServer(st, authSvc, idSvc, &config.Config{}) + srv, err := core.NewServer(st, authSvc, idSvc, &config.Config{ + PrimaryHost: appHost, AuthHost: authHost, RootHost: rootHost, + }) if err != nil { t.Fatalf("server: %v", err) } diff --git a/internal/e2e/logout_test.go b/internal/e2e/logout_test.go index fa96654..da9a3bd 100644 --- a/internal/e2e/logout_test.go +++ b/internal/e2e/logout_test.go @@ -16,8 +16,8 @@ import ( // chrome carries a POST /logout form, submitting it clears the session, and a // protected page then bounces the (now anonymous) browser to sign-in. Driving the // endpoint with same-origin fetch (rather than clicking through) keeps the test -// independent of where the single-host harness lands after logout, while still -// exercising real browser cookies and the strict CSP (connect-src 'self'). +// independent of the cross-host redirect logout lands on, while still exercising +// real browser cookies and the strict CSP (connect-src 'self'). func TestE2ELogout(t *testing.T) { srv := newE2EServer(t) _, cookie := srv.login(t, "logoutuser") diff --git a/internal/web/env.go b/internal/web/env.go index 1720c7d..f39db12 100644 --- a/internal/web/env.go +++ b/internal/web/env.go @@ -87,19 +87,11 @@ func (e *Env) Origin(r *http.Request, host string) string { return originFor(e.Cfg, r, host) } -// RedirectAfterLogout lands a signed-out visitor on the public root (or the app, -// or the local sign-in page in narrower configs). Shared by every host's POST -// /logout so sign-out ends in the same place regardless of which surface it was -// triggered from. +// RedirectAfterLogout lands a signed-out visitor on the public root host. Shared +// by every host's POST /logout so sign-out ends in the same place regardless of +// which surface it was triggered from. func (e *Env) RedirectAfterLogout(w http.ResponseWriter, r *http.Request) { - switch { - case e.Cfg.RootHost != "": - http.Redirect(w, r, e.Origin(r, e.Cfg.RootHost)+"/", http.StatusSeeOther) //nolint:gosec // G710: config-pinned origin - case e.Cfg.PrimaryHost != "": - http.Redirect(w, r, e.Origin(r, e.Cfg.PrimaryHost)+"/", http.StatusSeeOther) //nolint:gosec // G710: config-pinned origin - default: - http.Redirect(w, r, "/login", http.StatusSeeOther) - } + http.Redirect(w, r, e.Origin(r, e.Cfg.RootHost)+"/", http.StatusSeeOther) //nolint:gosec // G710: config-pinned origin } func originFor(cfg *config.Config, r *http.Request, host string) string { @@ -192,23 +184,18 @@ func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, page string, if data == nil { data = map[string]any{} } - // Absolute origins for cross-host links (sessions are host-scoped, so a link - // from root/app to a sibling surface must be absolute). Empty in single-host - // mode, where templates' relative paths already resolve correctly. - if rn.cfg.AuthHost != "" { - data["AppURL"] = originFor(rn.cfg, r, rn.cfg.PrimaryHost) - data["AuthURL"] = originFor(rn.cfg, r, rn.cfg.AuthHost) - if rn.cfg.RootHost != "" { - data["RootURL"] = originFor(rn.cfg, r, rn.cfg.RootHost) - } - } + // Absolute origins for cross-host links: sessions are host-scoped, so a link + // from one surface to a sibling must be absolute. + data["AppURL"] = originFor(rn.cfg, r, rn.cfg.PrimaryHost) + data["AuthURL"] = originFor(rn.cfg, r, rn.cfg.AuthHost) + data["RootURL"] = originFor(rn.cfg, r, rn.cfg.RootHost) // LogoutURL: sign-out is a POST that revokes the login row, so it must target a // host that owns a /logout endpoint AND holds this browser's session. The app // host, auth host, and custom org domains all do (relative "/logout", a // same-host POST). The root host is strictly side-effect-free GET (see // docs/auth-cross-host.md), so it has no logout of its own — the template hides // the control there and the user signs out from the app dashboard instead. - if rn.cfg.RootHost == "" || HostWithoutPort(r.Host) != rn.cfg.RootHost { + if HostWithoutPort(r.Host) != rn.cfg.RootHost { data["LogoutURL"] = "/logout" } if rn.userInfo != nil { @@ -282,19 +269,17 @@ func RedirectErr(w http.ResponseWriter, r *http.Request, path, msg string) { RedirectFlash(w, r, path, "error", msg) } -// Dispatcher routes by hostname across the surfaces. authH/rootH/appH are the -// per-surface handlers; rootH may be nil (single-host). When AuthHost is empty, -// appH serves everything (single-host mode). +// Dispatcher routes by hostname across the three surfaces: the auth host, the +// root (public discovery) host, and — for everything else, including custom org +// domains — the app host. AuthHost and RootHost are always configured (see +// config.Load); the WWWHost redirect is optional. func Dispatcher(cfg *config.Config, authH, rootH, appH http.Handler) http.Handler { - if cfg.AuthHost == "" { - return appH - } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host := HostWithoutPort(r.Host) switch { case strings.EqualFold(host, cfg.AuthHost): authH.ServeHTTP(w, r) - case rootH != nil && strings.EqualFold(host, cfg.RootHost): + case strings.EqualFold(host, cfg.RootHost): rootH.ServeHTTP(w, r) case cfg.WWWHost != "" && strings.EqualFold(host, cfg.WWWHost): http.Redirect(w, r, originFor(cfg, r, cfg.RootHost)+r.URL.RequestURI(), http.StatusMovedPermanently) //nolint:gosec // G710: local path or config-pinned origin