Repository: TecharoHQ/anubis

Issue:
Allow to change cookie expiration time
The expiration time of the cookie/JWT is hard-coded, it would be very useful if admins could change it through configuration.

Maybe allowing it to be changed in the bot policies could be useful too 🤔 

Discussion:
@ldidry: Thx! 🙏 

Solution patch:
diff --git a/anubis.go b/anubis.go
index d626ebc..3d85d01 100644
--- a/anubis.go
+++ b/anubis.go
@@ -1,6 +1,8 @@
 // Package anubis contains the version number of Anubis.
 package anubis
 
+import "time"
+
 // Version is the current version of Anubis.
 //
 // This variable is set at build time using the -X linker flag. If not set,
@@ -11,6 +13,9 @@ var Version = "devel"
 // access.
 const CookieName = "within.website-x-cmd-anubis-auth"
 
+// CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires.
+const CookieDefaultExpirationTime = 7 * 24 * time.Hour
+
 // BasePrefix is a global prefix for all Anubis endpoints. Can be emptied to remove the prefix entirely.
 var BasePrefix = ""
 
diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go
index 5ae7eff..1bef63e 100644
--- a/cmd/anubis/main.go
+++ b/cmd/anubis/main.go
@@ -43,6 +43,7 @@ var (
 	bindNetwork              = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
 	challengeDifficulty      = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge")
 	cookieDomain             = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for")
+	cookieExpiration         = flag.Duration("cookie-expiration-time", anubis.CookieDefaultExpirationTime, "The amount of time the authorization cookie is valid for")
 	cookiePartitioned        = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
 	ed25519PrivateKeyHex     = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
 	ed25519PrivateKeyHexFile = flag.String("ed25519-private-key-hex-file", "", "file name containing value for ed25519-private-key-hex")
@@ -279,6 +280,7 @@ func main() {
 		ServeRobotsTXT:       *robotsTxt,
 		PrivateKey:           priv,
 		CookieDomain:         *cookieDomain,
+		CookieExpiration:     *cookieExpiration,
 		CookiePartitioned:    *cookiePartitioned,
 		OGPassthrough:        *ogPassthrough,
 		OGTimeToLive:         *ogTimeToLive,
@@ -322,6 +324,7 @@ func main() {
 		"og-passthrough", *ogPassthrough,
 		"og-expiry-time", *ogTimeToLive,
 		"base-prefix", *basePrefix,
+		"cookie-expiration-time", *cookieExpiration,
 	)
 
 	go func() {
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index d4d84e5..1c51e8c 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
+- Added customization of authorization cookie expiration time with `--cookie-expiration-time` flag or envvar
 
 - Updated the `OG_PASSTHROUGH` to be true by default, thereby allowing OpenGraph tags to be passed through by default
 - Added the ability to [customize Anubis' HTTP status codes](./admin/configuration/custom-status-codes.mdx) ([#355](https://github.com/TecharoHQ/anubis/issues/355))
diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx
index 1fe2e0f..ac84d5c 100644
--- a/docs/docs/admin/installation.mdx
+++ b/docs/docs/admin/installation.mdx
@@ -55,6 +55,7 @@ Anubis uses these environment variables for configuration:
 | `BIND`                         | `:8923`                 | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock`                                                                                                                                                                                                              |
 | `BIND_NETWORK`                 | `tcp`                   | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports.                                                                                                                                                                           |
 | `COOKIE_DOMAIN`                | unset                   | The domain the Anubis challenge pass cookie should be set to. This should be set to the domain you bought from your registrar (EG: `techaro.lol` if your webapp is running on `anubis.techaro.lol`). See [here](https://stackoverflow.com/a/1063760) for more information.                                           |
+| `COOKIE_EXPIRATION_TIME`       | `168h`                  | The amount of time the authorization cookie is valid for.                                                                                                                                                                                                                                                            |
 | `COOKIE_PARTITIONED`           | `false`                 | If set to `true`, enables the [partitioned (CHIPS) flag](https://developers.google.com/privacy-sandbox/cookies/chips), meaning that Anubis inside an iframe has a different set of cookies than the domain hosting the iframe.                                                                                       |
 | `DIFFICULTY`                   | `4`                     | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses.                                                                                                                                                                                                               |
 | `ED25519_PRIVATE_KEY_HEX`      | unset                   | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. See below for details.                                                                                                                           |
diff --git a/lib/anubis.go b/lib/anubis.go
index 2a7add6..9f177b2 100644
--- a/lib/anubis.go
+++ b/lib/anubis.go
@@ -348,7 +348,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 		"response":  response,
 		"iat":       time.Now().Unix(),
 		"nbf":       time.Now().Add(-1 * time.Minute).Unix(),
-		"exp":       time.Now().Add(24 * 7 * time.Hour).Unix(),
+		"exp":       time.Now().Add(s.opts.CookieExpiration).Unix(),
 	})
 	tokenString, err := token.SignedString(s.priv)
 	if err != nil {
@@ -361,7 +361,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 	http.SetCookie(w, &http.Cookie{
 		Name:        anubis.CookieName,
 		Value:       tokenString,
-		Expires:     time.Now().Add(24 * 7 * time.Hour),
+		Expires:     time.Now().Add(s.opts.CookieExpiration),
 		SameSite:    http.SameSiteLaxMode,
 		Domain:      s.opts.CookieDomain,
 		Partitioned: s.opts.CookiePartitioned,
diff --git a/lib/config.go b/lib/config.go
index 44b6479..739e718 100644
--- a/lib/config.go
+++ b/lib/config.go
@@ -29,6 +29,7 @@ type Options struct {
 	ServeRobotsTXT  bool
 	PrivateKey      ed25519.PrivateKey
 
+	CookieExpiration  time.Duration
 	CookieDomain      string
 	CookieName        string
 	CookiePartitioned bool



Test patch:
diff --git a/lib/anubis_test.go b/lib/anubis_test.go
index 63e76f7..2c401f5 100644
--- a/lib/anubis_test.go
+++ b/lib/anubis_test.go
@@ -8,6 +8,7 @@ import (
 	"os"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/TecharoHQ/anubis"
 	"github.com/TecharoHQ/anubis/data"
@@ -126,6 +127,96 @@ func TestCVE2025_24369(t *testing.T) {
 	}
 }
 
+func TestCookieCustomExpiration(t *testing.T) {
+	pol := loadPolicies(t, "")
+	pol.DefaultDifficulty = 0
+	ckieExpiration := 10 * time.Minute
+
+	srv := spawnAnubis(t, Options{
+		Next:   http.NewServeMux(),
+		Policy: pol,
+
+		CookieDomain:     "local.cetacean.club",
+		CookieName:       t.Name(),
+		CookieExpiration: ckieExpiration,
+	})
+
+	ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
+	defer ts.Close()
+
+	cli := &http.Client{
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+
+	resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
+	if err != nil {
+		t.Fatalf("can't request challenge: %v", err)
+	}
+	defer resp.Body.Close()
+
+	var chall = struct {
+		Challenge string `json:"challenge"`
+	}{}
+	if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
+		t.Fatalf("can't read challenge response body: %v", err)
+	}
+
+	nonce := 0
+	elapsedTime := 420
+	redir := "/"
+	calculated := ""
+	calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
+	calculated = internal.SHA256sum(calcString)
+
+	req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
+	if err != nil {
+		t.Fatalf("can't make request: %v", err)
+	}
+
+	q := req.URL.Query()
+	q.Set("response", calculated)
+	q.Set("nonce", fmt.Sprint(nonce))
+	q.Set("redir", redir)
+	q.Set("elapsedTime", fmt.Sprint(elapsedTime))
+	req.URL.RawQuery = q.Encode()
+
+	requestRecieveLowerBound := time.Now()
+	resp, err = cli.Do(req)
+	requestRecieveUpperBound := time.Now()
+	if err != nil {
+		t.Fatalf("can't do challenge passing")
+	}
+
+	if resp.StatusCode != http.StatusFound {
+		resp.Write(os.Stderr)
+		t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
+	}
+
+	var ckie *http.Cookie
+	for _, cookie := range resp.Cookies() {
+		t.Logf("%#v", cookie)
+		if cookie.Name == anubis.CookieName {
+			ckie = cookie
+			break
+		}
+	}
+	if ckie == nil {
+		t.Errorf("Cookie %q not found", anubis.CookieName)
+		return
+	}
+
+	expirationLowerBound := requestRecieveLowerBound.Add(ckieExpiration)
+	expirationUpperBound := requestRecieveUpperBound.Add(ckieExpiration)
+	// Since the cookie expiration precision is only to the second due to the Unix() call, we can
+	// lower the level of expected precision.
+	if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
+		t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
+		return
+	}
+}
+
 func TestCookieSettings(t *testing.T) {
 	pol := loadPolicies(t, "")
 	pol.DefaultDifficulty = 0
@@ -137,6 +228,7 @@ func TestCookieSettings(t *testing.T) {
 		CookieDomain:      "local.cetacean.club",
 		CookiePartitioned: true,
 		CookieName:        t.Name(),
+		CookieExpiration:  anubis.CookieDefaultExpirationTime,
 	})
 
 	ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
@@ -180,7 +272,9 @@ func TestCookieSettings(t *testing.T) {
 	q.Set("elapsedTime", fmt.Sprint(elapsedTime))
 	req.URL.RawQuery = q.Encode()
 
+	requestRecieveLowerBound := time.Now()
 	resp, err = cli.Do(req)
+	requestRecieveUpperBound := time.Now()
 	if err != nil {
 		t.Fatalf("can't do challenge passing")
 	}
@@ -207,6 +301,15 @@ func TestCookieSettings(t *testing.T) {
 		t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
 	}
 
+	expirationLowerBound := requestRecieveLowerBound.Add(anubis.CookieDefaultExpirationTime)
+	expirationUpperBound := requestRecieveUpperBound.Add(anubis.CookieDefaultExpirationTime)
+	// Since the cookie expiration precision is only to the second due to the Unix() call, we can
+	// lower the level of expected precision.
+	if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
+		t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
+		return
+	}
+
 	if ckie.Partitioned != srv.opts.CookiePartitioned {
 		t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
 	}