feat(auth): add Express signed cookie package

This commit is contained in:
AJ ONeal 2026-09-19 14:09:06 -06:00
parent c6da1e7e4a
commit 6563695787
No known key found for this signature in database
3 changed files with 305 additions and 0 deletions

View File

@ -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
}

View File

@ -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==
}

View File

@ -0,0 +1,3 @@
module github.com/therootcompany/golib/auth/expresscookie
go 1.26.0