fix(auth): judge the passkey nudge by the configured origin

REVIEW-001 (M0) L7c: shouldNudge trusted the request Host, so a reverse
proxy that rewrites Host wrongly suppressed (or enabled) the nudge.

Prefer the configured server.base_url host (Deps.BaseURL) and fall back to
the request Host only when it is unset. Wire the secure session-cookie name
through SessionCookieNameFor so the `__Host-` prefix (L1s) is used
consistently by the flows.
This commit is contained in:
Claus Lohmar 2026-10-14 11:29:58 +00:00
parent 93ec8b8131
commit b4222077b1

View file

@ -25,6 +25,8 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit"
@ -71,6 +73,10 @@ type Deps struct {
// (SPEC-006 §8). The door wires it to setup mode, where a passkey is
// useless. nil = the nudge runs (subject to the other conditions).
SuppressNudge func() bool
// BaseURL is the CONFIGURED public origin. It decides the nudge's origin
// signal: a proxy that rewrites `Host` must not flip the decision (L7c).
// Empty falls back to the request `Host`.
BaseURL string
// Secure marks cookies Secure (https).
Secure bool
// Logger receives structured logs. Defaults to slog.Default().
@ -385,12 +391,12 @@ func (a *Actions) LoginFinish(w http.ResponseWriter, r *http.Request, guid strin
// Logout revokes the current session and clears the cookie (SPEC-006 §5).
func (a *Actions) Logout(w http.ResponseWriter, r *http.Request, guid string) error {
ctx := r.Context()
if c, err := r.Cookie(httpx.SessionCookieName); err == nil && c.Value != "" {
if c, err := r.Cookie(httpx.SessionCookieNameFor(a.d.Secure)); err == nil && c.Value != "" {
if err := a.d.Sessions.Revoke(ctx, c.Value); err != nil {
a.d.Logger.Warn("flow: logout revoke failed", "error", err)
}
}
httpx.ClearSessionCookie(w, httpx.SessionCookieName, a.d.Secure)
httpx.ClearSessionCookie(w, httpx.SessionCookieNameFor(a.d.Secure), a.d.Secure)
http.Redirect(w, r, "/home", http.StatusSeeOther)
return nil
}
@ -481,7 +487,7 @@ func (a *Actions) establishSessionAt(w http.ResponseWriter, r *http.Request, idn
if err != nil {
return err
}
httpx.SetSessionCookie(w, httpx.SessionCookieName, sess.ID, a.d.Secure)
httpx.SetSessionCookie(w, httpx.SessionCookieNameFor(a.d.Secure), sess.ID, a.d.Secure)
if target == "" {
target = a.successTarget(idn.GUID)
}
@ -512,7 +518,7 @@ func (a *Actions) shouldNudge(ctx context.Context, r *http.Request, idn auth.Ide
if a.d.SuppressNudge != nil && a.d.SuppressNudge() {
return false
}
if httpx.IsLoopbackHost(r.Host) {
if httpx.IsLoopbackHost(a.originHost(r)) {
return false
}
if a.d.Users == nil {
@ -534,6 +540,16 @@ func (a *Actions) shouldNudge(ctx context.Context, r *http.Request, idn auth.Ide
return !hasPasskey
}
// originHost is the origin the nudge is judged against (L7c): the CONFIGURED
// base_url's host when set — so a reverse proxy that rewrites `Host` cannot
// wrongly suppress (or enable) the nudge — falling back to the request `Host`.
func (a *Actions) originHost(r *http.Request) string {
if u, err := url.Parse(strings.TrimSpace(a.d.BaseURL)); err == nil && u.Host != "" {
return u.Host
}
return r.Host
}
// ── the passkey nudge (SPEC-006 §8, D-041) ──────────────────────────────────
// PasskeyPrompt renders the passkey nudge screen. It is a session-gated ROUTE
@ -646,7 +662,7 @@ func (a *Actions) issueSession(r *http.Request, idn auth.Identity) (auth.Session
if m, ok := audit.MetaFrom(ctx); ok {
ip = m.ClientIP
}
if c, err := r.Cookie(httpx.SessionCookieName); err == nil && c.Value != "" {
if c, err := r.Cookie(httpx.SessionCookieNameFor(a.d.Secure)); err == nil && c.Value != "" {
if old, err := a.d.Sessions.Validate(ctx, c.Value); err == nil && old.IdentityID == idn.ID {
return a.d.Sessions.Rotate(ctx, c.Value)
}