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