mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
feat(auth): add Express signed cookie package
This commit is contained in:
parent
c6da1e7e4a
commit
6563695787
138
auth/expresscookie/expresscookie.go
Normal file
138
auth/expresscookie/expresscookie.go
Normal 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
|
||||
}
|
||||
164
auth/expresscookie/expresscookie_test.go
Normal file
164
auth/expresscookie/expresscookie_test.go
Normal 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==
|
||||
}
|
||||
3
auth/expresscookie/go.mod
Normal file
3
auth/expresscookie/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module github.com/therootcompany/golib/auth/expresscookie
|
||||
|
||||
go 1.26.0
|
||||
Loading…
x
Reference in New Issue
Block a user