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 @@
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.