snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
266
oryx/cookiex/cookiex.go
Normal file
266
oryx/cookiex/cookiex.go
Normal file
@@ -0,0 +1,266 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package cookiex encodes authenticated, encrypted HTTP cookies. Values are
|
||||
// sealed with the Ory-wide AEAD (see ory/x/aead) under keys derived from the
|
||||
// configured secrets with HKDF-SHA256, and bound to their purpose and cookie
|
||||
// name. The package replaces the gorilla/securecookie-based cookie stores; a
|
||||
// decode-only fallback reads cookies minted by those stores until they have
|
||||
// expired.
|
||||
package cookiex
|
||||
|
||||
import (
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/x/aead"
|
||||
)
|
||||
|
||||
const (
|
||||
// formatPrefix marks the versioned wire format. A dot is not part of the
|
||||
// base64url alphabet, so legacy securecookie values can never collide
|
||||
// with the prefix.
|
||||
formatPrefix = "v1."
|
||||
// kdfInfo domain-separates the HKDF key derivation.
|
||||
kdfInfo = "ory/x/cookiex/v1"
|
||||
// aadPrefix domain-separates the additional authenticated data.
|
||||
aadPrefix = "ory/x/cookiex/v1"
|
||||
// defaultMaxAge matches the gorilla/securecookie default that the
|
||||
// previous cookie stores relied on.
|
||||
defaultMaxAge = 30 * 24 * time.Hour
|
||||
// maxCookieValueLength is the browser limit that securecookie also
|
||||
// enforced.
|
||||
maxCookieValueLength = 4096
|
||||
)
|
||||
|
||||
// ErrInvalidCookie is returned when a cookie with the requested name is
|
||||
// present but none of its values could be decoded (or none matched).
|
||||
var ErrInvalidCookie = errors.New("cookiex: cookie could not be decoded")
|
||||
|
||||
type (
|
||||
// Option configures a Codec.
|
||||
Option func(*config)
|
||||
|
||||
config struct {
|
||||
maxAge time.Duration
|
||||
legacyKeyPairs [][]byte
|
||||
legacyEncode bool
|
||||
}
|
||||
)
|
||||
|
||||
// WithMaxAge overrides how old a cookie may be before decoding rejects it.
|
||||
// The default is 30 days; zero disables the check. The value must not be
|
||||
// negative.
|
||||
func WithMaxAge(d time.Duration) Option {
|
||||
return func(c *config) { c.maxAge = d }
|
||||
}
|
||||
|
||||
// Codec seals and opens cookie values of type T. T is serialized as JSON.
|
||||
// A Codec is safe for concurrent use.
|
||||
type Codec[T any] struct {
|
||||
purpose string
|
||||
keys [][32]byte
|
||||
maxAge time.Duration
|
||||
legacy legacyState
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New returns a codec for the given purpose. The purpose is bound into the
|
||||
// ciphertext and used as the metric label; it must be a short constant like
|
||||
// "kratos/session". Because the purpose is embedded in the additional
|
||||
// authenticated data, it must be non-empty and must not contain a pipe
|
||||
// character. The codec seals with a key derived from the first secret
|
||||
// and opens with keys derived from any of them, so secrets rotate by
|
||||
// prepending a new one.
|
||||
func New[T any](purpose string, secrets [][]byte, opts ...Option) (*Codec[T], error) {
|
||||
if purpose == "" || strings.Contains(purpose, "|") {
|
||||
return nil, errors.New("cookiex: purpose must be non-empty and must not contain a pipe character")
|
||||
}
|
||||
if len(secrets) == 0 {
|
||||
return nil, errors.New("cookiex: at least one secret is required")
|
||||
}
|
||||
cfg := config{maxAge: defaultMaxAge}
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
}
|
||||
if cfg.maxAge < 0 {
|
||||
return nil, errors.New("cookiex: max age must not be negative")
|
||||
}
|
||||
if cfg.legacyEncode && len(cfg.legacyKeyPairs) == 0 {
|
||||
return nil, errors.New("cookiex: legacy encode requires legacy key pairs")
|
||||
}
|
||||
keys := make([][32]byte, len(secrets))
|
||||
for i, secret := range secrets {
|
||||
key, err := hkdf.Key(sha256.New, secret, nil, kdfInfo, 32)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cookiex: cannot derive key")
|
||||
}
|
||||
keys[i] = [32]byte(key)
|
||||
}
|
||||
return &Codec[T]{
|
||||
purpose: purpose,
|
||||
keys: keys,
|
||||
maxAge: cfg.maxAge,
|
||||
legacy: newLegacyState(cfg, cfg.maxAge),
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// envelope wraps the JSON payload with the seal time so decoding can enforce
|
||||
// the max age.
|
||||
type envelope struct {
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Values json.RawMessage `json:"v"`
|
||||
}
|
||||
|
||||
// aad binds a ciphertext to this codec's purpose and the cookie name, so a
|
||||
// sealed value cannot be replayed as a different cookie or in a different
|
||||
// context, even under the same key.
|
||||
func (c *Codec[T]) aad(name string) []byte {
|
||||
return []byte(aadPrefix + "|" + c.purpose + "|" + name)
|
||||
}
|
||||
|
||||
func (c *Codec[T]) seal(name string, value T) (string, error) {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "cookiex: cannot marshal cookie value")
|
||||
}
|
||||
plaintext, err := json.Marshal(envelope{IssuedAt: c.now().Unix(), Values: payload})
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "cookiex: cannot marshal envelope")
|
||||
}
|
||||
a, err := aead.New(c.keys[0])
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "cookiex: cannot create AEAD")
|
||||
}
|
||||
// The nonce is prepended to the ciphertext. AEADs that manage the nonce
|
||||
// internally report a nonce size of zero, so this also covers them.
|
||||
nonce := make([]byte, a.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", errors.Wrap(err, "cookiex: cannot generate nonce")
|
||||
}
|
||||
sealed := a.Seal(nonce, nonce, plaintext, c.aad(name))
|
||||
return formatPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func (c *Codec[T]) open(name, value string) (T, error) {
|
||||
var zero T
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, formatPrefix))
|
||||
if err != nil {
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
for _, key := range c.keys {
|
||||
a, err := aead.New(key)
|
||||
if err != nil {
|
||||
return zero, errors.Wrap(err, "cookiex: cannot create AEAD")
|
||||
}
|
||||
if len(raw) < a.NonceSize() {
|
||||
continue
|
||||
}
|
||||
plaintext, err := a.Open(nil, raw[:a.NonceSize()], raw[a.NonceSize():], c.aad(name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var env envelope
|
||||
if err := json.Unmarshal(plaintext, &env); err != nil {
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
if c.maxAge > 0 && c.now().Sub(time.Unix(env.IssuedAt, 0)) > c.maxAge {
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
var out T
|
||||
if err := json.Unmarshal(env.Values, &out); err != nil {
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
|
||||
// Set seals value into cookie.Value and writes the cookie to w. All other
|
||||
// attributes (name, path, domain, max-age, secure, http-only, same-site) must
|
||||
// be set by the caller on the cookie.
|
||||
func (c *Codec[T]) Set(w http.ResponseWriter, cookie *http.Cookie, value T) error {
|
||||
var encoded string
|
||||
var err error
|
||||
if c.legacy.encode {
|
||||
encoded, err = c.sealLegacy(cookie.Name, value)
|
||||
} else {
|
||||
encoded, err = c.seal(cookie.Name, value)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(encoded) > maxCookieValueLength {
|
||||
return errors.Errorf("cookiex: encoded cookie %q exceeds %d bytes", cookie.Name, maxCookieValueLength)
|
||||
}
|
||||
cookie.Value = encoded
|
||||
http.SetCookie(w, cookie)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the value of the first cookie named name that decodes. It
|
||||
// returns http.ErrNoCookie when no cookie with that name is present, and
|
||||
// ErrInvalidCookie when none of the present values decode.
|
||||
func (c *Codec[T]) Get(r *http.Request, name string) (T, error) {
|
||||
return c.GetMatching(r, name, nil)
|
||||
}
|
||||
|
||||
// GetMatching returns the value of the first cookie named name that decodes
|
||||
// and matches. Requests can carry multiple cookies with the same name, for
|
||||
// example one from a parent and one from a sub domain; match selects among
|
||||
// them. A nil match matches any value. It returns http.ErrNoCookie when no
|
||||
// cookie with that name is present, and ErrInvalidCookie when none of the
|
||||
// present values decode and match.
|
||||
func (c *Codec[T]) GetMatching(r *http.Request, name string, match func(T) bool) (T, error) {
|
||||
if match == nil {
|
||||
match = func(T) bool { return true }
|
||||
}
|
||||
var zero T
|
||||
found := false
|
||||
for _, ck := range r.Cookies() {
|
||||
if ck.Name != name {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
// Refuse oversized values before doing any decode work, matching the
|
||||
// limit Set enforces and securecookie's own decode-side check.
|
||||
if len(ck.Value) > maxCookieValueLength {
|
||||
continue
|
||||
}
|
||||
var value T
|
||||
var err error
|
||||
if strings.HasPrefix(ck.Value, formatPrefix) {
|
||||
value, err = c.open(name, ck.Value)
|
||||
} else {
|
||||
value, err = c.openLegacy(name, ck.Value)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if match(value) {
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return zero, errors.WithStack(http.ErrNoCookie)
|
||||
}
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
|
||||
// Expire overwrites the cookie with an expired empty one, deleting it from
|
||||
// the client.
|
||||
func (c *Codec[T]) Expire(w http.ResponseWriter, cookie *http.Cookie) {
|
||||
cookie.Value = ""
|
||||
cookie.MaxAge = -1
|
||||
cookie.Expires = time.Unix(1, 0)
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
136
oryx/cookiex/legacy_securecookie.go
Normal file
136
oryx/cookiex/legacy_securecookie.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cookiex
|
||||
|
||||
// This file isolates the gorilla/securecookie dependency. It exists so that
|
||||
// cookies sealed by the previous securecookie-based stores keep decoding
|
||||
// during the migration, and (behind WithLegacyEncode) so that freshly minted
|
||||
// cookies stay readable by old pods during the stage-1 rollout. Delete it,
|
||||
// and the securecookie dependency, once ory_legacy_cookie_decodes_total has
|
||||
// flatlined fleet-wide for a full max-cookie-lifespan window.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// legacyDecodes counts cookies that were successfully decoded via the legacy
|
||||
// securecookie fallback. It is registered on the Prometheus default
|
||||
// registerer, which Kratos and Hydra expose on /metrics/prometheus.
|
||||
var legacyDecodes = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "ory_legacy_cookie_decodes_total",
|
||||
Help: "Number of cookies successfully decoded via the legacy securecookie fallback, by codec purpose.",
|
||||
}, []string{"purpose"})
|
||||
|
||||
// LegacySecureCookieKeyPairs derives the (hashKey, blockKey) pairs the
|
||||
// securecookie-based cookie stores used: each secret signs, and its SHA-256
|
||||
// digest encrypts.
|
||||
func LegacySecureCookieKeyPairs(secrets [][]byte) [][]byte {
|
||||
pairs := make([][]byte, 0, 2*len(secrets))
|
||||
for _, secret := range secrets {
|
||||
block := sha256.Sum256(secret)
|
||||
pairs = append(pairs, secret, block[:])
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
// WithLegacyKeyPairs enables decoding cookies in the legacy securecookie
|
||||
// format, sealed under the given (hashKey, blockKey) pairs. While this option
|
||||
// is in use, T must be unmarshalable from a flat JSON object with string
|
||||
// values; WithLegacyEncode additionally requires T to marshal to one. Remove
|
||||
// once all cookies sealed in the legacy format have expired.
|
||||
func WithLegacyKeyPairs(pairs ...[]byte) Option {
|
||||
return func(c *config) { c.legacyKeyPairs = pairs }
|
||||
}
|
||||
|
||||
type legacyState struct {
|
||||
codecs []securecookie.Codec
|
||||
encode bool
|
||||
}
|
||||
|
||||
func newLegacyState(cfg config, maxAge time.Duration) legacyState {
|
||||
codecs := securecookie.CodecsFromPairs(cfg.legacyKeyPairs...)
|
||||
for _, codec := range codecs {
|
||||
if sc, ok := codec.(*securecookie.SecureCookie); ok {
|
||||
sc.MaxAge(int(maxAge / time.Second))
|
||||
}
|
||||
}
|
||||
return legacyState{codecs: codecs, encode: cfg.legacyEncode}
|
||||
}
|
||||
|
||||
// openLegacy decodes a securecookie-sealed value and bridges it into T via
|
||||
// its JSON representation: legacy payloads are flat string-to-string maps, so
|
||||
// string, time.Time, and UUID fields all round-trip through their JSON string
|
||||
// forms.
|
||||
func (c *Codec[T]) openLegacy(name, value string) (T, error) {
|
||||
var zero T
|
||||
if len(c.legacy.codecs) == 0 {
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
values := make(map[any]any)
|
||||
if err := securecookie.DecodeMulti(name, value, &values, c.legacy.codecs...); err != nil {
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
flat := make(map[string]string, len(values))
|
||||
for k, v := range values {
|
||||
key, keyOK := k.(string)
|
||||
val, valOK := v.(string)
|
||||
if !keyOK || !valOK {
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
flat[key] = val
|
||||
}
|
||||
bridge, err := json.Marshal(flat)
|
||||
if err != nil {
|
||||
return zero, errors.Wrap(err, "cookiex: cannot bridge legacy cookie")
|
||||
}
|
||||
var out T
|
||||
if err := json.Unmarshal(bridge, &out); err != nil {
|
||||
return zero, errors.WithStack(ErrInvalidCookie)
|
||||
}
|
||||
legacyDecodes.WithLabelValues(c.purpose).Inc()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WithLegacyEncode makes Set seal in the legacy securecookie format under the
|
||||
// first legacy key pair, so pods that only understand the legacy format can
|
||||
// read freshly minted cookies during a rolling deploy. Requires
|
||||
// WithLegacyKeyPairs. This is stage 1 of the rollout; a follow-up removes the
|
||||
// option, flipping encoding to the v1 format. JSON null values inside the
|
||||
// payload are coerced to empty strings by the bridge; do not use pointer-typed
|
||||
// fields while legacy encode is enabled.
|
||||
func WithLegacyEncode() Option {
|
||||
return func(c *config) { c.legacyEncode = true }
|
||||
}
|
||||
|
||||
// sealLegacy bridges T through its JSON representation into the flat
|
||||
// string-to-string map that the securecookie stores used.
|
||||
func (c *Codec[T]) sealLegacy(name string, value T) (string, error) {
|
||||
buf, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "cookiex: cannot marshal cookie value")
|
||||
}
|
||||
var flat map[string]string
|
||||
if err := json.Unmarshal(buf, &flat); err != nil {
|
||||
return "", errors.Wrap(err, "cookiex: payload must be a flat JSON object with string values while legacy encode is enabled")
|
||||
}
|
||||
if flat == nil {
|
||||
return "", errors.New("cookiex: payload must be a flat JSON object with string values while legacy encode is enabled")
|
||||
}
|
||||
values := make(map[any]any, len(flat))
|
||||
for k, v := range flat {
|
||||
values[k] = v
|
||||
}
|
||||
encoded, err := securecookie.EncodeMulti(name, values, c.legacy.codecs[0])
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "cookiex: cannot encode legacy cookie")
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
Reference in New Issue
Block a user