From 65636957878280ad10528e68f0fc358bf0b3de82 Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 19 Sep 2026 14:09:06 -0600 Subject: [PATCH] feat(auth): add Express signed cookie package --- auth/expresscookie/expresscookie.go | 138 +++++++++++++++++++ auth/expresscookie/expresscookie_test.go | 164 +++++++++++++++++++++++ auth/expresscookie/go.mod | 3 + 3 files changed, 305 insertions(+) create mode 100644 auth/expresscookie/expresscookie.go create mode 100644 auth/expresscookie/expresscookie_test.go create mode 100644 auth/expresscookie/go.mod diff --git a/auth/expresscookie/expresscookie.go b/auth/expresscookie/expresscookie.go new file mode 100644 index 0000000..97cd0f6 --- /dev/null +++ b/auth/expresscookie/expresscookie.go @@ -0,0 +1,138 @@ +package expresscookie + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/url" + "strings" +) + +const minSecretBytes = 16 + +var ( + // ErrNoSecret indicates that no signing secret was provided. + ErrNoSecret = errors.New("no secret") + // ErrSecretTooShort indicates that a secret has too little entropy. + ErrSecretTooShort = errors.New("secret too short") + // ErrCookieNameEmpty indicates that no cookie name was provided. + ErrCookieNameEmpty = errors.New("cookie name is empty") +) + +// Secret is a validated cookie-signing secret. +// +// [NewSecret] is the supported constructor. Converting bytes directly, as in +// Secret(value), bypasses validation and is unsafe for production. Direct +// conversion is retained only for security research and testing; its semantics +// may change, so callers and applications must not depend on it. +type Secret []byte + +// NewSecret validates and copies a cookie-signing secret. +// +// Secrets must contain at least 16 bytes. Use a randomly generated secret of +// at least 32 bytes for new applications. +func NewSecret(value []byte) (Secret, error) { + if len(value) == 0 { + return nil, ErrNoSecret + } + if len(value) < minSecretBytes { + return nil, fmt.Errorf("%w: got %d bytes, need at least %d", ErrSecretTooShort, len(value), minSecretBytes) + } + return Secret(bytes.Clone(value)), nil +} + +// DecodeHexSecret decodes and validates an APP_SECRET-style hexadecimal cookie key. +func DecodeHexSecret(value string) (Secret, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, ErrNoSecret + } + secret, err := hex.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("decode hex secret: %w", err) + } + return NewSecret(secret) +} + +// Cookie is a parsed or signable cookie. +// +// A parsed Cookie is untrusted until Verify succeeds. Verify returns the +// payload even when verification fails; callers must always check its error. +type Cookie struct { + http.Cookie + payload []byte + signature string +} + +// New creates a signable cookie from a name, raw payload, and HTTP cookie +// options. The options' Name and Value fields are ignored. It returns +// [ErrCookieNameEmpty] when name is empty. +func New(name string, payload []byte, options http.Cookie) (Cookie, error) { + if name == "" { + return Cookie{}, ErrCookieNameEmpty + } + options.Name = name + return Cookie{Cookie: options, payload: bytes.Clone(payload)}, nil +} + +// Sign creates an HttpOnly, Secure, SameSite=Strict HTTP cookie. +func (c Cookie) Sign(secret Secret) Cookie { + if len(secret) == 0 { + panic(ErrNoSecret) + } + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write(c.payload) + sig := base64.RawStdEncoding.EncodeToString(mac.Sum(nil)) + c.Cookie.Value = url.QueryEscape("s:" + string(c.payload) + "." + sig) + c.Cookie.HttpOnly = true + c.Cookie.Secure = true + if c.Cookie.SameSite == 0 { + c.Cookie.SameSite = http.SameSiteStrictMode + } + return c +} + +// Parse parses the signed value from an HTTP cookie without verifying it. +func Parse(rawValue string) (Cookie, error) { + cookieValue, err := url.QueryUnescape(rawValue) + if err != nil { + return Cookie{}, fmt.Errorf("failed to unescape cookie value: %w", err) + } + if len(cookieValue) < 2 || cookieValue[:2] != "s:" { + return Cookie{}, fmt.Errorf("failed to decode signed cookie: missing 's:' prefix") + } + withoutPrefix := cookieValue[2:] + dotIdx := strings.LastIndex(withoutPrefix, ".") + if dotIdx == -1 { + return Cookie{}, fmt.Errorf("failed to decode signed cookie: missing '.' separator") + } + return Cookie{ + Cookie: http.Cookie{Value: rawValue}, + payload: []byte(withoutPrefix[:dotIdx]), + signature: withoutPrefix[dotIdx+1:], + }, nil +} + +// Verify checks the signature and returns the raw payload. +// +// The payload is returned even when verification fails. Callers must always +// check the error before using it. +func (c Cookie) Verify(secret Secret) ([]byte, error) { + payload := bytes.Clone(c.payload) + if len(secret) == 0 { + return payload, fmt.Errorf("cookie HMAC verification failed: %w", ErrNoSecret) + } + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write(payload) + expected := base64.RawStdEncoding.EncodeToString(mac.Sum(nil)) + normalized := strings.NewReplacer("-", "+", "_", "/").Replace(c.signature) + if !hmac.Equal([]byte(expected), []byte(normalized)) { + return payload, fmt.Errorf("cookie HMAC verification failed: signature mismatch") + } + return payload, nil +} diff --git a/auth/expresscookie/expresscookie_test.go b/auth/expresscookie/expresscookie_test.go new file mode 100644 index 0000000..a04ebc6 --- /dev/null +++ b/auth/expresscookie/expresscookie_test.go @@ -0,0 +1,164 @@ +package expresscookie_test + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/therootcompany/golib/auth/expresscookie" +) + +func testSecret(t *testing.T) expresscookie.Secret { + t.Helper() + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + t.Fatal(err) + } + validated, err := expresscookie.NewSecret(secret) + if err != nil { + t.Fatal(err) + } + return validated +} + +func TestNewSecretValidation(t *testing.T) { + tests := []struct { + name string + value []byte + want error + }{ + {name: "empty", want: expresscookie.ErrNoSecret}, + {name: "short", value: make([]byte, 15), want: expresscookie.ErrSecretTooShort}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := expresscookie.NewSecret(test.value) + if err == nil { + t.Fatal("NewSecret succeeded") + } + if !errors.Is(err, test.want) { + t.Fatalf("error = %v, want %v", err, test.want) + } + }) + } +} + +func TestNewRejectsEmptyName(t *testing.T) { + if _, err := expresscookie.New("", nil, http.Cookie{}); !errors.Is(err, expresscookie.ErrCookieNameEmpty) { + t.Fatalf("error = %v, want ErrCookieNameEmpty", err) + } +} + +func TestDecodeHexSecret(t *testing.T) { + // APP_SECRET is hex, not base64. + encoded := "0102030405060708090a0b0c0d0e0f10" + decoded, err := expresscookie.DecodeHexSecret(encoded) + if err != nil { + t.Fatal(err) + } + if len(decoded) != 16 || decoded[0] != 1 || decoded[15] != 16 { + t.Fatalf("decoded secret = %x", decoded) + } +} + +func TestSignedCookieRoundTrip(t *testing.T) { + secret := testSecret(t) + expiresAt := time.Now().Add(time.Hour) + cookieConfig, err := expresscookie.New("session", []byte(`{"sub":"user-1","exp":123}`), http.Cookie{ + Name: "session", + Path: "/", + Expires: expiresAt, + }) + if err != nil { + t.Fatal(err) + } + cookie := cookieConfig.Sign(secret) + if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("cookie security attributes = %#v", cookie) + } + parsed, err := expresscookie.Parse(cookie.Value) + if err != nil { + t.Fatal(err) + } + payload, err := parsed.Verify(secret) + if err != nil { + t.Fatal(err) + } + if string(payload) != `{"sub":"user-1","exp":123}` { + t.Fatalf("payload = %q", payload) + } +} + +func TestSignedCookieRejectsTampering(t *testing.T) { + secret := testSecret(t) + cookieConfig, err := expresscookie.New("session", []byte("payload"), http.Cookie{ + Name: "session", + Expires: time.Now().Add(time.Hour), + }) + if err != nil { + t.Fatal(err) + } + cookie := cookieConfig.Sign(secret) + parsed, err := expresscookie.Parse(cookie.Value + "tampered") + if err != nil { + t.Fatal(err) + } + payload, err := parsed.Verify(secret) + if err == nil { + t.Fatal("tampered cookie was accepted") + } + if string(payload) != "payload" { + t.Fatalf("payload = %q, want %q", payload, "payload") + } +} + +func TestEncodedPayloadStages(t *testing.T) { + secret, err := expresscookie.NewSecret([]byte("example-secret-16")) + if err != nil { + t.Fatal(err) + } + cookieConfig, err := expresscookie.New("session", []byte("SGVsbG8sIFdvcmxkIQ=="), http.Cookie{}) + if err != nil { + t.Fatal(err) + } + cookie := cookieConfig.Sign(secret) + const want = "s%3ASGVsbG8sIFdvcmxkIQ%3D%3D.F%2FbW1t2GXhUIqykISYbB%2BFMA3lLquegPU4jYjVLsnXs" + if cookie.Value != want { + t.Fatalf("cookie value = %q, want %q", cookie.Value, want) + } +} + +func ExampleNew() { + secret, err := expresscookie.NewSecret([]byte("example-secret-16")) + if err != nil { + panic(err) + } + payload := base64.StdEncoding.AppendEncode(nil, []byte("Hello, World!")) + cookieConfig, err := expresscookie.New("session", payload, http.Cookie{}) + if err != nil { + panic(err) + } + cookie := cookieConfig.Sign(secret) + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&cookie.Cookie) + requestCookie, err := r.Cookie("session") + if err != nil { + panic(err) + } + parsed, err := expresscookie.Parse(requestCookie.Value) + if err != nil { + panic(err) + } + verified, err := parsed.Verify(secret) + if err != nil { + panic(err) + } + fmt.Println(string(verified)) + // Output: SGVsbG8sIFdvcmxkIQ== +} diff --git a/auth/expresscookie/go.mod b/auth/expresscookie/go.mod new file mode 100644 index 0000000..15364b5 --- /dev/null +++ b/auth/expresscookie/go.mod @@ -0,0 +1,3 @@ +module github.com/therootcompany/golib/auth/expresscookie + +go 1.26.0