From 33f629f3878ab4af71932fe22976ce421d69fd90 Mon Sep 17 00:00:00 2001 From: Efremenko Arhip Date: Sun, 26 Jul 2026 19:54:39 +0300 Subject: [PATCH] feat(coordinator): UI login/register via userservice (cookie session) When JWT_SECRET + USERSERVICE_URL are set, the operator UI authenticates through userservice login/registration instead of the static UI_AUTH_TOKEN: - /ui/login, /ui/register, /ui/logout pages proxy to the userservice - successful login stores the JWT in an httpOnly, /ui-scoped cookie - withUISession verifies the cookie locally and stamps the requester - unset -> falls back to basic auth, so the team's existing flow is unchanged Tests cover the session gate, cookie set/clear, and the login/register proxy. --- coordinator/cmd/coordinator/main.go | 2 +- coordinator/internal/infra/config.go | 6 + coordinator/internal/transport/http/server.go | 73 ++++++-- .../internal/transport/http/server_test.go | 2 +- .../transport/http/templates/login.html | 27 +++ .../transport/http/templates/register.html | 28 +++ .../internal/transport/http/ui_auth.go | 171 +++++++++++++++++ .../transport/http/ui_auth_internal_test.go | 175 ++++++++++++++++++ 8 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 coordinator/internal/transport/http/templates/login.html create mode 100644 coordinator/internal/transport/http/templates/register.html create mode 100644 coordinator/internal/transport/http/ui_auth.go create mode 100644 coordinator/internal/transport/http/ui_auth_internal_test.go diff --git a/coordinator/cmd/coordinator/main.go b/coordinator/cmd/coordinator/main.go index 2206bec..a2275f8 100644 --- a/coordinator/cmd/coordinator/main.go +++ b/coordinator/cmd/coordinator/main.go @@ -110,7 +110,7 @@ func run() error { // pool.Ping backs /health: readiness means the database answers, not just // that the process is alive. - api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, pool.Ping) + api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, pool.Ping) err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken)) // Shutdown order matters, and defers alone cannot express it (they run diff --git a/coordinator/internal/infra/config.go b/coordinator/internal/infra/config.go index 37f8d41..3d62e02 100644 --- a/coordinator/internal/infra/config.go +++ b/coordinator/internal/infra/config.go @@ -32,6 +32,11 @@ type Config struct { // user-JWT auth entirely — the pre-userservice behaviour. Must match the // userservice's JWT_SECRET. JWTSecret string + // Base URL of the userservice, e.g. http://userservice:8081. When set + // together with JWTSecret, the operator UI authenticates via userservice + // login/registration (cookie session) instead of the static UI_AUTH_TOKEN + // basic auth. Empty keeps the basic-auth UI. + UserserviceURL string // Minimum log level: debug, info, warn, error. LogLevel string @@ -87,6 +92,7 @@ func LoadConfig() (Config, error) { Token: getEnv("COORDINATOR_TOKEN", os.Getenv("WORKER_AUTH_TOKEN")), UIToken: os.Getenv("UI_AUTH_TOKEN"), JWTSecret: os.Getenv("JWT_SECRET"), + UserserviceURL: os.Getenv("USERSERVICE_URL"), LogLevel: getEnv("LOG_LEVEL", "info"), LogFile: os.Getenv("LOG_FILE"), StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"), diff --git a/coordinator/internal/transport/http/server.go b/coordinator/internal/transport/http/server.go index b4e5a64..ecd44a3 100644 --- a/coordinator/internal/transport/http/server.go +++ b/coordinator/internal/transport/http/server.go @@ -7,6 +7,7 @@ import ( "context" "log/slog" "net/http" + "strings" "time" tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token" @@ -44,13 +45,18 @@ type Server struct { // verifier validates userservice JWTs. nil disables user-JWT auth, leaving // only the shared service token — the pre-userservice behaviour. verifier *tokenpkg.Verifier + // userserviceURL is the base URL the UI proxies login/registration to. Empty + // keeps the static basic-auth UI. + userserviceURL string + // httpClient makes the login/register calls to the userservice. + httpClient *http.Client // ready probes downstream dependencies (the database) for /health. Kept as // a func so the transport layer never imports pgx. ready func(context.Context) error } func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration, - maxUploadBytes int64, jwtSecret string, ready func(context.Context) error) *Server { + maxUploadBytes int64, jwtSecret, userserviceURL string, ready func(context.Context) error) *Server { return &Server{ uc: uc, log: log, @@ -58,10 +64,19 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval heartbeatInterval: heartbeatInterval, maxUploadBytes: maxUploadBytes, verifier: tokenpkg.NewVerifier(jwtSecret), + userserviceURL: strings.TrimRight(userserviceURL, "/"), + httpClient: &http.Client{Timeout: 10 * time.Second}, ready: ready, } } +// uiSessionMode reports whether the operator UI authenticates via userservice +// login (cookie session) rather than the static basic-auth token. It needs both +// a verifier (to check the JWT locally) and a userservice URL (to issue it). +func (s *Server) uiSessionMode() bool { + return s.verifier != nil && s.userserviceURL != "" +} + // Handler builds the router. Go 1.22's ServeMux matches on method and path // wildcards, so no third-party router is needed. func (s *Server) Handler(token string, uiToken ...string) http.Handler { @@ -82,19 +97,51 @@ func (s *Server) Handler(token string, uiToken ...string) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.handleHealth) - if len(uiToken) > 0 && uiToken[0] != "" && s.uc.Dashboard != nil { + + hasBasicAuth := len(uiToken) > 0 && uiToken[0] != "" + if s.uc.Dashboard != nil && (s.uiSessionMode() || hasBasicAuth) { ui := http.NewServeMux() - ui.HandleFunc("GET /ui", s.handleUIHome) - ui.HandleFunc("GET /ui/jobs/new", s.handleUINewJob) - ui.HandleFunc("GET /ui/jobs/{job_id}", s.handleUIJob) - ui.HandleFunc("GET /ui/api/overview", s.handleUIOverviewJSON) - ui.HandleFunc("GET /ui/api/jobs/{job_id}", s.handleUIJobJSON) - ui.HandleFunc("POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob) - ui.HandleFunc("POST /ui/api/jobs/upload", s.handleUploadDataset) - ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload) - ui.HandleFunc("GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview) - mux.Handle("/ui", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin)) - mux.Handle("/ui/", chain(ui, withRequestID, withAccessLog(s.log), withBasicAuth(uiToken[0]), withSameOrigin)) + + // The operator application routes, all requiring an authenticated caller. + app := []struct { + pattern string + handler http.HandlerFunc + }{ + {"GET /ui", s.handleUIHome}, + {"GET /ui/jobs/new", s.handleUINewJob}, + {"GET /ui/jobs/{job_id}", s.handleUIJob}, + {"GET /ui/api/overview", s.handleUIOverviewJSON}, + {"GET /ui/api/jobs/{job_id}", s.handleUIJobJSON}, + {"POST /ui/api/jobs/{job_id}/cancel", s.handleCancelJob}, + {"POST /ui/api/jobs/upload", s.handleUploadDataset}, + {"GET /ui/jobs/{job_id}/artifacts/{artifact_id}", s.handleUIArtifactDownload}, + {"GET /ui/jobs/{job_id}/artifacts/{artifact_id}/preview", s.handleUIArtifactPreview}, + } + + if s.uiSessionMode() { + // Public auth pages — reachable without a session so a user can log in. + ui.HandleFunc("GET /ui/login", s.handleUILoginForm) + ui.HandleFunc("POST /ui/login", s.handleUILogin) + ui.HandleFunc("GET /ui/register", s.handleUIRegisterForm) + ui.HandleFunc("POST /ui/register", s.handleUIRegister) + ui.HandleFunc("POST /ui/logout", s.handleUILogout) + gate := withUISession(s.verifier) + for _, rt := range app { + ui.Handle(rt.pattern, gate(rt.handler)) + } + } else { + for _, rt := range app { + ui.HandleFunc(rt.pattern, rt.handler) + } + } + + common := []func(http.Handler) http.Handler{withRequestID, withAccessLog(s.log)} + if !s.uiSessionMode() { + common = append(common, withBasicAuth(uiToken[0])) + } + common = append(common, withSameOrigin) + mux.Handle("/ui", chain(ui, common...)) + mux.Handle("/ui/", chain(ui, common...)) } else { // More specific than the protected catch-all: UI absence is not an auth // failure and does not disclose that a UI feature is configured elsewhere. diff --git a/coordinator/internal/transport/http/server_test.go b/coordinator/internal/transport/http/server_test.go index e9463b3..220083c 100644 --- a/coordinator/internal/transport/http/server_test.go +++ b/coordinator/internal/transport/http/server_test.go @@ -68,7 +68,7 @@ func newEnvWithUIToken(t *testing.T, ready func(context.Context) error, configur if err != nil { t.Fatalf("register test worker: %v", err) } - srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", ready) + srv := coordhttp.NewServer(uc, slog.New(slog.NewTextHandler(io.Discard, nil)), 5*time.Second, 15*time.Second, 1<<30, "", "", ready) ts := httptest.NewServer(srv.Handler(token, configuredUIToken)) t.Cleanup(ts.Close) return &env{ts: ts, blobs: blobs, workerID: worker.ID.String()} diff --git a/coordinator/internal/transport/http/templates/login.html b/coordinator/internal/transport/http/templates/login.html new file mode 100644 index 0000000..9482e91 --- /dev/null +++ b/coordinator/internal/transport/http/templates/login.html @@ -0,0 +1,27 @@ +{{define "login.html"}} + + + + + + Sign in · SciMesh + + + +
+

