Get rid of single-host mode

This commit is contained in:
Jonathon Leight
2026-07-02 20:05:51 -04:00
parent 8e841b8432
commit 4bfcfd18f4
17 changed files with 177 additions and 117 deletions
+15 -19
View File
@@ -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
+3 -3
View File
@@ -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"
+2 -3
View File
@@ -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 {
+15 -25
View File
@@ -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
}
+8 -9
View File
@@ -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
+1 -1
View File
@@ -56,7 +56,7 @@
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Public profile</h3></div>
<div class="card-body">
<p class="text-secondary">These appear on your public page{{if .RootURL}} at <a href="{{.RootURL}}/u/{{.User.Username}}" target="_blank" rel="noopener">{{.RootURL}}/u/{{.User.Username}}</a>{{end}}, which anyone can view. Leave a field blank to keep it off your page.</p>
<p class="text-secondary">These appear on your public page at <a href="{{.RootURL}}/u/{{.User.Username}}" target="_blank" rel="noopener">{{.RootURL}}/u/{{.User.Username}}</a>, which anyone can view. Leave a field blank to keep it off your page.</p>
<form method="post" action="/account/profile-fields">
<div class="mb-3">
<label class="form-label" for="acct_bio">Bio <span class="form-label-description">optional</span></label>
+12 -5
View File
@@ -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
+58
View File
@@ -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.<RootHost> 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)
}
})
}
}
+2 -5
View File
@@ -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)
+2 -3
View File
@@ -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()
+2 -3
View File
@@ -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()
+2 -3
View File
@@ -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)
}
+23
View File
@@ -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
+2 -3
View File
@@ -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()
+13 -3
View File
@@ -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)
}
+2 -2
View File
@@ -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")
+15 -30
View File
@@ -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