Feat v0.6.9 session lifetime config

- Admin-configurable session TTLs (access: 5m–60m, refresh: 1h–90d)
  stored in global_settings under "session" key
- "Keep me logged in" checkbox on login form; unchecked caps refresh
  token at 24h, checked uses full admin-configured TTL
- generateTokens() reads config instead of hardcoded 15m/7d; expires_in
  and refresh_expires_in in JSON response reflect actual TTLs
- Cookie max-age tracks chosen refresh lifetime (not hardcoded 604800)
- Optional idle timeout: admin toggle + configurable duration; server
  rejects refresh if last_activity_at exceeds threshold
- Client SDK activity ping (POST /auth/activity, debounced 1/min)
- Admin Settings > Session section with dropdowns for all three knobs
- 7 new unit tests for duration parsing, clamping, and defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 09:35:43 +00:00
parent a675d0440d
commit 03c7bbed99
14 changed files with 528 additions and 37 deletions

View File

@@ -24,13 +24,17 @@ func (p *BuiltinProvider) SupportsRegistration() bool { return true }
func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) { func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
var req struct { var req struct {
Login string `json:"login" binding:"required"` Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
KeepLogin bool `json:"keep_login"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
return nil, err return nil, err
} }
// Stash keep_login in context for generateTokens
c.Set("keep_login", req.KeepLogin)
user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login) user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login)
if err != nil { if err != nil {
return nil, ErrInvalidCreds return nil, ErrInvalidCreds

View File

@@ -56,9 +56,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ revoked_at TIMESTAMPTZ,
keep_login BOOLEAN DEFAULT FALSE,
last_activity_at TIMESTAMPTZ
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);

View File

@@ -41,9 +41,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
revoked_at TEXT revoked_at TEXT,
keep_login INTEGER DEFAULT 0,
last_activity_at TEXT
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);

View File

@@ -72,7 +72,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
return return
} }
tokens, err := h.generateTokens(result.User) tokens, err := h.generateTokens(result.User, false)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return return
@@ -94,7 +94,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID) h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
tokens, err := h.generateTokens(result.User) // Check for keep_login flag from request body.
// The provider already consumed the body, so we read from gin context
// if the provider stashed it there; otherwise default to false.
keepLogin, _ := c.Get("keep_login")
keep, _ := keepLogin.(bool)
tokens, err := h.generateTokens(result.User, keep)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return return
@@ -113,22 +119,32 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
} }
tokenHash := hashToken(req.RefreshToken) tokenHash := hashToken(req.RefreshToken)
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash) info, err := h.stores.Users.GetRefreshTokenInfo(c.Request.Context(), tokenHash)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return return
} }
// Idle timeout check
sc := LoadSessionConfig(c.Request.Context(), h.stores.GlobalConfig)
if sc.IdleTimeout > 0 && info.LastActivityAt != nil {
if time.Since(*info.LastActivityAt) > sc.IdleTimeout {
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired due to inactivity"})
return
}
}
// Revoke the used token (rotate) // Revoke the used token (rotate)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash) h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID) user, err := h.stores.Users.GetByID(c.Request.Context(), info.UserID)
if err != nil || !user.IsActive { if err != nil || !user.IsActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
return return
} }
tokens, err := h.generateTokens(user) tokens, err := h.generateTokens(user, info.KeepLogin)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return return
@@ -159,6 +175,19 @@ func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "logged out"}) c.JSON(http.StatusOK, gin.H{"message": "logged out"})
} }
// Activity updates last_activity_at on the caller's active refresh tokens.
// Called by the client SDK on user interaction (debounced, max 1/min).
// POST /api/v1/auth/activity (requires auth middleware)
func (h *AuthHandler) Activity(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
h.stores.Users.UpdateRefreshTokenActivity(c.Request.Context(), userID.(string))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── OIDC Flow ────────────────────────────── // ── OIDC Flow ──────────────────────────────
// OIDCLogin initiates the authorization code flow by redirecting to the IdP. // OIDCLogin initiates the authorization code flow by redirecting to the IdP.
@@ -271,7 +300,7 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
} }
// Issue internal JWT // Issue internal JWT
tokens, err := h.generateTokens(result.User) tokens, err := h.generateTokens(result.User, false)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return return
@@ -287,9 +316,13 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
userJSON, _ := json.Marshal(tokens["user"]) userJSON, _ := json.Marshal(tokens["user"])
// Set page-auth cookie too (for SSR middleware). // Set page-auth cookie too (for SSR middleware).
// MaxAge = 7 days — matches refresh token lifetime so the cookie // MaxAge matches refresh token lifetime so the cookie
// survives until JS can proactively refresh the access token. // survives until JS can proactively refresh the access token.
c.SetCookie("arm_token", accessToken, 604800, "/", "", false, false) refreshExpiresIn, _ := tokens["refresh_expires_in"].(int)
if refreshExpiresIn <= 0 {
refreshExpiresIn = 604800
}
c.SetCookie("arm_token", accessToken, refreshExpiresIn, "/", "", false, false)
// Base64-encode the token payload for the fragment // Base64-encode the token payload for the fragment
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`, payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
@@ -300,13 +333,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
c.Redirect(http.StatusFound, dest) c.Redirect(http.StatusFound, dest)
} }
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) { func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) {
// Access token (15 min) sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig)
// Access token
accessClaims := Claims{ accessClaims := Claims{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(sc.AccessTokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.New().String(), ID: uuid.New().String(),
}, },
@@ -317,20 +352,26 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
return nil, err return nil, err
} }
// Refresh token (7 days) // Refresh token — keepLogin=false caps at 24h (session-length)
refreshTTL := sc.RefreshTokenTTL
if !keepLogin && refreshTTL > 24*time.Hour {
refreshTTL = 24 * time.Hour
}
refreshRaw := uuid.New().String() refreshRaw := uuid.New().String()
refreshHash := hashToken(refreshRaw) refreshHash := hashToken(refreshRaw)
expiresAt := time.Now().Add(7 * 24 * time.Hour) expiresAt := time.Now().Add(refreshTTL)
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil { if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt, keepLogin); err != nil {
log.Printf("warn: failed to store refresh token: %v", err) log.Printf("warn: failed to store refresh token: %v", err)
} }
return gin.H{ return gin.H{
"access_token": accessString, "access_token": accessString,
"refresh_token": refreshRaw, "refresh_token": refreshRaw,
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 900, "expires_in": int(sc.AccessTokenTTL.Seconds()),
"refresh_expires_in": int(refreshTTL.Seconds()),
"user": gin.H{ "user": gin.H{
"id": user.ID, "id": user.ID,
"username": user.Username, "username": user.Username,

View File

@@ -0,0 +1,103 @@
package handlers
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"armature/store"
)
// SessionConfig holds parsed, clamped session TTL settings.
type SessionConfig struct {
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
IdleTimeout time.Duration // 0 = disabled
}
// Defaults & clamp bounds
const (
defaultAccessTTL = 15 * time.Minute
defaultRefreshTTL = 7 * 24 * time.Hour // 7d
defaultIdleTimeout = 0 // disabled
minAccessTTL = 5 * time.Minute
maxAccessTTL = 60 * time.Minute
minRefreshTTL = 1 * time.Hour
maxRefreshTTL = 90 * 24 * time.Hour // 90d
minIdleTTL = 5 * time.Minute
maxIdleTTL = 24 * time.Hour
)
// LoadSessionConfig reads session settings from the global_settings table.
// Returns defaults if the key is missing or values are unparseable.
func LoadSessionConfig(ctx context.Context, gc store.GlobalConfigStore) SessionConfig {
sc := SessionConfig{
AccessTokenTTL: defaultAccessTTL,
RefreshTokenTTL: defaultRefreshTTL,
IdleTimeout: defaultIdleTimeout,
}
raw, err := gc.Get(ctx, "session")
if err != nil || raw == nil {
return sc
}
if v, ok := raw["access_token_ttl"].(string); ok && v != "" {
if d, err := parseDurationString(v); err == nil {
sc.AccessTokenTTL = clamp(d, minAccessTTL, maxAccessTTL)
}
}
if v, ok := raw["refresh_token_ttl"].(string); ok && v != "" {
if d, err := parseDurationString(v); err == nil {
sc.RefreshTokenTTL = clamp(d, minRefreshTTL, maxRefreshTTL)
}
}
if v, ok := raw["idle_timeout"].(string); ok && v != "" {
if d, err := parseDurationString(v); err == nil {
sc.IdleTimeout = clamp(d, minIdleTTL, maxIdleTTL)
}
}
return sc
}
// parseDurationString parses strings like "15m", "2h", "7d", "30d".
// Supports suffixes: s (seconds), m (minutes), h (hours), d (days).
func parseDurationString(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty duration")
}
// Try Go's standard parser first (handles "15m", "2h30m", etc.)
if d, err := time.ParseDuration(s); err == nil {
return d, nil
}
// Handle "Xd" suffix (days)
if strings.HasSuffix(s, "d") {
numStr := strings.TrimSuffix(s, "d")
n, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return 0, fmt.Errorf("invalid duration: %s", s)
}
return time.Duration(n * float64(24*time.Hour)), nil
}
return 0, fmt.Errorf("invalid duration: %s", s)
}
func clamp(d, min, max time.Duration) time.Duration {
if d < min {
return min
}
if d > max {
return max
}
return d
}

View File

@@ -0,0 +1,188 @@
package handlers
import (
"context"
"testing"
"time"
"armature/models"
"armature/store"
)
// mockGlobalConfig implements store.GlobalConfigStore for tests.
type mockGlobalConfig struct {
data map[string]models.JSONMap
}
func (m *mockGlobalConfig) Get(_ context.Context, key string) (models.JSONMap, error) {
if v, ok := m.data[key]; ok {
return v, nil
}
return nil, nil
}
func (m *mockGlobalConfig) Set(_ context.Context, _ string, _ models.JSONMap, _ string) error {
return nil
}
func (m *mockGlobalConfig) GetAll(_ context.Context) (map[string]models.JSONMap, error) {
return m.data, nil
}
func (m *mockGlobalConfig) SaveOIDCState(_ context.Context, _, _, _ string) error { return nil }
func (m *mockGlobalConfig) ConsumeOIDCState(_ context.Context, _ string) (string, string, error) {
return "", "", nil
}
func (m *mockGlobalConfig) CleanupOIDCState(_ context.Context) error { return nil }
func (m *mockGlobalConfig) GetString(_ context.Context, _ string) (string, error) { return "", nil }
func newMockGC(session models.JSONMap) store.GlobalConfigStore {
return &mockGlobalConfig{data: map[string]models.JSONMap{"session": session}}
}
// ── parseDurationString tests ──────────────
func TestParseDurationString(t *testing.T) {
tests := []struct {
input string
want time.Duration
err bool
}{
{"15m", 15 * time.Minute, false},
{"2h", 2 * time.Hour, false},
{"7d", 7 * 24 * time.Hour, false},
{"90d", 90 * 24 * time.Hour, false},
{"30m", 30 * time.Minute, false},
{"1h30m", 90 * time.Minute, false},
{"", 0, true},
{"abc", 0, true},
{"d", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseDurationString(tt.input)
if tt.err {
if err == nil {
t.Errorf("expected error for %q, got %v", tt.input, got)
}
return
}
if err != nil {
t.Fatalf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parseDurationString(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// ── LoadSessionConfig tests ────────────────
func TestSessionConfigDefaults(t *testing.T) {
gc := &mockGlobalConfig{data: map[string]models.JSONMap{}}
sc := LoadSessionConfig(context.Background(), gc)
if sc.AccessTokenTTL != 15*time.Minute {
t.Errorf("AccessTokenTTL = %v, want 15m", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 7*24*time.Hour {
t.Errorf("RefreshTokenTTL = %v, want 7d", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 0 {
t.Errorf("IdleTimeout = %v, want 0", sc.IdleTimeout)
}
}
func TestSessionConfigCustomValues(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "30m",
"refresh_token_ttl": "30d",
"idle_timeout": "1h",
})
sc := LoadSessionConfig(context.Background(), gc)
if sc.AccessTokenTTL != 30*time.Minute {
t.Errorf("AccessTokenTTL = %v, want 30m", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 30*24*time.Hour {
t.Errorf("RefreshTokenTTL = %v, want 30d", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 1*time.Hour {
t.Errorf("IdleTimeout = %v, want 1h", sc.IdleTimeout)
}
}
func TestSessionConfigClampLow(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "1m", // below 5m min
"refresh_token_ttl": "30m", // below 1h min
"idle_timeout": "1m", // below 5m min
})
sc := LoadSessionConfig(context.Background(), gc)
if sc.AccessTokenTTL != 5*time.Minute {
t.Errorf("AccessTokenTTL clamped = %v, want 5m", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 1*time.Hour {
t.Errorf("RefreshTokenTTL clamped = %v, want 1h", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 5*time.Minute {
t.Errorf("IdleTimeout clamped = %v, want 5m", sc.IdleTimeout)
}
}
func TestSessionConfigClampHigh(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "2h", // above 60m max
"refresh_token_ttl": "365d", // above 90d max
"idle_timeout": "48h", // above 24h max
})
sc := LoadSessionConfig(context.Background(), gc)
if sc.AccessTokenTTL != 60*time.Minute {
t.Errorf("AccessTokenTTL clamped = %v, want 60m", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 90*24*time.Hour {
t.Errorf("RefreshTokenTTL clamped = %v, want 90d", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 24*time.Hour {
t.Errorf("IdleTimeout clamped = %v, want 24h", sc.IdleTimeout)
}
}
func TestSessionConfigEmptyIdleTimeout(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "15m",
"refresh_token_ttl": "7d",
"idle_timeout": "",
})
sc := LoadSessionConfig(context.Background(), gc)
if sc.IdleTimeout != 0 {
t.Errorf("IdleTimeout = %v, want 0 (disabled)", sc.IdleTimeout)
}
}
func TestSessionConfigInvalidValues(t *testing.T) {
gc := newMockGC(models.JSONMap{
"access_token_ttl": "not-a-duration",
"refresh_token_ttl": "xyz",
"idle_timeout": "---",
})
sc := LoadSessionConfig(context.Background(), gc)
// Should fall back to defaults
if sc.AccessTokenTTL != 15*time.Minute {
t.Errorf("AccessTokenTTL = %v, want 15m (default)", sc.AccessTokenTTL)
}
if sc.RefreshTokenTTL != 7*24*time.Hour {
t.Errorf("RefreshTokenTTL = %v, want 7d (default)", sc.RefreshTokenTTL)
}
if sc.IdleTimeout != 0 {
t.Errorf("IdleTimeout = %v, want 0 (default)", sc.IdleTimeout)
}
}

View File

@@ -444,6 +444,13 @@ func main() {
wfScanner.Start() wfScanner.Start()
defer wfScanner.Stop() defer wfScanner.Stop()
// ── Activity ping (authenticated, rate-limited) ──
// Client SDK calls this on user interaction (debounced, max 1/min)
// to update last_activity_at for idle-timeout tracking.
activityGroup := api.Group("/auth")
activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
activityGroup.POST("/activity", authH.Activity)
// ── Protected routes ──────────────────── // ── Protected routes ────────────────────
protected := api.Group("") protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache)) protected.Use(middleware.Auth(cfg, stores.Users, userCache))

View File

@@ -13,6 +13,13 @@ import (
// ErrSystemGroup is returned when attempting to delete a system-sourced group. // ErrSystemGroup is returned when attempting to delete a system-sourced group.
var ErrSystemGroup = errors.New("system groups cannot be deleted") var ErrSystemGroup = errors.New("system groups cannot be deleted")
// RefreshTokenInfo holds extended metadata for a refresh token row.
type RefreshTokenInfo struct {
UserID string
KeepLogin bool
LastActivityAt *time.Time
}
// ========================================= // =========================================
// STORES — Data Access Layer // STORES — Data Access Layer
// ========================================= // =========================================
@@ -79,11 +86,13 @@ type UserStore interface {
ListActiveUserIDs(ctx context.Context) ([]string, error) ListActiveUserIDs(ctx context.Context) ([]string, error)
// Refresh tokens // Refresh tokens
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error) GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error CleanExpiredTokens(ctx context.Context) error
UpdateRefreshTokenActivity(ctx context.Context, userID string) error
// ── CS1 additions ── // ── CS1 additions ──

View File

@@ -142,8 +142,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ────────────────────────── // ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt) _, err := DB.ExecContext(ctx,
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES ($1, $2, $3, $4, NOW())`,
userID, tokenHash, expiresAt, keepLogin)
return err return err
} }
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
@@ -151,6 +153,17 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID) err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID)
return userID, err return userID, err
} }
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
var info store.RefreshTokenInfo
err := DB.QueryRowContext(ctx,
`SELECT user_id, COALESCE(keep_login, false), last_activity_at
FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
tokenHash).Scan(&info.UserID, &info.KeepLogin, &info.LastActivityAt)
if err != nil {
return nil, err
}
return &info, nil
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash) _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
return err return err
@@ -163,6 +176,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'") _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
return err return err
} }
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET last_activity_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
userID)
return err
}
// ── Internal ──────────────────────────────── // ── Internal ────────────────────────────────

View File

@@ -148,9 +148,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ────────────────────────── // ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`, _, err := DB.ExecContext(ctx,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt)) `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin)
return err return err
} }
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
@@ -158,6 +159,23 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID) err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID)
return userID, err return userID, err
} }
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
var info store.RefreshTokenInfo
var lastAct *string
err := DB.QueryRowContext(ctx,
`SELECT user_id, COALESCE(keep_login, 0), last_activity_at
FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`,
tokenHash).Scan(&info.UserID, &info.KeepLogin, &lastAct)
if err != nil {
return nil, err
}
if lastAct != nil {
if t, err := time.Parse(timeFmt, *lastAct); err == nil {
info.LastActivityAt = &t
}
}
return &info, nil
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash) _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
return err return err
@@ -170,6 +188,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')") _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
return err return err
} }
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET last_activity_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL",
userID)
return err
}
// ── Internal ──────────────────────────────── // ── Internal ────────────────────────────────

