TUN-10802: Manage process-local authentication sessions

## Summary

- generate a cryptographically random process-local HMAC-SHA256 session signing key
- issue host-only, Secure, HttpOnly, SameSite=Lax Quick Tunnel session cookies
- cap sessions at the earlier of 4 hours or the Access identity expiry
- validate canonical cookie encoding, signatures, nonces, and expiry
- invalidate sessions across cloudflared restarts through process-local signing keys
- use `github.com/cloudflare/backoff` for tunnel retry jitter while preserving retry limits, retry-forever caps, and grace-period resets

## Security

- session payloads contain only a random nonce and expiry; no visitor identity or recipient rules
- malformed, tampered, expired, and previous-process sessions fail closed
- HMAC signatures are compared in constant time

## Validation

- `make test lint`
- `go test -race ./retry ./quicktunnelauth`
This commit is contained in:
Alessandro Frigerio
2026-09-17 17:08:56 +00:00
committed by Miguel da Costa Martins Marcelino
parent 4f80fc1c23
commit be3ac12702
4 changed files with 871 additions and 33 deletions
+209
View File
@@ -0,0 +1,209 @@
package quicktunnelauth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
// quickTunnelAuthSessionSigningKeySize provides a 256-bit HMAC key for
// signing authentication session cookies.
quickTunnelAuthSessionSigningKeySize = 32
quickTunnelAuthSessionTTL = 4 * time.Hour
quickTunnelAuthSessionNonceSize = 16 // 128 bits
// __Host- makes the browser require Secure, Path=/, and no Domain attribute,
// binding the session cookie to one exact Quick Tunnel hostname.
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#cookie_prefixes.
quickTunnelAuthSessionCookieName = "__Host-cloudflared-qt-auth-session"
quickTunnelAuthSessionCookiePath = "/"
quickTunnelAuthSessionCookieSeparator = "."
)
type quickTunnelAuthTimeSource func() time.Time
// QuickTunnelAuthSessionManager manages process-local authentication sessions.
// It must be created with NewQuickTunnelAuthSessionManager.
type QuickTunnelAuthSessionManager struct {
sessionCookieSigningKey []byte
random io.Reader
// now is injectable so expiry behavior can be tested with a deterministic clock.
now quickTunnelAuthTimeSource
}
// quickTunnelAuthSessionCookiePayload is the signed process-local session data
// stored in the browser cookie. It intentionally contains no visitor identity
// or recipient rules.
type quickTunnelAuthSessionCookiePayload struct {
Nonce string `json:"nonce"`
ExpiresAt int64 `json:"exp"`
}
// NewQuickTunnelAuthSessionManager creates a session manager with a fresh
// process-local signing key, invalidating sessions when cloudflared restarts.
func NewQuickTunnelAuthSessionManager() (*QuickTunnelAuthSessionManager, error) {
key := make([]byte, quickTunnelAuthSessionSigningKeySize)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("generate session cookie signing key: %w", err)
}
return &QuickTunnelAuthSessionManager{
sessionCookieSigningKey: key,
random: rand.Reader,
now: time.Now,
}, nil
}
// sign returns an HMAC-SHA256 signature over payload using the manager's
// process-local signing key.
func (m *QuickTunnelAuthSessionManager) sign(payload []byte) ([]byte, error) {
if m == nil || len(m.sessionCookieSigningKey) == 0 {
return nil, errors.New("session cookie signing key is not initialized")
}
mac := hmac.New(sha256.New, m.sessionCookieSigningKey)
if _, err := mac.Write(payload); err != nil {
return nil, fmt.Errorf("sign session cookie payload: %w", err)
}
return mac.Sum(nil), nil
}
// encodeQuickTunnelAuthSessionCookieValue encodes a signed session as
// "<base64url payload>.<base64url HMAC signature>".
func encodeQuickTunnelAuthSessionCookieValue(payloadBytes, signature []byte) string {
return base64.RawURLEncoding.EncodeToString(payloadBytes) +
quickTunnelAuthSessionCookieSeparator +
base64.RawURLEncoding.EncodeToString(signature)
}
// IssueSession creates a process-local authentication session cookie.
func (m *QuickTunnelAuthSessionManager) IssueSession(identityExpiresAt time.Time) (*http.Cookie, error) {
if m == nil || m.now == nil || m.random == nil {
return nil, errors.New("authentication session manager is not initialized")
}
now := m.now()
if identityExpiresAt.Compare(now) <= 0 {
return nil, errors.New("issue authentication session: identity has expired")
}
expiresAt := min(identityExpiresAt.Unix(), now.Add(quickTunnelAuthSessionTTL).Unix())
if expiresAt <= now.Unix() {
return nil, errors.New("issue authentication session: identity expires too soon")
}
nonceBytes := make([]byte, quickTunnelAuthSessionNonceSize)
if _, err := io.ReadFull(m.random, nonceBytes); err != nil {
return nil, fmt.Errorf("generate authentication session nonce: %w", err)
}
nonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
payload := quickTunnelAuthSessionCookiePayload{
Nonce: nonce,
ExpiresAt: expiresAt,
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("encode authentication session payload: %w", err)
}
signature, err := m.sign(payloadBytes)
if err != nil {
return nil, fmt.Errorf("issue authentication session: %w", err)
}
cookieValue := encodeQuickTunnelAuthSessionCookieValue(payloadBytes, signature)
// SameSite=Lax allows an existing session on top-level navigations from
// external links while withholding it from cross-site subrequests and unsafe
// methods.
return &http.Cookie{
Name: quickTunnelAuthSessionCookieName,
Value: cookieValue,
Path: quickTunnelAuthSessionCookiePath,
Expires: time.Unix(expiresAt, 0).UTC(),
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}, nil
}
// removeQuickTunnelAuthSessionCookie removes the process-local session cookie
// before the request is proxied while preserving unrelated origin cookies.
func removeQuickTunnelAuthSessionCookie(request *http.Request) {
cookies := request.Cookies()
request.Header.Del("Cookie")
for _, cookie := range cookies {
if cookie.Name != quickTunnelAuthSessionCookieName {
request.AddCookie(cookie)
}
}
}
// ValidateSession validates the authentication session attached to request and
// removes a valid session cookie before the request is proxied to the origin.
func (m *QuickTunnelAuthSessionManager) ValidateSession(request *http.Request) (bool, error) {
if m == nil || m.now == nil {
return false, errors.New("authentication session manager is not initialized")
}
if request == nil {
return false, errors.New("validate authentication session: request is nil")
}
sessionCookies := request.CookiesNamed(quickTunnelAuthSessionCookieName)
if len(sessionCookies) != 1 {
return false, nil
}
sessionCookie := sessionCookies[0]
encodedPayload, encodedSignature, found := strings.Cut(sessionCookie.Value, quickTunnelAuthSessionCookieSeparator)
if !found || encodedPayload == "" || encodedSignature == "" {
return false, nil
}
payloadBytes, err := base64.RawURLEncoding.Strict().DecodeString(encodedPayload)
if err != nil {
return false, nil
}
signature, err := base64.RawURLEncoding.Strict().DecodeString(encodedSignature)
if err != nil {
return false, nil
}
expectedSignature, err := m.sign(payloadBytes)
if err != nil {
return false, fmt.Errorf("verify authentication session signature: %w", err)
}
if !hmac.Equal(signature, expectedSignature) {
return false, nil
}
var payload quickTunnelAuthSessionCookiePayload
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return false, nil
}
nonceBytes, err := base64.RawURLEncoding.Strict().DecodeString(payload.Nonce)
if err != nil || len(nonceBytes) != quickTunnelAuthSessionNonceSize {
return false, nil
}
now := m.now()
expiresAt := time.Unix(payload.ExpiresAt, 0)
if now.Compare(expiresAt) >= 0 {
return false, nil
}
removeQuickTunnelAuthSessionCookie(request)
return true, nil
}
+571
View File
@@ -0,0 +1,571 @@
package quicktunnelauth
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/iotest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewQuickTunnelAuthSessionManager(t *testing.T) {
t.Parallel()
first, err := NewQuickTunnelAuthSessionManager()
require.NoError(t, err)
require.NotNil(t, first)
assert.Len(t, first.sessionCookieSigningKey, quickTunnelAuthSessionSigningKeySize)
assert.NotNil(t, first.random)
assert.NotNil(t, first.now)
second, err := NewQuickTunnelAuthSessionManager()
require.NoError(t, err)
require.NotNil(t, second)
assert.NotEqual(t, first.sessionCookieSigningKey, second.sessionCookieSigningKey)
}
func TestQuickTunnelAuthSessionManagerSign(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{0x42}, quickTunnelAuthSessionSigningKeySize)
manager := &QuickTunnelAuthSessionManager{sessionCookieSigningKey: key}
payload := []byte("session payload")
signature, err := manager.sign(payload)
require.NoError(t, err)
expectedMAC := hmac.New(sha256.New, key)
_, err = expectedMAC.Write(payload)
require.NoError(t, err)
assert.Equal(t, expectedMAC.Sum(nil), signature)
assert.Len(t, signature, sha256.Size)
}
func TestQuickTunnelAuthSessionManagerSignRequiresKey(t *testing.T) {
t.Parallel()
tests := []struct {
name string
manager *QuickTunnelAuthSessionManager
}{
{name: "nil manager"},
{name: "empty key", manager: &QuickTunnelAuthSessionManager{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
_, err := test.manager.sign([]byte("payload"))
require.EqualError(t, err, "session cookie signing key is not initialized")
})
}
}
func TestQuickTunnelAuthSessionManagerIssueSession(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
tests := []struct {
name string
identityExpiresAt time.Time
expectedExpiresAt time.Time
}{
{
name: "identity expires first",
identityExpiresAt: now.Add(2 * time.Hour),
expectedExpiresAt: now.Add(2 * time.Hour),
},
{
name: "session TTL expires first",
identityExpiresAt: now.Add(48 * time.Hour),
expectedExpiresAt: now.Add(4 * time.Hour),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
randomBytes := bytes.Repeat([]byte{0x24}, quickTunnelAuthSessionNonceSize)
manager := newTestQuickTunnelAuthSessionManager(now, 0x42, bytes.NewReader(randomBytes))
cookie, err := manager.IssueSession(test.identityExpiresAt)
require.NoError(t, err)
require.NotNil(t, cookie)
assert.Equal(t, quickTunnelAuthSessionCookieName, cookie.Name)
assert.Equal(t, quickTunnelAuthSessionCookiePath, cookie.Path)
assert.Empty(t, cookie.Domain)
assert.True(t, cookie.Secure)
assert.True(t, cookie.HttpOnly)
assert.Equal(t, http.SameSiteLaxMode, cookie.SameSite)
assert.True(t, cookie.Expires.Equal(test.expectedExpiresAt))
payloadBytes, signature, payload := decodeQuickTunnelAuthSessionCookie(t, cookie)
assert.Equal(t, base64.RawURLEncoding.EncodeToString(randomBytes), payload.Nonce)
assert.Equal(t, test.expectedExpiresAt.Unix(), payload.ExpiresAt)
expectedSignature, err := manager.sign(payloadBytes)
require.NoError(t, err)
assert.True(t, hmac.Equal(expectedSignature, signature))
})
}
}
func TestQuickTunnelAuthSessionManagerIssueSessionRejectsExpiredIdentity(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
tests := []struct {
name string
identityExpiresAt time.Time
}{
{name: "at current time", identityExpiresAt: now},
{name: "before current time", identityExpiresAt: now.Add(-time.Second)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
manager := newTestQuickTunnelAuthSessionManager(now, 0x42, bytes.NewReader(make([]byte, quickTunnelAuthSessionNonceSize)))
cookie, err := manager.IssueSession(test.identityExpiresAt)
require.EqualError(t, err, "issue authentication session: identity has expired")
assert.Nil(t, cookie)
})
}
}
func TestQuickTunnelAuthSessionManagerIssueSessionRejectsIdentityExpiringWithinCurrentSecond(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC).Add(100 * time.Millisecond)
manager := newTestQuickTunnelAuthSessionManager(
now,
0x42,
bytes.NewReader(make([]byte, quickTunnelAuthSessionNonceSize)),
)
cookie, err := manager.IssueSession(now.Add(500 * time.Millisecond))
require.EqualError(t, err, "issue authentication session: identity expires too soon")
assert.Nil(t, cookie)
}
func TestQuickTunnelAuthSessionManagerIssueSessionRequiresInitializedManager(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
tests := []struct {
name string
manager *QuickTunnelAuthSessionManager
}{
{name: "nil manager"},
{name: "zero-value manager", manager: &QuickTunnelAuthSessionManager{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
cookie, err := test.manager.IssueSession(now.Add(time.Hour))
require.EqualError(t, err, "authentication session manager is not initialized")
assert.Nil(t, cookie)
})
}
}
func TestQuickTunnelAuthSessionManagerIssueSessionErrors(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
t.Run("random source failure", func(t *testing.T) {
t.Parallel()
manager := newTestQuickTunnelAuthSessionManager(now, 0x42, iotest.ErrReader(assert.AnError))
cookie, err := manager.IssueSession(now.Add(time.Hour))
require.EqualError(t, err, "generate authentication session nonce: assert.AnError general error for testing")
assert.Nil(t, cookie)
})
t.Run("missing signing key", func(t *testing.T) {
t.Parallel()
manager := newTestQuickTunnelAuthSessionManager(now, 0, bytes.NewReader(make([]byte, quickTunnelAuthSessionNonceSize)))
manager.sessionCookieSigningKey = nil
cookie, err := manager.IssueSession(now.Add(time.Hour))
require.EqualError(t, err, "issue authentication session: session cookie signing key is not initialized")
assert.Nil(t, cookie)
})
}
func TestQuickTunnelAuthSessionManagerValidateSession(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
manager := newTestQuickTunnelAuthSessionManager(
now,
0x42,
bytes.NewReader(bytes.Repeat([]byte{0x24}, quickTunnelAuthSessionNonceSize)),
)
cookie, err := manager.IssueSession(now.Add(time.Hour))
require.NoError(t, err)
request := requestWithQuickTunnelAuthSession(cookie.Value)
valid, err := manager.ValidateSession(request)
require.NoError(t, err)
assert.True(t, valid)
}
func TestQuickTunnelAuthSessionManagerValidateSessionRejectsDuplicateCookies(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
manager := newTestQuickTunnelAuthSessionManager(
now,
0x42,
bytes.NewReader(bytes.Repeat([]byte{0x24}, quickTunnelAuthSessionNonceSize)),
)
cookie, err := manager.IssueSession(now.Add(time.Hour))
require.NoError(t, err)
request := requestWithQuickTunnelAuthSession(cookie.Value)
request.AddCookie(cookie)
valid, err := manager.ValidateSession(request)
require.NoError(t, err)
assert.False(t, valid)
}
func TestQuickTunnelAuthSessionManagerValidateSessionRemovesSessionCookie(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
manager := newTestQuickTunnelAuthSessionManager(
now,
0x42,
bytes.NewReader(bytes.Repeat([]byte{0x24}, quickTunnelAuthSessionNonceSize)),
)
cookie, err := manager.IssueSession(now.Add(time.Hour))
require.NoError(t, err)
request := requestWithQuickTunnelAuthSession(cookie.Value)
request.AddCookie(&http.Cookie{
Name: "origin-session",
Value: "origin-value",
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
valid, err := manager.ValidateSession(request)
require.NoError(t, err)
assert.True(t, valid)
assert.Empty(t, request.CookiesNamed(quickTunnelAuthSessionCookieName))
originCookies := request.CookiesNamed("origin-session")
require.Len(t, originCookies, 1)
assert.Equal(t, "origin-value", originCookies[0].Value)
}
func TestQuickTunnelAuthSessionManagerValidateSessionExpirationBoundaries(t *testing.T) {
t.Parallel()
expiresAt := time.Date(2026, time.September, 3, 10, 0, 0, 0, time.UTC)
validNonce := base64.RawURLEncoding.EncodeToString(make([]byte, quickTunnelAuthSessionNonceSize))
tests := []struct {
name string
now time.Time
expiresAt time.Time
valid bool
}{
{name: "immediately before expiry", now: expiresAt.Add(-time.Nanosecond), expiresAt: expiresAt, valid: true},
{name: "at expiry", now: expiresAt, expiresAt: expiresAt},
{name: "after expiry", now: expiresAt.Add(time.Nanosecond), expiresAt: expiresAt},
{name: "at maximum TTL", now: expiresAt, expiresAt: expiresAt.Add(quickTunnelAuthSessionTTL), valid: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
manager := newTestQuickTunnelAuthSessionManager(test.now, 0x42, bytes.NewReader(nil))
payloadBytes, err := json.Marshal(quickTunnelAuthSessionCookiePayload{
Nonce: validNonce,
ExpiresAt: test.expiresAt.Unix(),
})
require.NoError(t, err)
cookie := signedQuickTunnelAuthSessionCookie(t, manager, payloadBytes)
valid, err := manager.ValidateSession(requestWithQuickTunnelAuthSession(cookie.Value))
require.NoError(t, err)
assert.Equal(t, test.valid, valid)
})
}
}
func TestQuickTunnelAuthSessionManagerValidateSessionRejectsMalformedCookies(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
manager := newTestQuickTunnelAuthSessionManager(now, 0x42, bytes.NewReader(nil))
tests := []struct {
name string
cookieValue string
}{
{name: "empty"},
{name: "missing separator", cookieValue: "payload"},
{name: "empty payload", cookieValue: ".signature"},
{name: "empty signature", cookieValue: "payload."},
{name: "extra separator", cookieValue: "payload.signature.extra"},
{name: "invalid payload encoding", cookieValue: "*.AA"},
{name: "invalid signature encoding", cookieValue: "e30.!"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
valid, err := manager.ValidateSession(requestWithQuickTunnelAuthSession(test.cookieValue))
require.NoError(t, err)
assert.False(t, valid)
})
}
}
func TestQuickTunnelAuthSessionManagerValidateSessionRejectsInvalidPayloads(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
validNonce := base64.RawURLEncoding.EncodeToString(make([]byte, quickTunnelAuthSessionNonceSize))
nonCanonicalNonce := validNonce[:len(validNonce)-1] + "B"
tests := []struct {
name string
rawPayload []byte
payload *quickTunnelAuthSessionCookiePayload
}{
{name: "invalid JSON", rawPayload: []byte("{")},
{
name: "invalid nonce encoding",
payload: &quickTunnelAuthSessionCookiePayload{
Nonce: "not-base64!",
ExpiresAt: now.Add(time.Hour).Unix(),
},
},
{
name: "invalid nonce size",
payload: &quickTunnelAuthSessionCookiePayload{
Nonce: base64.RawURLEncoding.EncodeToString(make([]byte, quickTunnelAuthSessionNonceSize-1)),
ExpiresAt: now.Add(time.Hour).Unix(),
},
},
{
name: "non-canonical nonce",
payload: &quickTunnelAuthSessionCookiePayload{
Nonce: nonCanonicalNonce,
ExpiresAt: now.Add(time.Hour).Unix(),
},
},
{
name: "expired at boundary",
payload: &quickTunnelAuthSessionCookiePayload{
Nonce: validNonce,
ExpiresAt: now.Unix(),
},
},
{
name: "expired before boundary",
payload: &quickTunnelAuthSessionCookiePayload{
Nonce: validNonce,
ExpiresAt: now.Add(-time.Second).Unix(),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
manager := newTestQuickTunnelAuthSessionManager(now, 0x42, bytes.NewReader(nil))
payloadBytes := test.rawPayload
if test.payload != nil {
var err error
payloadBytes, err = json.Marshal(test.payload)
require.NoError(t, err)
}
cookie := signedQuickTunnelAuthSessionCookie(t, manager, payloadBytes)
valid, err := manager.ValidateSession(requestWithQuickTunnelAuthSession(cookie.Value))
require.NoError(t, err)
assert.False(t, valid)
})
}
}
func TestQuickTunnelAuthSessionManagerValidateSessionRejectsTampering(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
tests := []struct {
name string
tamperPayload bool
tamperSignature bool
}{
{name: "payload", tamperPayload: true},
{name: "signature", tamperSignature: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
manager := newTestQuickTunnelAuthSessionManager(
now,
0x42,
bytes.NewReader(bytes.Repeat([]byte{0x24}, quickTunnelAuthSessionNonceSize)),
)
cookie, err := manager.IssueSession(now.Add(time.Hour))
require.NoError(t, err)
payloadBytes, signature, _ := decodeQuickTunnelAuthSessionCookie(t, cookie)
if test.tamperPayload {
payloadBytes[0] ^= 0xff
}
if test.tamperSignature {
signature[0] ^= 0xff
}
cookie.Value = encodeQuickTunnelAuthSessionCookieValue(payloadBytes, signature)
valid, err := manager.ValidateSession(requestWithQuickTunnelAuthSession(cookie.Value))
require.NoError(t, err)
assert.False(t, valid)
})
}
}
func TestQuickTunnelAuthSessionManagerValidateSessionRejectsPreviousProcess(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
firstManager := newTestQuickTunnelAuthSessionManager(
now,
0x11,
bytes.NewReader(bytes.Repeat([]byte{0x24}, quickTunnelAuthSessionNonceSize)),
)
cookie, err := firstManager.IssueSession(now.Add(time.Hour))
require.NoError(t, err)
restartedManager := newTestQuickTunnelAuthSessionManager(now, 0x22, bytes.NewReader(nil))
valid, err := restartedManager.ValidateSession(requestWithQuickTunnelAuthSession(cookie.Value))
require.NoError(t, err)
assert.False(t, valid)
}
func TestQuickTunnelAuthSessionManagerValidateSessionErrors(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.September, 2, 10, 0, 0, 0, time.UTC)
manager := newTestQuickTunnelAuthSessionManager(now, 0x42, bytes.NewReader(nil))
t.Run("nil request", func(t *testing.T) {
t.Parallel()
valid, err := manager.ValidateSession(nil)
require.EqualError(t, err, "validate authentication session: request is nil")
assert.False(t, valid)
})
t.Run("missing cookie", func(t *testing.T) {
t.Parallel()
request := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
valid, err := manager.ValidateSession(request)
require.NoError(t, err)
assert.False(t, valid)
})
t.Run("uninitialized manager", func(t *testing.T) {
t.Parallel()
var uninitialized *QuickTunnelAuthSessionManager
valid, err := uninitialized.ValidateSession(httptest.NewRequest(http.MethodGet, "https://example.com", nil))
require.EqualError(t, err, "authentication session manager is not initialized")
assert.False(t, valid)
})
t.Run("missing signing key", func(t *testing.T) {
t.Parallel()
unsignedManager := newTestQuickTunnelAuthSessionManager(now, 0, bytes.NewReader(nil))
unsignedManager.sessionCookieSigningKey = nil
payloadBytes := []byte(`{}`)
signature := make([]byte, sha256.Size)
request := requestWithQuickTunnelAuthSession(encodeQuickTunnelAuthSessionCookieValue(payloadBytes, signature))
valid, err := unsignedManager.ValidateSession(request)
require.EqualError(t, err, "verify authentication session signature: session cookie signing key is not initialized")
assert.False(t, valid)
})
}
func newTestQuickTunnelAuthSessionManager(now time.Time, keyByte byte, random io.Reader) *QuickTunnelAuthSessionManager {
return &QuickTunnelAuthSessionManager{
sessionCookieSigningKey: bytes.Repeat([]byte{keyByte}, quickTunnelAuthSessionSigningKeySize),
random: random,
now: func() time.Time { return now },
}
}
func signedQuickTunnelAuthSessionCookie(t *testing.T, manager *QuickTunnelAuthSessionManager, payloadBytes []byte) *http.Cookie {
t.Helper()
signature, err := manager.sign(payloadBytes)
require.NoError(t, err)
return &http.Cookie{
Name: quickTunnelAuthSessionCookieName,
Value: encodeQuickTunnelAuthSessionCookieValue(payloadBytes, signature),
Path: quickTunnelAuthSessionCookiePath,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}
func decodeQuickTunnelAuthSessionCookie(t *testing.T, cookie *http.Cookie) ([]byte, []byte, quickTunnelAuthSessionCookiePayload) {
t.Helper()
encodedPayload, encodedSignature, found := strings.Cut(cookie.Value, quickTunnelAuthSessionCookieSeparator)
require.True(t, found)
payloadBytes, err := base64.RawURLEncoding.DecodeString(encodedPayload)
require.NoError(t, err)
signature, err := base64.RawURLEncoding.DecodeString(encodedSignature)
require.NoError(t, err)
var payload quickTunnelAuthSessionCookiePayload
require.NoError(t, json.Unmarshal(payloadBytes, &payload))
return payloadBytes, signature, payload
}
func requestWithQuickTunnelAuthSession(cookieValue string) *http.Request {
request := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
request.AddCookie(&http.Cookie{
Name: quickTunnelAuthSessionCookieName,
Value: cookieValue,
Path: quickTunnelAuthSessionCookiePath,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
return request
}
+60 -14
View File
@@ -2,12 +2,18 @@ package retry
import (
"context"
"math/rand"
"math"
"time"
"github.com/cloudflare/backoff"
)
const (
DefaultBaseTime time.Duration = time.Second
DefaultBaseTime time.Duration = time.Second
initialBackoffExponent = 1
gracePeriodExponentOffset = 2
// int64OverflowExponent is the first exponent that shifts into int64's sign bit.
int64OverflowExponent = 63
)
// Redeclare time functions so they can be overridden in tests.
@@ -33,6 +39,7 @@ type BackoffHandler struct {
retries uint
resetDeadline time.Time
strategy *backoff.Backoff
Clock Clock
}
@@ -54,23 +61,26 @@ func (b BackoffHandler) GetMaxBackoffDuration(ctx context.Context) (time.Duratio
return time.Duration(0), false
default:
}
retries := b.retries
if !b.resetDeadline.IsZero() && b.Clock.Now().After(b.resetDeadline) {
// b.retries would be set to 0 at this point
return time.Second, true
retries = 0
}
if b.retries >= b.maxRetries && !b.retryForever {
if retries >= b.maxRetries && !b.retryForever {
return time.Duration(0), false
}
maxTimeToWait := b.GetBaseTime() * 1 << (b.retries + 1)
return maxTimeToWait, true
if retries < b.maxRetries {
retries++
}
return exponentialBackoffDuration(b.GetBaseTime(), retries), true
}
// BackoffTimer returns a channel that sends the current time when the exponential backoff timeout expires.
// Returns nil if the maximum number of retries have been used.
func (b *BackoffHandler) BackoffTimer() <-chan time.Time {
if !b.resetDeadline.IsZero() && b.Clock.Now().After(b.resetDeadline) {
b.retries = 0
b.resetDeadline = time.Time{}
b.reset()
}
if b.retries >= b.maxRetries {
if !b.retryForever {
@@ -79,9 +89,8 @@ func (b *BackoffHandler) BackoffTimer() <-chan time.Time {
} else {
b.retries++
}
maxTimeToWait := b.GetBaseTime() * (1 << b.retries)
timeToWait := time.Duration(rand.Int63n(maxTimeToWait.Nanoseconds())) // #nosec G404
return b.Clock.After(timeToWait)
return b.Clock.After(b.backoffStrategy().Duration())
}
// Backoff is used to wait according to exponential backoff. Returns false if the
@@ -102,8 +111,12 @@ func (b *BackoffHandler) Backoff(ctx context.Context) bool {
// Sets a grace period within which the backoff timer is maintained. After the grace
// period expires, the number of retries & backoff duration is reset.
func (b *BackoffHandler) SetGracePeriod() time.Duration {
maxTimeToWait := b.GetBaseTime() * 2 << (b.retries + 1)
timeToWait := time.Duration(rand.Int63n(maxTimeToWait.Nanoseconds())) // #nosec G404
gracePeriodExponent := b.retries
if gracePeriodExponent < int64OverflowExponent {
gracePeriodExponent += gracePeriodExponentOffset
}
maxTimeToWait := exponentialBackoffDuration(b.GetBaseTime(), gracePeriodExponent)
timeToWait := backoff.New(maxTimeToWait, maxTimeToWait).Duration()
b.resetDeadline = b.Clock.Now().Add(timeToWait)
return timeToWait
@@ -126,6 +139,39 @@ func (b *BackoffHandler) ReachedMaxRetries() bool {
}
func (b *BackoffHandler) ResetNow() {
b.reset()
b.resetDeadline = b.Clock.Now()
}
func (b *BackoffHandler) backoffStrategy() *backoff.Backoff {
if b.strategy == nil {
b.strategy = backoff.New(
exponentialBackoffDuration(b.GetBaseTime(), b.maxRetries),
exponentialBackoffDuration(b.GetBaseTime(), initialBackoffExponent),
)
}
return b.strategy
}
func (b *BackoffHandler) reset() {
b.retries = 0
b.resetDeadline = time.Time{}
if b.strategy != nil {
b.strategy.Reset()
}
}
func exponentialBackoffDuration(baseTime time.Duration, retries uint) time.Duration {
if baseTime <= 0 {
baseTime = DefaultBaseTime
}
if retries >= int64OverflowExponent {
return time.Duration(math.MaxInt64)
}
multiplier := int64(1) << retries
if int64(baseTime) > math.MaxInt64/multiplier {
return time.Duration(math.MaxInt64)
}
return time.Duration(int64(baseTime) * multiplier)
}
+31 -19
View File
@@ -13,6 +13,8 @@ func immediateTimeAfter(time.Duration) <-chan time.Time {
}
func TestBackoffRetries(t *testing.T) {
t.Parallel()
ctx := context.Background()
// make backoff return immediately
backoff := BackoffHandler{maxRetries: 3, Clock: Clock{time.Now, immediateTimeAfter}}
@@ -31,6 +33,8 @@ func TestBackoffRetries(t *testing.T) {
}
func TestBackoffCancel(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
// prevent backoff from returning normally
after := func(time.Duration) <-chan time.Time { return make(chan time.Time) }
@@ -45,6 +49,8 @@ func TestBackoffCancel(t *testing.T) {
}
func TestBackoffGracePeriod(t *testing.T) {
t.Parallel()
ctx := context.Background()
currentTime := time.Now()
// make Clock.Now return whatever we like
@@ -68,6 +74,8 @@ func TestBackoffGracePeriod(t *testing.T) {
}
func TestGetMaxBackoffDurationRetries(t *testing.T) {
t.Parallel()
ctx := context.Background()
// make backoff return immediately
backoff := BackoffHandler{maxRetries: 3, Clock: Clock{time.Now, immediateTimeAfter}}
@@ -92,51 +100,55 @@ func TestGetMaxBackoffDurationRetries(t *testing.T) {
}
func TestGetMaxBackoffDuration(t *testing.T) {
t.Parallel()
ctx := context.Background()
// make backoff return immediately
backoff := BackoffHandler{maxRetries: 3, Clock: Clock{time.Now, immediateTimeAfter}}
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration > time.Second*2 {
t.Fatalf("backoff (%s) didn't return < 2 seconds on first retry", duration)
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration != 2*time.Second {
t.Fatalf("backoff returned %s instead of 2 seconds on first retry", duration)
}
backoff.Backoff(ctx) // noop
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration > time.Second*4 {
t.Fatalf("backoff (%s) didn't return < 4 seconds on second retry", duration)
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration != 4*time.Second {
t.Fatalf("backoff returned %s instead of 4 seconds on second retry", duration)
}
backoff.Backoff(ctx) // noop
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration > time.Second*8 {
t.Fatalf("backoff (%s) didn't return < 8 seconds on third retry", duration)
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration != 8*time.Second {
t.Fatalf("backoff returned %s instead of 8 seconds on third retry", duration)
}
backoff.Backoff(ctx) // noop
if duration, ok := backoff.GetMaxBackoffDuration(ctx); ok || duration != 0 {
t.Fatalf("backoff (%s) didn't return 0 seconds on fourth retry (exceeding limit)", duration)
t.Fatalf("backoff returned %s instead of 0 seconds after exhausting retries", duration)
}
}
func TestBackoffRetryForever(t *testing.T) {
t.Parallel()
ctx := context.Background()
// make backoff return immediately
backoff := BackoffHandler{maxRetries: 3, retryForever: true, Clock: Clock{time.Now, immediateTimeAfter}}
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration > time.Second*2 {
t.Fatalf("backoff (%s) didn't return < 2 seconds on first retry", duration)
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration != 2*time.Second {
t.Fatalf("backoff returned %s instead of 2 seconds on first retry", duration)
}
backoff.Backoff(ctx) // noop
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration > time.Second*4 {
t.Fatalf("backoff (%s) didn't return < 4 seconds on second retry", duration)
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration != 4*time.Second {
t.Fatalf("backoff returned %s instead of 4 seconds on second retry", duration)
}
backoff.Backoff(ctx) // noop
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration > time.Second*8 {
t.Fatalf("backoff (%s) didn't return < 8 seconds on third retry", duration)
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration != 8*time.Second {
t.Fatalf("backoff returned %s instead of 8 seconds on third retry", duration)
}
if !backoff.Backoff(ctx) {
t.Fatalf("backoff refused on fourth retry despire RetryForever")
t.Fatalf("backoff refused on fourth retry despite RetryForever")
}
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration > time.Second*16 {
t.Fatalf("backoff returned %v instead of 8 seconds on fourth retry", duration)
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration != 8*time.Second {
t.Fatalf("backoff returned %s instead of 8 seconds on fourth retry", duration)
}
if !backoff.Backoff(ctx) {
t.Fatalf("backoff refused on fifth retry despire RetryForever")
t.Fatalf("backoff refused on fifth retry despite RetryForever")
}
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration > time.Second*16 {
t.Fatalf("backoff returned %v instead of 8 seconds on fifth retry", duration)
if duration, ok := backoff.GetMaxBackoffDuration(ctx); !ok || duration != 8*time.Second {
t.Fatalf("backoff returned %s instead of 8 seconds on fifth retry", duration)
}
}