Fix session cookie max-age bug; add UI hardening roadmap
Cookie max-age was 900s (15 min, matching access token) but refresh token lives 7 days — users got bounced to login after 15 min idle because the Go SSR middleware rejected the expired cookie before JS could refresh. Now cookie max-age = 604800s (7 days) on both the client (auth.js) and server (auth.go OIDC callback). Go page-auth middleware accepts expired-but-signed JWTs via new parseJWTIgnoringExpiry() so the page shell renders and the Preact SDK can refresh client-side. API middleware still validates expiry strictly. 6 new middleware tests cover strict/lenient/tampered/garbage cases. VERSION bumped to 0.6.8 (rebrand was already shipped but file missed). ROADMAP-UI.md added with 7 milestones (v0.6.9–v0.6.15). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -286,8 +286,10 @@ 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 = 7 days — matches refresh token lifetime so the cookie
|
||||
// survives until JS can proactively refresh the access token.
|
||||
c.SetCookie("arm_token", accessToken, 604800, "/", "", false, false)
|
||||
|
||||
// Base64-encode the token payload for the fragment
|
||||
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
|
||||
|
||||
@@ -142,9 +142,30 @@ func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) {
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// parseJWTIgnoringExpiry parses a JWT and validates the signature but
|
||||
// tolerates an expired token. Used by page-auth middleware so the Go
|
||||
// template can render the page shell even when the access token has
|
||||
// lapsed — the Preact SDK will refresh the token client-side.
|
||||
// Returns (claims, signatureValid). A tampered or unsigned token
|
||||
// returns (nil, false).
|
||||
func parseJWTIgnoringExpiry(tokenString string, jwtSecret string) (*Claims, bool) {
|
||||
claims := &Claims{}
|
||||
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(jwtSecret), nil
|
||||
}, jwt.WithoutClaimsValidation())
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// UserIDFromCookie extracts the user ID from the arm_token cookie without
|
||||
// requiring authentication. Returns "" if no valid token is found.
|
||||
// Used by unauthenticated routes that want optional user context.
|
||||
// Tolerates expired tokens (signature must be valid) so that user preferences
|
||||
// still apply even when the access token has lapsed.
|
||||
func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
||||
cookie, err := c.Cookie("arm_token")
|
||||
if err != nil || cookie == "" {
|
||||
@@ -152,7 +173,11 @@ func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
||||
}
|
||||
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
|
||||
if !ok {
|
||||
return ""
|
||||
// Accept expired-but-signed token for optional user context
|
||||
claims, ok = parseJWTIgnoringExpiry(cookie, jwtSecret)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return claims.UserID
|
||||
}
|
||||
|
||||
82
server/middleware/auth_test.go
Normal file
82
server/middleware/auth_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const testSecret = "test-jwt-secret-key"
|
||||
|
||||
func makeToken(t *testing.T, userID, email string, expiresAt time.Time) string {
|
||||
t.Helper()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
|
||||
ID: "test-jti",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
s, err := token.SignedString([]byte(testSecret))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestParseAndValidateJWT_Valid(t *testing.T) {
|
||||
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(15*time.Minute))
|
||||
claims, ok := parseAndValidateJWT(tok, testSecret)
|
||||
if !ok {
|
||||
t.Fatal("expected valid token to parse")
|
||||
}
|
||||
if claims.UserID != "u1" {
|
||||
t.Errorf("UserID = %q, want u1", claims.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateJWT_Expired(t *testing.T) {
|
||||
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||
_, ok := parseAndValidateJWT(tok, testSecret)
|
||||
if ok {
|
||||
t.Fatal("expected expired token to be rejected by strict parser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateJWT_WrongSecret(t *testing.T) {
|
||||
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(15*time.Minute))
|
||||
_, ok := parseAndValidateJWT(tok, "wrong-secret")
|
||||
if ok {
|
||||
t.Fatal("expected wrong-secret token to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWTIgnoringExpiry_Expired(t *testing.T) {
|
||||
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||
claims, ok := parseJWTIgnoringExpiry(tok, testSecret)
|
||||
if !ok {
|
||||
t.Fatal("expected expired token to be accepted by lenient parser")
|
||||
}
|
||||
if claims.UserID != "u1" {
|
||||
t.Errorf("UserID = %q, want u1", claims.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWTIgnoringExpiry_WrongSecret(t *testing.T) {
|
||||
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||
_, ok := parseJWTIgnoringExpiry(tok, "wrong-secret")
|
||||
if ok {
|
||||
t.Fatal("expected tampered token to be rejected even with lenient parser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJWTIgnoringExpiry_GarbageToken(t *testing.T) {
|
||||
_, ok := parseJWTIgnoringExpiry("not.a.jwt", testSecret)
|
||||
if ok {
|
||||
t.Fatal("expected garbage token to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,17 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
|
||||
return
|
||||
}
|
||||
|
||||
// Try strict validation first (token not expired).
|
||||
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
||||
if !ok {
|
||||
redirectToLogin(c, loginPath)
|
||||
return
|
||||
// Fallback: accept an expired-but-signed token so the page
|
||||
// shell can render and the Preact SDK can refresh client-side.
|
||||
// A tampered/unsigned token still gets rejected.
|
||||
claims, ok = parseJWTIgnoringExpiry(tokenString, cfg.JWTSecret)
|
||||
if !ok {
|
||||
redirectToLogin(c, loginPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if claims.UserID == "" {
|
||||
|
||||
Reference in New Issue
Block a user