feat: cookies encryption support (#4676)

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
Maksim Nabokikh
2026-03-27 09:33:20 +01:00
committed by GitHub
parent 58e387a5fa
commit 9f92c71d62
5 changed files with 125 additions and 26 deletions
+4
View File
@@ -683,6 +683,10 @@ type Sessions struct {
ValidIfNotUsedFor string `json:"validIfNotUsedFor"`
// RememberMeCheckedByDefault controls the default state of the "remember me" checkbox.
RememberMeCheckedByDefault *bool `json:"rememberMeCheckedByDefault"`
// CookieEncryptionKey is the AES key for encrypting session cookies.
// Must be 16, 24, or 32 bytes for AES-128, AES-192, or AES-256.
// If empty, cookies are not encrypted.
CookieEncryptionKey string `json:"cookieEncryptionKey"`
}
// MFAAuthenticator defines a multi-factor authentication provider.
+6
View File
@@ -804,6 +804,9 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) {
if s.RememberMeCheckedByDefault != nil {
sc.RememberMeCheckedByDefault = *s.RememberMeCheckedByDefault
}
if s.CookieEncryptionKey != "" {
sc.CookieEncryptionKey = []byte(s.CookieEncryptionKey)
}
}
if sc.AbsoluteLifetime <= 0 {
return nil, fmt.Errorf("absoluteLifetime must be positive, got %v", sc.AbsoluteLifetime)
@@ -814,6 +817,9 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) {
if sc.ValidIfNotUsedFor > sc.AbsoluteLifetime {
return nil, fmt.Errorf("validIfNotUsedFor (%v) must not exceed absoluteLifetime (%v)", sc.ValidIfNotUsedFor, sc.AbsoluteLifetime)
}
if k := len(sc.CookieEncryptionKey); k > 0 && k != 16 && k != 24 && k != 32 {
return nil, fmt.Errorf("cookieEncryptionKey must be 16, 24, or 32 bytes (AES-128/192/256), got %d", k)
}
return sc, nil
}
+1
View File
@@ -152,6 +152,7 @@ type Config struct {
// SessionConfig holds resolved session configuration.
type SessionConfig struct {
CookieName string
CookieEncryptionKey []byte
AbsoluteLifetime time.Duration
ValidIfNotUsedFor time.Duration
RememberMeCheckedByDefault bool
+68 -7
View File
@@ -2,11 +2,15 @@ package server
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"path"
"time"
@@ -32,8 +36,9 @@ func remoteIP(r *http.Request) string {
return r.RemoteAddr
}
// sessionCookieValue encodes session identity into a cookie value using protobuf.
func sessionCookieValue(userID, connectorID, nonce string) string {
// sessionCookieValue encodes session identity into a cookie value.
// If encryptionKey is provided, the value is encrypted with AES-GCM.
func sessionCookieValue(userID, connectorID, nonce string, encryptionKey []byte) string {
val, err := internal.Marshal(&internal.SessionCookie{
UserId: userID,
ConnectorId: connectorID,
@@ -43,11 +48,24 @@ func sessionCookieValue(userID, connectorID, nonce string) string {
// Should never happen with valid string inputs.
panic(fmt.Sprintf("marshal session cookie: %v", err))
}
if len(encryptionKey) > 0 {
val, err = encryptCookieValue(val, encryptionKey)
if err != nil {
panic(fmt.Sprintf("encrypt session cookie: %v", err))
}
}
return val
}
// parseSessionCookie decodes a protobuf-encoded session cookie value.
func parseSessionCookie(value string) (userID, connectorID, nonce string, err error) {
// parseSessionCookie decodes a session cookie value.
// If encryptionKey is provided, the value is decrypted first.
func parseSessionCookie(value string, encryptionKey []byte) (userID, connectorID, nonce string, err error) {
if len(encryptionKey) > 0 {
value, err = decryptCookieValue(value, encryptionKey)
if err != nil {
return "", "", "", fmt.Errorf("decrypt session cookie: %w", err)
}
}
var cookie internal.SessionCookie
if err := internal.Unmarshal(value, &cookie); err != nil {
return "", "", "", fmt.Errorf("decode session cookie: %w", err)
@@ -55,6 +73,49 @@ func parseSessionCookie(value string) (userID, connectorID, nonce string, err er
return cookie.UserId, cookie.ConnectorId, cookie.Nonce, nil
}
// encryptCookieValue encrypts plaintext with AES-GCM and returns base64url-encoded ciphertext.
func encryptCookieValue(plaintext string, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.RawURLEncoding.EncodeToString(ciphertext), nil
}
// decryptCookieValue decodes base64url and decrypts AES-GCM ciphertext.
func decryptCookieValue(encrypted string, key []byte) (string, error) {
data, err := base64.RawURLEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func (s *Server) sessionCookiePath() string {
if s.issuerURL.Path == "" {
return "/"
@@ -65,7 +126,7 @@ func (s *Server) sessionCookiePath() string {
func (s *Server) setSessionCookie(w http.ResponseWriter, userID, connectorID, nonce string, rememberMe bool) {
cookie := &http.Cookie{
Name: s.sessionConfig.CookieName,
Value: sessionCookieValue(userID, connectorID, nonce),
Value: sessionCookieValue(userID, connectorID, nonce, s.sessionConfig.CookieEncryptionKey),
Path: s.sessionCookiePath(),
HttpOnly: true,
Secure: s.issuerURL.Scheme == "https",
@@ -103,7 +164,7 @@ func (s *Server) getValidAuthSession(ctx context.Context, w http.ResponseWriter,
return nil
}
userID, connectorID, nonce, err := parseSessionCookie(cookie.Value)
userID, connectorID, nonce, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey)
if err != nil {
s.logger.DebugContext(ctx, "invalid session cookie format", "err", err)
s.clearSessionCookie(w)
@@ -336,7 +397,7 @@ func (s *Server) updateSessionTokenIssuedAt(r *http.Request, clientID string) {
return
}
userID, connectorID, _, err := parseSessionCookie(cookie.Value)
userID, connectorID, _, err := parseSessionCookie(cookie.Value, s.sessionConfig.CookieEncryptionKey)
if err != nil {
return
}
+46 -19
View File
@@ -47,7 +47,7 @@ func TestSetSessionCookie(t *testing.T) {
c := cookies[0]
assert.Equal(t, "dex_session", c.Name)
assert.Equal(t, sessionCookieValue("user1", "conn1", "nonce123"), c.Value)
assert.Equal(t, sessionCookieValue("user1", "conn1", "nonce123", nil), c.Value)
assert.Equal(t, "/dex", c.Path)
assert.True(t, c.HttpOnly)
assert.True(t, c.Secure)
@@ -93,8 +93,8 @@ func TestSessionCookieValueRoundtrip(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
value := sessionCookieValue(tt.userID, tt.connectorID, tt.nonce)
gotUser, gotConn, gotNonce, err := parseSessionCookie(value)
value := sessionCookieValue(tt.userID, tt.connectorID, tt.nonce, nil)
gotUser, gotConn, gotNonce, err := parseSessionCookie(value, nil)
require.NoError(t, err)
assert.Equal(t, tt.userID, gotUser)
assert.Equal(t, tt.connectorID, gotConn)
@@ -103,12 +103,39 @@ func TestSessionCookieValueRoundtrip(t *testing.T) {
}
}
func TestSessionCookieValueEncryptedRoundtrip(t *testing.T) {
key := []byte("0123456789abcdef") // 16 bytes = AES-128
value := sessionCookieValue("user1", "ldap", "nonce1", key)
// Encrypted value must differ from unencrypted.
unencrypted := sessionCookieValue("user1", "ldap", "nonce1", nil)
assert.NotEqual(t, unencrypted, value)
// Must decrypt correctly.
gotUser, gotConn, gotNonce, err := parseSessionCookie(value, key)
require.NoError(t, err)
assert.Equal(t, "user1", gotUser)
assert.Equal(t, "ldap", gotConn)
assert.Equal(t, "nonce1", gotNonce)
// Wrong key must fail.
wrongKey := []byte("abcdef0123456789")
//nolint:dogsled // only for tests
_, _, _, err = parseSessionCookie(value, wrongKey)
assert.Error(t, err)
// No key must fail (encrypted value isn't valid protobuf).
//nolint:dogsled // only for tests
_, _, _, err = parseSessionCookie(value, nil)
assert.Error(t, err)
}
func TestParseSessionCookie_Invalid(t *testing.T) {
//nolint:dogsled // only for tests
_, _, _, err := parseSessionCookie("invalid")
_, _, _, err := parseSessionCookie("invalid", nil)
assert.Error(t, err)
//nolint:dogsled // only for tests
_, _, _, err = parseSessionCookie("a.b")
_, _, _, err = parseSessionCookie("a.b", nil)
assert.Error(t, err)
}
@@ -142,7 +169,7 @@ func TestGetValidAuthSession(t *testing.T) {
t.Run("session not found", func(t *testing.T) {
s := newTestSessionServer(t)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("nouser", "noconn", "nonce")})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("nouser", "noconn", "nonce", nil)})
w := httptest.NewRecorder()
assert.Nil(t, s.getValidAuthSession(ctx, w, r, authReq))
// Cookie should be cleared.
@@ -169,7 +196,7 @@ func TestGetValidAuthSession(t *testing.T) {
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "conn1", nonce)})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "conn1", nonce, nil)})
result := s.getValidAuthSession(ctx, httptest.NewRecorder(), r, authReq)
require.NotNil(t, result)
@@ -197,7 +224,7 @@ func TestGetValidAuthSession(t *testing.T) {
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "ldap", nonce)})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user1", "ldap", nonce, nil)})
githubReq := &storage.AuthRequest{ConnectorID: "github"}
assert.Nil(t, s.getValidAuthSession(ctx, httptest.NewRecorder(), r, githubReq))
@@ -222,7 +249,7 @@ func TestGetValidAuthSession(t *testing.T) {
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user2", "conn2", "wrong-nonce")})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user2", "conn2", "wrong-nonce", nil)})
conn2Req := &storage.AuthRequest{ConnectorID: "conn2"}
w := httptest.NewRecorder()
@@ -250,7 +277,7 @@ func TestGetValidAuthSession(t *testing.T) {
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user3", "conn3", nonce)})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user3", "conn3", nonce, nil)})
conn3Req := &storage.AuthRequest{ConnectorID: "conn3"}
w := httptest.NewRecorder()
@@ -282,7 +309,7 @@ func TestGetValidAuthSession(t *testing.T) {
require.NoError(t, s.storage.CreateAuthSession(ctx, session))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user4", "conn4", nonce)})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user4", "conn4", nonce, nil)})
conn4Req := &storage.AuthRequest{ConnectorID: "conn4"}
w := httptest.NewRecorder()
@@ -317,7 +344,7 @@ func TestCreateOrUpdateAuthSession(t *testing.T) {
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
userID, connectorID, nonce, err := parseSessionCookie(cookies[0].Value)
userID, connectorID, nonce, err := parseSessionCookie(cookies[0].Value, nil)
require.NoError(t, err)
assert.Equal(t, "user-1", userID)
assert.Equal(t, "mock", connectorID)
@@ -373,7 +400,7 @@ func TestCreateOrUpdateAuthSession(t *testing.T) {
// Cookie should be set with existing nonce.
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
_, _, gotNonce, err := parseSessionCookie(cookies[0].Value)
_, _, gotNonce, err := parseSessionCookie(cookies[0].Value, nil)
require.NoError(t, err)
assert.Equal(t, nonce, gotNonce)
@@ -451,7 +478,7 @@ func setupSessionLoginFixture(t *testing.T, s *Server) storage.AuthRequest {
func sessionCookieRequest(userID, connectorID, nonce string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue(userID, connectorID, nonce)})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue(userID, connectorID, nonce, nil)})
return r
}
@@ -664,7 +691,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
authReq.MaxAge = -1 // not specified
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
@@ -680,7 +707,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
authReq.MaxAge = 3600
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
@@ -696,7 +723,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
authReq.MaxAge = 3600
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
@@ -712,7 +739,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
authReq.MaxAge = 0
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
_, ok := s.trySessionLogin(ctx, r, w, &authReq)
@@ -734,7 +761,7 @@ func TestTrySessionLogin_MaxAge(t *testing.T) {
}))
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce")})
r.AddCookie(&http.Cookie{Name: "dex_session", Value: sessionCookieValue("user-1", "mock", "test-nonce", nil)})
w := httptest.NewRecorder()
redirectURL, ok := s.trySessionLogin(ctx, r, w, &authReq)