SciMesh

+

Sign in

+
+ + + + + +
+ {{if .Error}}

{{.Error}}

{{end}} +

No account? Register

+
+ + +{{end}} diff --git a/coordinator/internal/transport/http/templates/register.html b/coordinator/internal/transport/http/templates/register.html new file mode 100644 index 0000000..e05e2fe --- /dev/null +++ b/coordinator/internal/transport/http/templates/register.html @@ -0,0 +1,28 @@ +{{define "register.html"}} + + + + + + Register · SciMesh + + + +
+

SciMesh

+

Create account

+
+ + + + +

At least 8 characters.

+ +
+ {{if .Error}}

{{.Error}}

{{end}} +

Already have an account? Sign in

+
+ + +{{end}} diff --git a/coordinator/internal/transport/http/ui_auth.go b/coordinator/internal/transport/http/ui_auth.go new file mode 100644 index 0000000..4741126 --- /dev/null +++ b/coordinator/internal/transport/http/ui_auth.go @@ -0,0 +1,171 @@ +package http + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" + tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token" +) + +// sessionCookie holds the userservice JWT for the operator UI. It is httpOnly so +// page scripts cannot read the token, and scoped to /ui so it never rides along +// with worker API calls. +const sessionCookie = "scimesh_session" + +// withUISession gates the operator UI on a valid userservice session cookie. +// A missing or invalid token redirects to the login page rather than returning +// 401, because the caller here is a browser, not an API client. On success it +// stamps the requester so downstream handlers can scope views by owner. +func withUISession(v tokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + redirectToLogin(w, r) + return + } + claims, err := v.Verify(c.Value) + if err != nil { + // Expired or tampered: drop the stale cookie and re-authenticate. + clearSessionCookie(w, r) + redirectToLogin(w, r) + return + } + ctx := authctx.With(r.Context(), authctx.Requester{ + UserID: claims.UserID, + Role: claims.Role, + Verified: claims.Verified, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// tokenVerifier is satisfied by *token.Verifier; taking an interface keeps the +// UI auth testable with a stub. +type tokenVerifier interface { + Verify(raw string) (tokenpkg.Claims, error) +} + +func (s *Server) handleUILoginForm(w http.ResponseWriter, r *http.Request) { + s.renderUI(w, "login.html", map[string]any{"Error": r.URL.Query().Get("error")}) +} + +func (s *Server) handleUIRegisterForm(w http.ResponseWriter, r *http.Request) { + s.renderUI(w, "register.html", map[string]any{"Error": r.URL.Query().Get("error")}) +} + +// handleUILogin exchanges the submitted credentials for a userservice token and +// stores it in the session cookie. The coordinator never sees or stores the +// password beyond forwarding it once. +func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) { + email, password := r.FormValue("email"), r.FormValue("password") + + status, body, err := s.callUserservice(r.Context(), "/login", email, password) + if err != nil { + s.log.Error("userservice login call", "err", err) + http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther) + return + } + if status != http.StatusOK { + http.Redirect(w, r, "/ui/login?error=invalid+email+or+password", http.StatusSeeOther) + return + } + + var resp struct { + Token string `json:"token"` + } + if err := json.Unmarshal(body, &resp); err != nil || resp.Token == "" { + http.Redirect(w, r, "/ui/login?error=service+unavailable", http.StatusSeeOther) + return + } + setSessionCookie(w, r, resp.Token) + http.Redirect(w, r, "/ui", http.StatusSeeOther) +} + +// handleUIRegister creates an account through the userservice, then sends the +// user to the login page. The new account is a plain user until an admin +// promotes or verifies it. +func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) { + email, password := r.FormValue("email"), r.FormValue("password") + + status, _, err := s.callUserservice(r.Context(), "/register", email, password) + if err != nil { + s.log.Error("userservice register call", "err", err) + http.Redirect(w, r, "/ui/register?error=service+unavailable", http.StatusSeeOther) + return + } + switch status { + case http.StatusCreated: + http.Redirect(w, r, "/ui/login?error=registered,+please+log+in", http.StatusSeeOther) + case http.StatusConflict: + http.Redirect(w, r, "/ui/register?error=email+already+registered", http.StatusSeeOther) + default: + http.Redirect(w, r, "/ui/register?error=invalid+email+or+password", http.StatusSeeOther) + } +} + +func (s *Server) handleUILogout(w http.ResponseWriter, r *http.Request) { + clearSessionCookie(w, r) + http.Redirect(w, r, "/ui/login", http.StatusSeeOther) +} + +// callUserservice POSTs credentials to the userservice and returns its status +// and body. It is the only runtime dependency on the userservice — login and +// registration; token verification stays local. +func (s *Server) callUserservice(ctx context.Context, path, email, password string) (int, []byte, error) { + payload, _ := json.Marshal(map[string]string{"email": email, "password": password}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.userserviceURL+path, bytes.NewReader(payload)) + if err != nil { + return 0, nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return 0, nil, err + } + defer func() { _ = resp.Body.Close() }() + + // Cap the response; login/register bodies are tiny. + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, nil, err + } + return resp.StatusCode, body, nil +} + +func setSessionCookie(w http.ResponseWriter, r *http.Request, token string) { + // Secure is set under TLS; a local demo runs plain HTTP, where forcing + // Secure would stop the browser from ever sending the cookie back. + http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design + Name: sessionCookie, + Value: token, + Path: "/ui", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(24 * time.Hour), + }) +} + +func clearSessionCookie(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure follows r.TLS by design + Name: sessionCookie, + Value: "", + Path: "/ui", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +func redirectToLogin(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/login", http.StatusSeeOther) +} diff --git a/coordinator/internal/transport/http/ui_auth_internal_test.go b/coordinator/internal/transport/http/ui_auth_internal_test.go new file mode 100644 index 0000000..081ad37 --- /dev/null +++ b/coordinator/internal/transport/http/ui_auth_internal_test.go @@ -0,0 +1,175 @@ +package http + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/uuid" + + "github.com/emil28092005/SciMesh/coordinator/internal/authctx" + tokenpkg "github.com/emil28092005/SciMesh/coordinator/internal/token" +) + +// newReq builds a request carrying a context, which http.NewRequestWithContext +// provides on go1.22 (httptest.NewRequestWithContext needs go1.23). +func newReq(method, target string, body io.Reader) *http.Request { + req, err := http.NewRequestWithContext(context.Background(), method, target, body) + if err != nil { + panic(err) + } + return req +} + +type stubVerifier struct { + claims tokenpkg.Claims + err error +} + +func (s stubVerifier) Verify(string) (tokenpkg.Claims, error) { return s.claims, s.err } + +func TestWithUISessionRedirectsWithoutCookie(t *testing.T) { + h := withUISession(stubVerifier{})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler must not run without a session") + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newReq(http.MethodGet, "/ui", nil)) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("got %d, want 303", rec.Code) + } + if loc := rec.Header().Get("Location"); loc != "/ui/login" { + t.Errorf("redirect = %q, want /ui/login", loc) + } +} + +func TestWithUISessionAcceptsValidCookieAndStampsRequester(t *testing.T) { + id := uuid.New() + verifier := stubVerifier{claims: tokenpkg.Claims{UserID: id, Role: "admin", Verified: true}} + + var gotReq authctx.Requester + var ok bool + h := withUISession(verifier)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + gotReq, ok = authctx.From(r.Context()) + })) + + req := newReq(http.MethodGet, "/ui", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "valid.jwt"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if !ok || gotReq.UserID != id || gotReq.Role != "admin" || !gotReq.Verified { + t.Errorf("requester = %+v (ok=%v), want id=%v admin verified", gotReq, ok, id) + } +} + +func TestWithUISessionClearsInvalidCookie(t *testing.T) { + h := withUISession(stubVerifier{err: errors.New("expired")})(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler must not run with an invalid token") + })) + req := newReq(http.MethodGet, "/ui", nil) + req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "stale.jwt"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("got %d, want 303", rec.Code) + } + if c := rec.Result().Cookies(); len(c) == 0 || c[0].MaxAge >= 0 { + t.Error("stale cookie must be cleared (MaxAge < 0)") + } +} + +// newLoginServer builds a Server whose userservice calls hit stub. +func newLoginServer(stub *httptest.Server) *Server { + return &Server{ + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + userserviceURL: strings.TrimRight(stub.URL, "/"), + httpClient: stub.Client(), + } +} + +func postForm(path string, form url.Values) *http.Request { + req := newReq(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req +} + +func TestHandleUILoginSetsCookieOnSuccess(t *testing.T) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/login" { + t.Errorf("unexpected path %q", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"token":"issued.jwt.here"}`)) + })) + defer stub.Close() + s := newLoginServer(stub) + + rec := httptest.NewRecorder() + s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"password123"}})) + + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/ui" { + t.Fatalf("got %d -> %q, want 303 -> /ui", rec.Code, rec.Header().Get("Location")) + } + cookies := rec.Result().Cookies() + if len(cookies) == 0 || cookies[0].Name != sessionCookie || cookies[0].Value != "issued.jwt.here" { + t.Errorf("session cookie not set: %+v", cookies) + } + if !cookies[0].HttpOnly { + t.Error("session cookie must be httpOnly") + } +} + +func TestHandleUILoginRejectsBadCredentials(t *testing.T) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer stub.Close() + s := newLoginServer(stub) + + rec := httptest.NewRecorder() + s.handleUILogin(rec, postForm("/ui/login", url.Values{"email": {"a@b.com"}, "password": {"wrong"}})) + + if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/ui/login?error=") { + t.Fatalf("got %d -> %q, want 303 -> /ui/login?error=", rec.Code, rec.Header().Get("Location")) + } + if len(rec.Result().Cookies()) != 0 { + t.Error("no cookie must be set on failed login") + } +} + +func TestHandleUIRegisterConflict(t *testing.T) { + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + })) + defer stub.Close() + s := newLoginServer(stub) + + rec := httptest.NewRecorder() + s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"dup@b.com"}, "password": {"password123"}})) + + if got := rec.Header().Get("Location"); !strings.Contains(got, "already+registered") { + t.Errorf("register conflict redirect = %q", got) + } +} + +func TestHandleUILogoutClearsCookie(t *testing.T) { + s := &Server{log: slog.New(slog.NewTextHandler(io.Discard, nil))} + rec := httptest.NewRecorder() + s.handleUILogout(rec, newReq(http.MethodPost, "/ui/logout", nil)) + + if rec.Header().Get("Location") != "/ui/login" { + t.Errorf("logout redirect = %q", rec.Header().Get("Location")) + } + c := rec.Result().Cookies() + if len(c) == 0 || c[0].MaxAge >= 0 { + t.Error("logout must clear the session cookie") + } +}