Files
multica/server/internal/auth/cookie_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

134 lines
4.1 KiB
Go

package auth
import (
"net/http/httptest"
"testing"
"time"
)
func TestIsSecureCookie(t *testing.T) {
cases := []struct {
name string
frontendOrigin string
want bool
}{
{"https origin → Secure", "https://app.example.com", true},
{"https with port", "https://app.example.com:8443", true},
{"http origin → not Secure", "http://192.168.5.5:13000", false},
{"http localhost → not Secure", "http://localhost:3000", false},
{"empty → not Secure", "", false},
{"malformed → not Secure", "::not-a-url", false},
{"uppercase scheme still matches", "HTTPS://app.example.com", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("FRONTEND_ORIGIN", tc.frontendOrigin)
if got := isSecureCookie(); got != tc.want {
t.Errorf("isSecureCookie() = %v, want %v (FRONTEND_ORIGIN=%q)", got, tc.want, tc.frontendOrigin)
}
})
}
}
func TestCookieDomain(t *testing.T) {
cases := []struct {
name string
env string
want string
}{
{"empty", "", ""},
{"whitespace only", " ", ""},
{"real domain", ".example.com", ".example.com"},
{"bare domain", "example.com", "example.com"},
{"IPv4 rejected", "192.168.5.5", ""},
{"IPv4 with leading dot rejected", ".192.168.5.5", ""},
{"IPv6 rejected", "::1", ""},
{"IPv6 bracketed is not a valid IP literal → passthrough", "[::1]", "[::1]"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("COOKIE_DOMAIN", tc.env)
if got := cookieDomain(); got != tc.want {
t.Errorf("cookieDomain() = %q, want %q (COOKIE_DOMAIN=%q)", got, tc.want, tc.env)
}
})
}
}
// TestSetAuthCookies_HTTPSelfHost covers the exact misconfiguration that
// shipped to users on LAN self-host: COOKIE_DOMAIN=<ip> + HTTP FRONTEND_ORIGIN.
// The cookie must land with no Domain attribute and Secure=false so browsers
// actually store it.
func TestSetAuthCookies_HTTPSelfHost(t *testing.T) {
t.Setenv("FRONTEND_ORIGIN", "http://192.168.5.5:13000")
t.Setenv("COOKIE_DOMAIN", "192.168.5.5")
rec := httptest.NewRecorder()
if err := SetAuthCookies(rec, "test-token"); err != nil {
t.Fatalf("SetAuthCookies: %v", err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 2 {
t.Fatalf("expected 2 cookies (auth + csrf), got %d", len(cookies))
}
for _, c := range cookies {
if c.Secure {
t.Errorf("cookie %q has Secure=true on HTTP origin; browser would reject it", c.Name)
}
if c.Domain != "" {
t.Errorf("cookie %q has Domain=%q; IP-address Domain would be rejected by the browser (RFC 6265)", c.Name, c.Domain)
}
}
}
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")
rec := httptest.NewRecorder()
if err := SetAuthCookies(rec, "test-token"); err != nil {
t.Fatalf("SetAuthCookies: %v", err)
}
for _, c := range rec.Result().Cookies() {
if !c.Secure {
t.Errorf("cookie %q missing Secure flag on HTTPS origin", c.Name)
}
if c.Domain != "app.example.com" {
t.Errorf("cookie %q Domain = %q, want %q", c.Name, c.Domain, "app.example.com")
}
}
}