diff --git a/quicktunnelauth/session.go b/quicktunnelauth/session.go new file mode 100644 index 00000000..06b9fee8 --- /dev/null +++ b/quicktunnelauth/session.go @@ -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 +// ".". +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 +} diff --git a/quicktunnelauth/session_test.go b/quicktunnelauth/session_test.go new file mode 100644 index 00000000..72f07e45 --- /dev/null +++ b/quicktunnelauth/session_test.go @@ -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 +} diff --git a/retry/backoffhandler.go b/retry/backoffhandler.go index a2647ecb..8f5eb73b 100644 --- a/retry/backoffhandler.go +++ b/retry/backoffhandler.go @@ -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) } diff --git a/retry/backoffhandler_test.go b/retry/backoffhandler_test.go index f118c43f..27aaf961 100644 --- a/retry/backoffhandler_test.go +++ b/retry/backoffhandler_test.go @@ -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) } }