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) {
var req struct {
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
KeepLogin bool `json:"keep_login"`
}
if err := c.ShouldBindJSON(&req); err != nil {
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)
if err != nil {
return nil, ErrInvalidCreds

View File

@@ -56,9 +56,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
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);

View File

@@ -41,9 +41,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
revoked_at TEXT
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
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);

View File

@@ -72,7 +72,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
tokens, err := h.generateTokens(result.User)
tokens, err := h.generateTokens(result.User, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -94,7 +94,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -113,22 +119,32 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
}
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 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
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)
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 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
return
}
tokens, err := h.generateTokens(user)
tokens, err := h.generateTokens(user, info.KeepLogin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -159,6 +175,19 @@ func (h *AuthHandler) Logout(c *gin.Context) {
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 ──────────────────────────────
// 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
tokens, err := h.generateTokens(result.User)
tokens, err := h.generateTokens(result.User, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -287,9 +316,13 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
userJSON, _ := json.Marshal(tokens["user"])
// 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.
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
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)
}
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
// Access token (15 min)
func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) {
sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig)
// Access token
accessClaims := Claims{
UserID: user.ID,
Email: user.Email,
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()),
ID: uuid.New().String(),
},
@@ -317,20 +352,26 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
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()
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)
}
return gin.H{
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": 900,
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": int(sc.AccessTokenTTL.Seconds()),
"refresh_expires_in": int(refreshTTL.Seconds()),
"user": gin.H{
"id": user.ID,
"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()
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 := api.Group("")
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.
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
// =========================================
@@ -79,11 +86,13 @@ type UserStore interface {
ListActiveUserIDs(ctx context.Context) ([]string, error)
// 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)
GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error
UpdateRefreshTokenActivity(ctx context.Context, userID string) error
// ── CS1 additions ──

View File

@@ -142,8 +142,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
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, keep_login, last_activity_at) VALUES ($1, $2, $3, $4, NOW())`,
userID, tokenHash, expiresAt, keepLogin)
return err
}
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)
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 {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
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'")
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 ────────────────────────────────

View File

@@ -148,9 +148,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt))
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, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin)
return err
}
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)
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 {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
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')")
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 ────────────────────────────────