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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user