diff --git a/service/internal/api/middleware.go b/service/internal/api/middleware.go
index ef6f7f8..853b47e 100644
--- a/service/internal/api/middleware.go
+++ b/service/internal/api/middleware.go
@@ -2,6 +2,7 @@ package api
import (
"context"
+ "errors"
"net/http"
"strings"
@@ -76,7 +77,11 @@ func (app *App) withMiddleware(next http.Handler) http.Handler {
security := effectiveSecurity(settingsPayload.Security)
session, err := app.Sessions.LookupValidSession(writer, request, security)
if err != nil {
- writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
+ errorMessage := "authentication required"
+ if errors.Is(err, auth.ErrSessionIdle) {
+ errorMessage = "session_idle"
+ }
+ writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: errorMessage})
return
}
diff --git a/service/internal/api/middleware_idle_test.go b/service/internal/api/middleware_idle_test.go
new file mode 100644
index 0000000..77f66a8
--- /dev/null
+++ b/service/internal/api/middleware_idle_test.go
@@ -0,0 +1,83 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
+ "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
+)
+
+func TestMiddlewareReturnsSessionIdle(t *testing.T) {
+ configDir := t.TempDir()
+ cookie := seedCompletedSetup(t, configDir)
+
+ keyBytes := make([]byte, 32)
+ for index := range keyBytes {
+ keyBytes[index] = byte(index + 1)
+ }
+
+ store, err := settings.LoadSessions(configDir, keyBytes)
+ if err != nil {
+ t.Fatalf("LoadSessions: %v", err)
+ }
+ if len(store.Sessions) != 1 {
+ t.Fatalf("expected 1 session, got %d", len(store.Sessions))
+ }
+ store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-31 * time.Minute)
+ if err := settings.SaveSessions(configDir, store, keyBytes); err != nil {
+ t.Fatalf("SaveSessions: %v", err)
+ }
+
+ router := NewRouter(configDir)
+ request := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/status", nil), cookie)
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusUnauthorized {
+ t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
+ }
+
+ var payload apiErrorResponse
+ if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.Error != "session_idle" {
+ t.Fatalf("expected session_idle, got %q", payload.Error)
+ }
+
+ cleared := false
+ for _, setCookie := range recorder.Result().Cookies() {
+ if setCookie.Name == auth.SessionCookieName && setCookie.MaxAge < 0 {
+ cleared = true
+ }
+ }
+ if !cleared {
+ t.Fatal("expected session cookie to be cleared")
+ }
+}
+
+func TestMiddlewareReturnsAuthenticationRequiredWithoutCookie(t *testing.T) {
+ configDir := t.TempDir()
+ _ = seedCompletedSetup(t, configDir)
+
+ router := NewRouter(configDir)
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusUnauthorized {
+ t.Fatalf("expected status %d, got %d body=%s", http.StatusUnauthorized, recorder.Code, recorder.Body.String())
+ }
+
+ var payload apiErrorResponse
+ if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.Error != "authentication required" {
+ t.Fatalf("expected authentication required, got %q", payload.Error)
+ }
+}
diff --git a/service/internal/auth/session.go b/service/internal/auth/session.go
index bb646db..ac1e9b9 100644
--- a/service/internal/auth/session.go
+++ b/service/internal/auth/session.go
@@ -20,7 +20,10 @@ const (
sessionRotateAfter = time.Hour
)
-var ErrSessionNotFound = errors.New("session not found")
+var (
+ ErrSessionNotFound = errors.New("session not found")
+ ErrSessionIdle = errors.New("session idle")
+)
// SessionManager persists sessions in sessions.enc and sets hardened cookies.
type SessionManager struct {
@@ -108,6 +111,7 @@ func (manager *SessionManager) LookupValidSession(
lifetime := time.Duration(security.SessionLifetimeHours) * time.Hour
var found *settings.SessionRecord
+ idleMatched := false
remaining := make([]settings.SessionRecord, 0, len(store.Sessions))
for index := range store.Sessions {
session := store.Sessions[index]
@@ -116,6 +120,7 @@ func (manager *SessionManager) LookupValidSession(
}
if session.ID == cookie.Value {
if now.Sub(session.LastSeenAt) > idleLimit {
+ idleMatched = true
continue
}
copySession := session
@@ -129,6 +134,9 @@ func (manager *SessionManager) LookupValidSession(
store.Sessions = remaining
_ = settings.SaveSessions(manager.configDir, store, manager.key)
clearSessionCookie(writer)
+ if idleMatched {
+ return settings.SessionRecord{}, ErrSessionIdle
+ }
return settings.SessionRecord{}, ErrSessionNotFound
}
diff --git a/service/internal/auth/session_test.go b/service/internal/auth/session_test.go
new file mode 100644
index 0000000..56ad29e
--- /dev/null
+++ b/service/internal/auth/session_test.go
@@ -0,0 +1,91 @@
+package auth
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
+)
+
+func TestLookupValidSessionReturnsIdle(t *testing.T) {
+ configDir := t.TempDir()
+ key := testSessionKey()
+ manager := NewSessionManager(configDir, key)
+ security := settings.DefaultSecuritySettings()
+ security.IdleTimeoutMinutes = 5
+
+ createRec := httptest.NewRecorder()
+ record, err := manager.CreateSession(createRec, "user-1", security)
+ if err != nil {
+ t.Fatalf("CreateSession: %v", err)
+ }
+
+ store, err := settings.LoadSessions(configDir, key)
+ if err != nil {
+ t.Fatalf("LoadSessions: %v", err)
+ }
+ if len(store.Sessions) != 1 {
+ t.Fatalf("expected 1 session, got %d", len(store.Sessions))
+ }
+ store.Sessions[0].LastSeenAt = time.Now().UTC().Add(-6 * time.Minute)
+ if err := settings.SaveSessions(configDir, store, key); err != nil {
+ t.Fatalf("SaveSessions: %v", err)
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
+ request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: record.ID})
+ lookupRec := httptest.NewRecorder()
+
+ _, err = manager.LookupValidSession(lookupRec, request, security)
+ if !errors.Is(err, ErrSessionIdle) {
+ t.Fatalf("expected ErrSessionIdle, got %v", err)
+ }
+
+ store, err = settings.LoadSessionsOrEmpty(configDir, key)
+ if err != nil {
+ t.Fatalf("LoadSessionsOrEmpty: %v", err)
+ }
+ if len(store.Sessions) != 0 {
+ t.Fatalf("expected idle session removed, got %#v", store.Sessions)
+ }
+}
+
+func TestLookupValidSessionMissingCookie(t *testing.T) {
+ configDir := t.TempDir()
+ manager := NewSessionManager(configDir, testSessionKey())
+ security := settings.DefaultSecuritySettings()
+
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
+ lookupRec := httptest.NewRecorder()
+
+ _, err := manager.LookupValidSession(lookupRec, request, security)
+ if !errors.Is(err, ErrSessionNotFound) {
+ t.Fatalf("expected ErrSessionNotFound, got %v", err)
+ }
+}
+
+func TestLookupValidSessionUnknownCookie(t *testing.T) {
+ configDir := t.TempDir()
+ manager := NewSessionManager(configDir, testSessionKey())
+ security := settings.DefaultSecuritySettings()
+
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
+ request.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "unknown-session"})
+ lookupRec := httptest.NewRecorder()
+
+ _, err := manager.LookupValidSession(lookupRec, request, security)
+ if !errors.Is(err, ErrSessionNotFound) {
+ t.Fatalf("expected ErrSessionNotFound, got %v", err)
+ }
+}
+
+func testSessionKey() []byte {
+ key := make([]byte, 32)
+ for index := range key {
+ key[index] = byte(index + 3)
+ }
+ return key
+}
diff --git a/webui/src/App.css b/webui/src/App.css
index eeda4ff..b91abe1 100644
--- a/webui/src/App.css
+++ b/webui/src/App.css
@@ -1065,6 +1065,12 @@
font-size: 0.9rem;
}
+.wizard-notice {
+ margin: 0 0 0.85rem;
+ color: var(--ctp-subtext0);
+ font-size: 0.9rem;
+}
+
.wizard-rules {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
resize: vertical;
diff --git a/webui/src/LoginPage.test.tsx b/webui/src/LoginPage.test.tsx
new file mode 100644
index 0000000..eb694ae
--- /dev/null
+++ b/webui/src/LoginPage.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { LoginPage } from './LoginPage'
+
+describe('LoginPage', () => {
+ afterEach(() => {
+ window.history.replaceState(null, '', '/login')
+ vi.restoreAllMocks()
+ })
+
+ it('shows an idle signed-out notice from the query string', () => {
+ window.history.replaceState(null, '', '/login?reason=idle')
+
+ render(
+ {idleMessage} +
+ ) : null} + {errorMessage ? (
{errorMessage}
diff --git a/webui/src/api/client.test.ts b/webui/src/api/client.test.ts
index ada422b..9c3653b 100644
--- a/webui/src/api/client.test.ts
+++ b/webui/src/api/client.test.ts
@@ -14,6 +14,7 @@ import {
fetchStatus,
fetchUsers,
getApiBaseUrl,
+ login,
patchAction,
saveNetwork,
saveSecurity,
@@ -716,3 +717,71 @@ describe('actions API', () => {
)
})
})
+
+describe('session idle redirect', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('reloads to login when an authenticated call returns session_idle', async () => {
+ const assign = vi.fn()
+ vi.stubGlobal('location', {
+ pathname: '/configuration/security',
+ assign,
+ })
+
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ clone: () => ({
+ json: async () => ({ error: 'session_idle' }),
+ }),
+ json: async () => ({ error: 'session_idle' }),
+ })
+
+ await expect(fetchStatus(fetchMock)).rejects.toThrow()
+ expect(assign).toHaveBeenCalledWith('/login?reason=idle')
+ })
+
+ it('does not redirect on login 401', async () => {
+ const assign = vi.fn()
+ vi.stubGlobal('location', {
+ pathname: '/login',
+ assign,
+ })
+
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ clone: () => ({
+ json: async () => ({ error: 'invalid credentials' }),
+ }),
+ json: async () => ({ error: 'invalid credentials' }),
+ })
+
+ await expect(login('Admin', 'wrong', '', fetchMock)).rejects.toThrow(
+ 'invalid credentials',
+ )
+ expect(assign).not.toHaveBeenCalled()
+ })
+
+ it('does not redirect for generic authentication required', async () => {
+ const assign = vi.fn()
+ vi.stubGlobal('location', {
+ pathname: '/',
+ assign,
+ })
+
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ clone: () => ({
+ json: async () => ({ error: 'authentication required' }),
+ }),
+ json: async () => ({ error: 'authentication required' }),
+ })
+
+ await expect(fetchStatus(fetchMock)).rejects.toThrow()
+ expect(assign).not.toHaveBeenCalled()
+ })
+})
diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts
index adc4910..9896776 100644
--- a/webui/src/api/client.ts
+++ b/webui/src/api/client.ts
@@ -154,6 +154,36 @@ async function readErrorMessage(response: Response): Promise