mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-09 13:15:33 +00:00
Get rid of single-host mode
This commit is contained in:
+15
-19
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user