diff --git a/.env.example b/.env.example index d3da90d0a..c998c9003 100644 --- a/.env.example +++ b/.env.example @@ -112,6 +112,13 @@ CLOUDFRONT_DOMAIN= # attribute and browsers silently drop such cookies. COOKIE_DOMAIN= +# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g. +# "8760h", "720h30m") or plain integer seconds. +# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can +# set a longer value to reduce re-authentication frequency. +# Note: longer TTL = longer exposure window if a cookie is leaked. +# AUTH_TOKEN_TTL=2592000 + # Local file storage (fallback when S3_BUCKET is not set) LOCAL_UPLOAD_DIR=./data/uploads LOCAL_UPLOAD_BASE_URL=http://localhost:8080 diff --git a/server/internal/auth/cookie.go b/server/internal/auth/cookie.go index 5f3330535..8634e0cd8 100644 --- a/server/internal/auth/cookie.go +++ b/server/internal/auth/cookie.go @@ -6,22 +6,87 @@ import ( "crypto/sha256" "encoding/hex" "log/slog" + "math" "net" "net/http" "net/url" "os" + "strconv" "strings" "sync" "time" ) const ( - AuthCookieName = "multica_auth" - CSRFCookieName = "multica_csrf" - authCookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds + AuthCookieName = "multica_auth" + CSRFCookieName = "multica_csrf" + defaultAuthTokenTTL = 30 * 24 * time.Hour // 30 days ) -var ipCookieDomainWarnOnce sync.Once +var ( + ipCookieDomainWarnOnce sync.Once + authTokenTTLOnce sync.Once + authTokenTTLCached time.Duration +) + +// parseAuthTokenTTL parses a raw AUTH_TOKEN_TTL value into a duration. +// It first tries time.ParseDuration (e.g. "8760h", "720h30m"), then falls +// back to parsing as integer seconds. Returns the parsed duration and true +// on success; zero and false when the input is empty or invalid. +func parseAuthTokenTTL(raw string) (time.Duration, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, false + } + + // Try Go duration string first (e.g. "8760h", "720h30m"). + if d, err := time.ParseDuration(raw); err == nil { + if d <= 0 { + return 0, false + } + if d > 10*365*24*time.Hour { + slog.Warn("AUTH_TOKEN_TTL exceeds 10 years; accepting but verify this is intentional", + "value", raw, "hours", d.Hours()) + } + return d, true + } + + // Fall back to plain integer seconds. + secs, err := strconv.ParseInt(raw, 10, 64) + if err != nil || secs <= 0 { + return 0, false + } + if secs > int64(math.MaxInt64/int64(time.Second)) { + return 0, false + } + d := time.Duration(secs) * time.Second + if d > 10*365*24*time.Hour { + slog.Warn("AUTH_TOKEN_TTL exceeds 10 years; accepting but verify this is intentional", + "value", raw, "hours", d.Hours()) + } + return d, true +} + +// AuthTokenTTL returns the configured auth token lifetime. It reads the +// AUTH_TOKEN_TTL environment variable (Go duration string or integer seconds) on first call and caches +// the result. When the variable is unset or invalid the default of 30 days +// is used. +func AuthTokenTTL() time.Duration { + authTokenTTLOnce.Do(func() { + raw := os.Getenv("AUTH_TOKEN_TTL") + if ttl, ok := parseAuthTokenTTL(raw); ok { + authTokenTTLCached = ttl + slog.Info("auth token TTL configured", "seconds", int(ttl.Seconds())) + return + } + authTokenTTLCached = defaultAuthTokenTTL + if strings.TrimSpace(raw) != "" { + slog.Warn("AUTH_TOKEN_TTL is not a valid duration or positive integer; using default", + "value", raw, "default_seconds", int(defaultAuthTokenTTL.Seconds())) + } + }) + return authTokenTTLCached +} // cookieDomain returns the trimmed COOKIE_DOMAIN env value, or "" if it looks // like an IP address. RFC 6265 §4.1.2.3 forbids IP literals in the cookie @@ -84,14 +149,16 @@ func generateCSRFToken(authToken string) (string, error) { func SetAuthCookies(w http.ResponseWriter, token string) error { secure := isSecureCookie() domain := cookieDomain() + ttl := AuthTokenTTL() + now := time.Now() http.SetCookie(w, &http.Cookie{ Name: AuthCookieName, Value: token, Path: "/", Domain: domain, - MaxAge: authCookieMaxAge, - Expires: time.Now().Add(30 * 24 * time.Hour), + MaxAge: int(ttl.Seconds()), + Expires: now.Add(ttl), HttpOnly: true, Secure: secure, SameSite: http.SameSiteStrictMode, @@ -107,8 +174,8 @@ func SetAuthCookies(w http.ResponseWriter, token string) error { Value: csrfToken, Path: "/", Domain: domain, - MaxAge: authCookieMaxAge, - Expires: time.Now().Add(30 * 24 * time.Hour), + MaxAge: int(ttl.Seconds()), + Expires: now.Add(ttl), HttpOnly: false, Secure: secure, SameSite: http.SameSiteStrictMode, diff --git a/server/internal/auth/cookie_test.go b/server/internal/auth/cookie_test.go index 40ec49a28..775eeb362 100644 --- a/server/internal/auth/cookie_test.go +++ b/server/internal/auth/cookie_test.go @@ -3,6 +3,7 @@ package auth import ( "net/http/httptest" "testing" + "time" ) func TestIsSecureCookie(t *testing.T) { @@ -81,6 +82,37 @@ func TestSetAuthCookies_HTTPSelfHost(t *testing.T) { } } +func TestParseAuthTokenTTL(t *testing.T) { + cases := []struct { + name string + raw string + wantDur time.Duration + wantOK bool + }{ + {"empty string", "", 0, false}, + {"valid 3600", "3600", time.Hour, true}, + {"valid 86400", "86400", 24 * time.Hour, true}, + {"negative", "-100", 0, false}, + {"zero", "0", 0, false}, + {"non-numeric", "abc", 0, false}, + {"whitespace trimmed", " 7200 ", 2 * time.Hour, true}, + {"duration hours", "8760h", 8760 * time.Hour, true}, + {"duration compound", "720h30m", 720*time.Hour + 30*time.Minute, true}, + {"duration minutes", "90m", 90 * time.Minute, true}, + {"duration negative", "-1h", 0, false}, + {"duration zero", "0s", 0, false}, + {"integer overflow", "9999999999", 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := parseAuthTokenTTL(tc.raw) + if ok != tc.wantOK || got != tc.wantDur { + t.Errorf("parseAuthTokenTTL(%q) = (%v, %v), want (%v, %v)", tc.raw, got, ok, tc.wantDur, tc.wantOK) + } + }) + } +} + func TestSetAuthCookies_HTTPSProduction(t *testing.T) { t.Setenv("FRONTEND_ORIGIN", "https://app.example.com") t.Setenv("COOKIE_DOMAIN", "app.example.com") diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index d40a50b4b..aa0ef233d 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -139,7 +139,7 @@ func (h *Handler) issueJWT(user db.User) (string, error) { "sub": uuidToString(user.ID), "email": user.Email, "name": user.Name, - "exp": time.Now().Add(30 * 24 * time.Hour).Unix(), + "exp": time.Now().Add(auth.AuthTokenTTL()).Unix(), "iat": time.Now().Unix(), }) return token.SignedString(auth.JWTSecret()) @@ -393,7 +393,7 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { // Set CloudFront signed cookies for CDN access. if h.CFSigner != nil { - for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) { + for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(auth.AuthTokenTTL())) { http.SetCookie(w, cookie) } } diff --git a/server/internal/middleware/cloudfront.go b/server/internal/middleware/cloudfront.go index b6a27d751..79621b5ef 100644 --- a/server/internal/middleware/cloudfront.go +++ b/server/internal/middleware/cloudfront.go @@ -18,7 +18,8 @@ func RefreshCloudFrontCookies(signer *auth.CloudFrontSigner) func(http.Handler) } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, err := r.Cookie("CloudFront-Policy"); err != nil { - for _, cookie := range signer.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) { + ttl := auth.AuthTokenTTL() + for _, cookie := range signer.SignedCookies(time.Now().Add(ttl)) { http.SetCookie(w, cookie) } } diff --git a/server/internal/middleware/cloudfront_test.go b/server/internal/middleware/cloudfront_test.go new file mode 100644 index 000000000..060713f1f --- /dev/null +++ b/server/internal/middleware/cloudfront_test.go @@ -0,0 +1,102 @@ +package middleware + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/multica-ai/multica/server/internal/auth" +) + +// testSigner sets up env vars and creates a CloudFrontSigner with a throwaway RSA key. +func testSigner(t *testing.T) *auth.CloudFrontSigner { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + pkcs8Bytes, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + pemBlock := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8Bytes}) + b64Key := base64.StdEncoding.EncodeToString(pemBlock) + + t.Setenv("CLOUDFRONT_KEY_PAIR_ID", "TESTKEY") + t.Setenv("CLOUDFRONT_DOMAIN", "cdn.example.com") + t.Setenv("COOKIE_DOMAIN", ".example.com") + t.Setenv("CLOUDFRONT_PRIVATE_KEY", b64Key) + + signer := auth.NewCloudFrontSignerFromEnv() + if signer == nil { + t.Fatal("failed to create test CloudFrontSigner") + } + return signer +} + +func TestRefreshCloudFrontCookies_UsesAuthTokenTTL(t *testing.T) { + // Set a short TTL (1 hour) so we can verify the middleware does NOT use + // the old hardcoded 30-day value. + t.Setenv("AUTH_TOKEN_TTL", "1h") + + signer := testSigner(t) + handler := RefreshCloudFrontCookies(signer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + cookies := rec.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("expected CloudFront cookies to be set") + } + + for _, c := range cookies { + // Cookie expiry should be ~1 hour from now, not ~30 days. + untilExpiry := time.Until(c.Expires) + if untilExpiry > 2*time.Hour { + t.Errorf("cookie %q expires in %v; expected ~1h (AUTH_TOKEN_TTL), got what looks like 30-day hardcode", c.Name, untilExpiry) + } + } +} + +func TestRefreshCloudFrontCookies_NilSigner(t *testing.T) { + handler := RefreshCloudFrontCookies(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if len(rec.Result().Cookies()) != 0 { + t.Error("nil signer should not set any cookies") + } +} + +func TestRefreshCloudFrontCookies_SkipsWhenCookiePresent(t *testing.T) { + signer := testSigner(t) + handler := RefreshCloudFrontCookies(signer)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "CloudFront-Policy", Value: "existing"}) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if len(rec.Result().Cookies()) != 0 { + t.Error("should not refresh cookies when CloudFront-Policy is already present") + } +}