mirror of
https://github.com/cloudflare/cloudflared.git
synced 2026-10-12 02:12:14 +00:00
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:
committed by
Miguel da Costa Martins Marcelino
parent
4f80fc1c23
commit
be3ac12702
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user