package auth import ( "context" "log/slog" "net/http" ) // sessionCtxKey is the unexported context key type for session data owned by // this package. Using an unexported named struct prevents collisions with // other packages' context keys. type sessionCtxKey struct{} // sessionKey is the singleton key value used in context.WithValue. var sessionKey = sessionCtxKey{} // authed holds the resolved session and user attached to a request context. type authed struct { Session *Session User *User } // Authed extracts the session and user from the request context. // Returns (session, user, true) when a valid session is present, and // (nil, nil, false) when the request is unauthenticated. func Authed(ctx context.Context) (*Session, *User, bool) { a, ok := ctx.Value(sessionKey).(*authed) if !ok || a == nil { return nil, nil, false } return a.Session, a.User, true } // ResolveSession reads the session cookie, looks up the session + user, and // attaches them to the request context. It NEVER blocks the request — missing // or invalid sessions are silently ignored; RequireAuth enforces access. // // When store is nil (e.g. in Phase 1 unit tests that pass a zero AuthDeps), // the middleware is a no-op pass-through. Cookie resolution only happens when // store is non-nil and a cookie is present. // // On a valid session hit, MaybeExtend is called best-effort (logged but not // fatal) to implement the sliding 30-day TTL (D-09). func ResolveSession(store *Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Nil-store guard: Phase 1 route tests pass AuthDeps{} (zero value). if store == nil { next.ServeHTTP(w, r) return } cookie, err := r.Cookie(SessionCookieName) if err != nil || cookie.Value == "" { // No cookie — pass through unauthenticated. next.ServeHTTP(w, r) return } sess, user, err := store.Lookup(r.Context(), cookie.Value) if err != nil { // Invalid / expired / tampered cookie — do NOT clear the cookie // here; the handler or RequireAuth will decide what to do. next.ServeHTTP(w, r) return } // Session found — attempt lazy extension (D-09). Best-effort: log on // error but do not fail the request. if extErr := store.MaybeExtend(r.Context(), sess.ID, sess.ExpiresAt); extErr != nil { slog.Default().Warn("session extend failed", "session_id", sess.ID, "err", extErr) } // Attach session + user to context for downstream handlers. ctx := context.WithValue(r.Context(), sessionKey, &authed{Session: sess, User: user}) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // RequireAuth is middleware that enforces an authenticated session. // If no session is present in the context (set by ResolveSession), it // redirects unauth requests to /login: // - HTMX requests (HX-Request: true) → 200 with HX-Redirect: /login header // - Plain requests → 303 See Other with Location: /login // // 303 is mandated (not 302) per D-23 and Pitfall 9: POST/Redirect/GET pattern // requires 303 to guarantee the redirect uses GET. func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, _, ok := Authed(r.Context()); !ok { redirectTo(w, r, "/login") return } next.ServeHTTP(w, r) }) } // RedirectIfAuthed bounces already-authenticated users away from auth pages // (e.g. /login, /signup) to the home route. This prevents authed users from // accidentally re-logging-in and rotating their session unnecessarily. // - HTMX requests → 200 with HX-Redirect: / // - Plain requests → 303 See Other with Location: / func RedirectIfAuthed(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, _, ok := Authed(r.Context()); ok { redirectTo(w, r, "/") return } next.ServeHTTP(w, r) }) } // redirectTo performs an HTMX-aware redirect: // - When the request carries HX-Request: true, it returns 200 with an // HX-Redirect header so HTMX can handle the navigation client-side // (Pattern 5 — avoids confusing HTMX with a 303 response). // - For plain browser requests it uses 303 See Other (NOT 302 — Pitfall 9). func redirectTo(w http.ResponseWriter, r *http.Request, target string) { if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", target) w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, target, http.StatusSeeOther) }