Feat v0.6.9 cookie fix roadmap (#44)
Some checks failed
CI/CD / detect-changes (push) Successful in 19s
CI/CD / test-frontend (push) Successful in 27s
CI/CD / test-go-pg (push) Failing after 2m46s
CI/CD / test-sqlite (push) Successful in 3m24s
CI/CD / build-and-deploy (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #44.
This commit is contained in:
2026-04-01 09:41:59 +00:00
committed by xcaliber
parent 680ec3b897
commit 617d81e7d4
20 changed files with 866 additions and 42 deletions

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
@@ -286,8 +315,14 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
refreshToken, _ := tokens["refresh_token"].(string)
userJSON, _ := json.Marshal(tokens["user"])
// Set page-auth cookie too (for SSR middleware)
c.SetCookie("arm_token", accessToken, 900, "/", "", false, false)
// Set page-auth cookie too (for SSR middleware).
// MaxAge matches refresh token lifetime so the cookie
// survives until JS can proactively refresh the access token.
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}`,
@@ -298,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(),
},
@@ -315,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)
}
}