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) <noreply@anthropic.com>
This commit is contained in:
104
web/web.go
Normal file
104
web/web.go
Normal file
@@ -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/<name>", 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)
|
||||
}
|
||||
}
|
||||
241
web/web_test.go
Normal file
241
web/web_test.go
Normal file
@@ -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"}}<!DOCTYPE html><html>{{template "content" .}}</html>{{end}}`),
|
||||
},
|
||||
"templates/page.html": &fstest.MapFile{
|
||||
Data: []byte(`{{define "content"}}<h1>{{.Title}}</h1>{{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 != "<!DOCTYPE html><html><h1>Hello</h1></html>" {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user