Files
multica/server/internal/middleware/cloudfront_test.go
Kagura 59617f376e feat(auth): make auth token TTL configurable via AUTH_TOKEN_TTL env var (MUL-2371) (#2713)
* feat(auth): make auth token TTL configurable via AUTH_TOKEN_TTL env var

Add AUTH_TOKEN_TTL environment variable (in seconds) to override the
hardcoded 30-day auth token lifetime. Self-hosted deployments on trusted
networks can set a longer value to avoid frequent magic-link
re-authentication.

The value is read once at startup and cached. Invalid or missing values
fall back to the 30-day default with a warning log.

Closes #2685

* refactor(auth): extract parseAuthTokenTTL for testability

Address review feedback: extract pure parse function from sync.Once
wrapper so the parsing logic can be unit-tested independently.
Add TestParseAuthTokenTTL with table-driven cases.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

* refactor(auth): accept Go duration strings + hoist shared TTL in SetAuthCookies

Address nice-to-have review feedback from Bohan-J:
- parseAuthTokenTTL now tries time.ParseDuration first (e.g. '8760h'),
  falling back to ParseInt for integer seconds
- Warn on unreasonable values (>10 years) but still accept them
- Hoist AuthTokenTTL() and time.Now() in SetAuthCookies so both
  cookies share the exact same expiry
- Add security trade-off note in .env.example
- Add 5 new test cases for duration strings

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>

* fix: use AuthTokenTTL() in CloudFront middleware, guard ParseInt overflow

Address review feedback from Bohan-J (round 2):

1. CloudFront refresh middleware (cloudfront.go:21) was hardcoding
   30*24*time.Hour instead of using auth.AuthTokenTTL(). Now calls
   AuthTokenTTL() so the middleware respects AUTH_TOKEN_TTL env var.

2. parseAuthTokenTTL integer-seconds branch: very large values like
   9999999999 would silently overflow int64 when multiplied by
   time.Second. Added overflow guard comparing against
   math.MaxInt64/int64(time.Second) before the multiplication.

3. Updated AuthTokenTTL() doc comment to reflect that it accepts
   Go duration strings or integer seconds (not just seconds).

4. Added middleware test (cloudfront_test.go) verifying short
   AUTH_TOKEN_TTL produces short cookie expiry, not 30-day hardcode.
   Also covers nil signer and existing-cookie-skip cases.

5. Added integer overflow test case to cookie_test.go.

* style: run gofmt on cookie.go and cookie_test.go

---------

Signed-off-by: kagura-agent <kagura.agent.ai@gmail.com>
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
2026-05-19 16:22:07 +08:00

103 lines
2.8 KiB
Go

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")
}
}