From aba90a1de5479b7e0d9a60f2e71f1ab312b29d71 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Wed, 25 Mar 2026 16:30:52 -0700 Subject: [PATCH] Add web package: session cookies, auth middleware, templates - SetSessionCookie/ClearSessionCookie/GetSessionToken with HttpOnly, Secure, SameSite=Strict - RequireAuth middleware: validates token, redirects to login, sets TokenInfo in context - RenderTemplate: layout + page block pattern with FuncMap merge - 9 tests with mock MCIAS and fstest Co-Authored-By: Claude Opus 4.6 (1M context) --- web/web.go | 104 +++++++++++++++++++++ web/web_test.go | 241 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 web/web.go create mode 100644 web/web_test.go diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..5dc8e34 --- /dev/null +++ b/web/web.go @@ -0,0 +1,104 @@ +// Package web provides session cookie management, auth middleware, and +// template rendering helpers for Metacircular web UIs built with htmx +// and Go html/template. +package web + +import ( + "html/template" + "io/fs" + "net/http" + + "git.wntrmute.dev/kyle/mcdsl/auth" +) + +// SetSessionCookie sets a session cookie with the standard Metacircular +// security flags: HttpOnly, Secure, SameSite=Strict. +func SetSessionCookie(w http.ResponseWriter, name, token string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +// ClearSessionCookie removes a session cookie by setting it to empty +// with MaxAge=-1. +func ClearSessionCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +// GetSessionToken extracts the session token from the named cookie. +// Returns empty string if the cookie is missing or empty. +func GetSessionToken(r *http.Request, name string) string { + c, err := r.Cookie(name) + if err != nil { + return "" + } + return c.Value +} + +// RequireAuth returns middleware that validates the session token via +// the Authenticator. If the token is missing or invalid, the user is +// redirected to loginPath. On success, the [auth.TokenInfo] is stored +// in the request context (retrievable via [auth.TokenInfoFromContext]). +func RequireAuth(authenticator *auth.Authenticator, cookieName, loginPath string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := GetSessionToken(r, cookieName) + if token == "" { + http.Redirect(w, r, loginPath, http.StatusFound) + return + } + + info, err := authenticator.ValidateToken(token) + if err != nil { + ClearSessionCookie(w, cookieName) + http.Redirect(w, r, loginPath, http.StatusFound) + return + } + + ctx := auth.ContextWithTokenInfo(r.Context(), info) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RenderTemplate parses and executes a template from an embedded FS. +// It parses "templates/layout.html" and "templates/", merges +// any provided FuncMaps, and executes the "layout" template with data. +// +// This matches the layout + page block pattern used by all Metacircular +// web UIs. +func RenderTemplate(w http.ResponseWriter, fsys fs.FS, name string, data any, funcs ...template.FuncMap) { + merged := template.FuncMap{} + for _, fm := range funcs { + for k, v := range fm { + merged[k] = v + } + } + + tmpl, err := template.New("").Funcs(merged).ParseFS(fsys, + "templates/layout.html", + "templates/"+name, + ) + if err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + } +} diff --git a/web/web_test.go b/web/web_test.go new file mode 100644 index 0000000..5ce7b64 --- /dev/null +++ b/web/web_test.go @@ -0,0 +1,241 @@ +package web + +import ( + "encoding/json" + "io/fs" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "git.wntrmute.dev/kyle/mcdsl/auth" +) + +func TestSetSessionCookie(t *testing.T) { + rec := httptest.NewRecorder() + SetSessionCookie(rec, "my_token", "abc123") + + cookies := rec.Result().Cookies() + var found *http.Cookie + for _, c := range cookies { + if c.Name == "my_token" { + found = c + break + } + } + if found == nil { + t.Fatal("cookie not set") + } + if found.Value != "abc123" { + t.Fatalf("value = %q, want %q", found.Value, "abc123") + } + if !found.HttpOnly { + t.Fatal("not HttpOnly") + } + if !found.Secure { + t.Fatal("not Secure") + } + if found.SameSite != http.SameSiteStrictMode { + t.Fatal("not SameSite=Strict") + } +} + +func TestClearSessionCookie(t *testing.T) { + rec := httptest.NewRecorder() + ClearSessionCookie(rec, "my_token") + + cookies := rec.Result().Cookies() + var found *http.Cookie + for _, c := range cookies { + if c.Name == "my_token" { + found = c + break + } + } + if found == nil { + t.Fatal("cookie not set") + } + if found.MaxAge != -1 { + t.Fatalf("MaxAge = %d, want -1", found.MaxAge) + } + if found.Value != "" { + t.Fatalf("Value = %q, want empty", found.Value) + } +} + +func TestGetSessionToken(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "tok", Value: "mytoken"}) + + got := GetSessionToken(req, "tok") + if got != "mytoken" { + t.Fatalf("got %q, want %q", got, "mytoken") + } +} + +func TestGetSessionTokenMissing(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + got := GetSessionToken(req, "tok") + if got != "" { + t.Fatalf("got %q, want empty", got) + } +} + +// mockMCIAS returns a test HTTP server for auth testing. +func mockMCIAS(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/token/validate", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad", http.StatusBadRequest) + return + } + if req.Token == "valid-token" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "valid": true, + "username": "testuser", + "roles": []string{"user"}, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"valid": false}) + }) + return httptest.NewServer(mux) +} + +func newTestAuth(t *testing.T, serverURL string) *auth.Authenticator { + t.Helper() + a, err := auth.New(auth.Config{ServerURL: serverURL}, slog.Default()) + if err != nil { + t.Fatalf("auth.New: %v", err) + } + return a +} + +func TestRequireAuthRedirectsWhenMissing(t *testing.T) { + srv := mockMCIAS(t) + defer srv.Close() + a := newTestAuth(t, srv.URL) + + handler := RequireAuth(a, "session", "/login")( + http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("handler should not be called") + }), + ) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound) + } + if loc := rec.Header().Get("Location"); loc != "/login" { + t.Fatalf("Location = %q, want %q", loc, "/login") + } +} + +func TestRequireAuthRedirectsWhenInvalid(t *testing.T) { + srv := mockMCIAS(t) + defer srv.Close() + a := newTestAuth(t, srv.URL) + + handler := RequireAuth(a, "session", "/login")( + http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("handler should not be called") + }), + ) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "bad-token"}) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound) + } +} + +func TestRequireAuthPassesValid(t *testing.T) { + srv := mockMCIAS(t) + defer srv.Close() + a := newTestAuth(t, srv.URL) + + var gotInfo *auth.TokenInfo + handler := RequireAuth(a, "session", "/login")( + http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + gotInfo = auth.TokenInfoFromContext(r.Context()) + }), + ) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.AddCookie(&http.Cookie{Name: "session", Value: "valid-token"}) + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if gotInfo == nil { + t.Fatal("TokenInfo not in context") + } + if gotInfo.Username != "testuser" { + t.Fatalf("Username = %q, want %q", gotInfo.Username, "testuser") + } +} + +func TestRenderTemplate(t *testing.T) { + testFS := fstest.MapFS{ + "templates/layout.html": &fstest.MapFile{ + Data: []byte(`{{define "layout"}}{{template "content" .}}{{end}}`), + }, + "templates/page.html": &fstest.MapFile{ + Data: []byte(`{{define "content"}}

{{.Title}}

{{end}}`), + }, + } + + rec := httptest.NewRecorder() + RenderTemplate(rec, fs.FS(testFS), "page.html", map[string]string{"Title": "Hello"}) + + body := rec.Body.String() + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Fatalf("Content-Type = %q", ct) + } + if body != "

Hello

" { + t.Fatalf("body = %q", body) + } +} + +func TestRenderTemplateWithFuncMap(t *testing.T) { + testFS := fstest.MapFS{ + "templates/layout.html": &fstest.MapFile{ + Data: []byte(`{{define "layout"}}{{upper .Name}}{{end}}`), + }, + "templates/page.html": &fstest.MapFile{ + Data: []byte(`{{define "content"}}unused{{end}}`), + }, + } + + rec := httptest.NewRecorder() + RenderTemplate(rec, fs.FS(testFS), "page.html", + map[string]string{"Name": "hello"}, + map[string]any{ + "upper": func(s string) string { + return "HELLO" + }, + }, + ) + + if rec.Body.String() != "HELLO" { + t.Fatalf("body = %q, want %q", rec.Body.String(), "HELLO") + } +}