View File

@@ -99,6 +99,13 @@
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
} }
.login-keep-login {
display: flex; align-items: center; gap: 6px; margin-top: 4px;
font-size: 0.82rem; color: var(--text-secondary, #999); cursor: pointer;
user-select: none;
}
.login-keep-login input[type="checkbox"] { margin: 0; cursor: pointer; }
.login-auth-error { .login-auth-error {
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem; color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
} }

View File

@@ -36,15 +36,18 @@ export function createAuth() {
// ── Token persistence ─────────────────────── // ── Token persistence ───────────────────────
let _cookieMaxAge = 604800; // default 7 days, updated from server response
function _saveTokens() { function _saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({ localStorage.setItem(_storageKey, JSON.stringify({
accessToken: _accessToken, accessToken: _accessToken,
refreshToken: _refreshToken, refreshToken: _refreshToken,
user: _user, user: _user,
cookieMaxAge: _cookieMaxAge,
})); }));
// Cookie sync for Go template page auth // Cookie sync for Go template page auth
if (_accessToken) { if (_accessToken) {
document.cookie = `arm_token=${_accessToken}; path=/; max-age=604800; SameSite=Strict`; document.cookie = `arm_token=${_accessToken}; path=/; max-age=${_cookieMaxAge}; SameSite=Strict`;
} else { } else {
document.cookie = 'arm_token=; path=/; max-age=0'; document.cookie = 'arm_token=; path=/; max-age=0';
} }
@@ -57,6 +60,7 @@ export function createAuth() {
_accessToken = saved.accessToken || null; _accessToken = saved.accessToken || null;
_refreshToken = saved.refreshToken || null; _refreshToken = saved.refreshToken || null;
_user = saved.user || null; _user = saved.user || null;
if (saved.cookieMaxAge) _cookieMaxAge = saved.cookieMaxAge;
} }
} catch (_) { /* corrupt storage */ } } catch (_) { /* corrupt storage */ }
} }
@@ -81,6 +85,7 @@ export function createAuth() {
_accessToken = data.access_token; _accessToken = data.access_token;
_refreshToken = data.refresh_token; _refreshToken = data.refresh_token;
_user = data.user; _user = data.user;
if (data.refresh_expires_in) _cookieMaxAge = data.refresh_expires_in;
_saveTokens(); _saveTokens();
_scheduleRefresh(data.expires_in || 900); _scheduleRefresh(data.expires_in || 900);
} }
@@ -149,8 +154,8 @@ export function createAuth() {
/** /**
* Login with credentials. Stores tokens, fetches permissions, emits event. * Login with credentials. Stores tokens, fetches permissions, emits event.
*/ */
async login(login, password) { async login(login, password, keepLogin = false) {
const data = await _restClient.post('/api/v1/auth/login', { login, password }); const data = await _restClient.post('/api/v1/auth/login', { login, password, keep_login: keepLogin });
_setAuth(data); _setAuth(data);
await _fetchPermissions(); await _fetchPermissions();
_emit('auth.login', { user: auth.user }, { localOnly: true }); _emit('auth.login', { user: auth.user }, { localOnly: true });
@@ -245,9 +250,30 @@ export function createAuth() {
if (_accessToken) { if (_accessToken) {
_emit('auth.boot', { user: auth.user }, { localOnly: true }); _emit('auth.boot', { user: auth.user }, { localOnly: true });
auth._startActivityTracking();
} }
}, },
// ── Activity Ping (idle-timeout support) ──
/**
* Start activity tracking. Sends POST /api/v1/auth/activity
* on user interaction, debounced to max once per minute.
*/
_startActivityTracking() {
let lastPing = 0;
const INTERVAL = 60_000; // 1 minute
const ping = () => {
if (!_accessToken) return;
const now = Date.now();
if (now - lastPing < INTERVAL) return;
lastPing = now;
_restClient.post('/api/v1/auth/activity', {}).catch(() => {});
};
document.addEventListener('click', ping, { passive: true });
document.addEventListener('keydown', ping, { passive: true });
},
// ── Internal (called by rest-client / index.js) ── // ── Internal (called by rest-client / index.js) ──
_getToken() { return _accessToken; }, _getToken() { return _accessToken; },

View File

@@ -37,6 +37,10 @@ export default function SettingsSection() {
footer_enabled: !!cfg_.footer?.enabled, footer_enabled: !!cfg_.footer?.enabled,
footer_text: cfg_.footer?.text || '', footer_text: cfg_.footer?.text || '',
package_registry_url: cfg_.package_registry?.url || '', package_registry_url: cfg_.package_registry?.url || '',
session_access_ttl: cfg_.session?.access_token_ttl || '15m',
session_refresh_ttl: cfg_.session?.refresh_token_ttl || '7d',
session_idle_enabled: !!cfg_.session?.idle_timeout,
session_idle_timeout: cfg_.session?.idle_timeout || '2h',
}); });
setVault(v); setVault(v);
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
@@ -82,6 +86,13 @@ export default function SettingsSection() {
// Package Registry // Package Registry
await sw.api.admin.settings.update('package_registry', { value: { url: cfg.package_registry_url } }); await sw.api.admin.settings.update('package_registry', { value: { url: cfg.package_registry_url } });
// Session
await sw.api.admin.settings.update('session', { value: {
access_token_ttl: cfg.session_access_ttl,
refresh_token_ttl: cfg.session_refresh_ttl,
idle_timeout: cfg.session_idle_enabled ? cfg.session_idle_timeout : '',
}});
sw.toast('Settings saved', 'success'); sw.toast('Settings saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(false); } finally { setSaving(false); }
@@ -163,6 +174,48 @@ export default function SettingsSection() {
`} `}
</div> </div>
<div class="settings-section"><h3>Session</h3>
<div class="form-row">
<div class="form-group"><label>Access token TTL</label>
<select value=${cfg.session_access_ttl} onChange=${e => set('session_access_ttl', e.target.value)}>
<option value="5m">5 minutes</option>
<option value="10m">10 minutes</option>
<option value="15m">15 minutes (default)</option>
<option value="30m">30 minutes</option>
<option value="60m">60 minutes</option>
</select>
</div>
<div class="form-group"><label>Refresh token TTL</label>
<select value=${cfg.session_refresh_ttl} onChange=${e => set('session_refresh_ttl', e.target.value)}>
<option value="1h">1 hour</option>
<option value="12h">12 hours</option>
<option value="24h">24 hours</option>
<option value="7d">7 days (default)</option>
<option value="30d">30 days</option>
<option value="90d">90 days</option>
</select>
</div>
</div>
<span class="text-muted" style="font-size:12px;">Access token controls JWT lifetime. Refresh token controls how long sessions persist. "Keep me logged in" unchecked caps refresh at 24h.</span>
<div style="margin-top:8px;">
<label class="toggle-label"><input type="checkbox" checked=${cfg.session_idle_enabled} onChange=${e => set('session_idle_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable idle timeout</span></label>
${cfg.session_idle_enabled && html`
<div class="form-group" style="margin-top:8px;"><label>Idle timeout</label>
<select value=${cfg.session_idle_timeout} onChange=${e => set('session_idle_timeout', e.target.value)}>
<option value="15m">15 minutes</option>
<option value="30m">30 minutes</option>
<option value="1h">1 hour</option>
<option value="2h">2 hours (default)</option>
<option value="4h">4 hours</option>
<option value="8h">8 hours</option>
<option value="24h">24 hours</option>
</select>
</div>
<span class="text-muted" style="font-size:12px;">Rejects token refresh if no user activity within the timeout period.</span>
`}
</div>
</div>
<div class="settings-section"><h3>Encryption Vault</h3> <div class="settings-section"><h3>Encryption Vault</h3>
${vault ${vault
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>` ? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`

View File

@@ -10,6 +10,7 @@ const { useState, useRef, useCallback } = hooks;
export function LoginForm({ onSuccess }) { export function LoginForm({ onSuccess }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [keepLogin, setKeepLogin] = useState(false);
const userRef = useRef(null); const userRef = useRef(null);
const passRef = useRef(null); const passRef = useRef(null);
@@ -25,7 +26,7 @@ export function LoginForm({ onSuccess }) {
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
await sw.auth.login(username, password); await sw.auth.login(username, password, keepLogin);
if (onSuccess) { if (onSuccess) {
onSuccess(); onSuccess();
} else { } else {
@@ -35,7 +36,7 @@ export function LoginForm({ onSuccess }) {
setError(e.message || 'Login failed'); setError(e.message || 'Login failed');
setLoading(false); setLoading(false);
} }
}, [onSuccess]); }, [onSuccess, keepLogin]);
const onUserKey = useCallback((e) => { const onUserKey = useCallback((e) => {
if (e.key === 'Enter') passRef.current?.focus(); if (e.key === 'Enter') passRef.current?.focus();
@@ -59,6 +60,11 @@ export function LoginForm({ onSuccess }) {
placeholder="Enter password" autocomplete="current-password" placeholder="Enter password" autocomplete="current-password"
onKeyDown=${onPassKey} /> onKeyDown=${onPassKey} />
</div> </div>
<label class="login-keep-login">
<input type="checkbox" checked=${keepLogin}
onChange=${e => setKeepLogin(e.target.checked)} />
<span>Keep me logged in</span>
</label>
${error && html`<p class="login-auth-error">${error}</p>`} ${error && html`<p class="login-auth-error">${error}</p>`}
<button class="login-btn" disabled=${loading} onClick=${doLogin}> <button class="login-btn" disabled=${loading} onClick=${doLogin}>
${loading ? 'Logging in\u2026' : 'Log In'} ${loading ? 'Logging in\u2026' : 'Log In'}