diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go index 6b6a73f..7675262 100644 --- a/internal/server/device_features_api_test.go +++ b/internal/server/device_features_api_test.go @@ -3,11 +3,13 @@ package server import ( "context" "encoding/json" + "errors" "log/slog" "net/http" "net/http/httptest" "strings" "testing" + "time" "vocat/internal/device" "vocat/internal/modem" @@ -354,7 +356,22 @@ func TestHandleUpdateCheckUsesTrustedRepository(t *testing.T) { } func TestHandleUpdateApplyInstallsFromTrustedRepository(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.SetAdmin(context.Background(), "admin", []byte("hash")); err != nil { + t.Fatal(err) + } + tokenHash := []byte("active-session") + if err := database.CreateSession( + context.Background(), 1, tokenHash, []byte("csrf"), time.Now().Add(time.Hour), + ); err != nil { + t.Fatal(err) + } server := &Server{ + store: database, logger: regionTestLogger(), updateRepository: update.DefaultRepository, updateApply: func(_ context.Context, _ *slog.Logger, options update.Options, restart bool) (update.CheckResult, error) { @@ -370,9 +387,21 @@ func TestHandleUpdateApplyInstallsFromTrustedRepository(t *testing.T) { t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body) } data := decodeData(t, recorder) - if data["applied"] != true || data["version"] != "9.9.9" { + if data["applied"] != true || data["version"] != "9.9.9" || data["reauthentication_required"] != true { t.Fatalf("apply data = %#v", data) } + if _, err := database.SessionByTokenHash(context.Background(), tokenHash); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("session must be revoked after update, got %v", err) + } + expired := map[string]bool{} + for _, cookie := range recorder.Result().Cookies() { + if cookie.MaxAge < 0 { + expired[cookie.Name] = true + } + } + if !expired[sessionCookieName] || !expired[csrfCookieName] { + t.Fatalf("auth cookies were not expired: %#v", recorder.Result().Cookies()) + } } func TestE911WebsheetFlow(t *testing.T) { diff --git a/internal/server/general_api.go b/internal/server/general_api.go index 2c016fd..03352e0 100644 --- a/internal/server/general_api.go +++ b/internal/server/general_api.go @@ -421,11 +421,22 @@ func (s *Server) handleUpdateApply(w http.ResponseWriter, r *http.Request) { }) return } + // A binary update changes the trusted server code underneath every active + // browser/API session. Revoke every durable token before scheduling the + // restart and expire this client's cookies so all users must authenticate + // against the newly installed version. + if err := s.store.DeleteAllSessions(r.Context()); err != nil { + s.logger.Error("revoke sessions after update failed", "error", err) + writeError(w, http.StatusInternalServerError, "update_session_revocation_failed", "The update was installed, but active sessions could not be revoked; restart the service and sign in again.") + return + } + s.clearAuthCookies(w) writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ - "applied": true, - "version": result.Latest, - "message": "Update verified and installed; the service is restarting.", + "applied": true, + "version": result.Latest, + "reauthentication_required": true, + "message": "Update verified and installed; all sessions were revoked and the service is restarting.", }, }) if flusher, ok := w.(http.Flusher); ok {