From 9f92c71d6286e354bfdd9df9ba360baf09b85dc3 Mon Sep 17 00:00:00 2001 From: Maksim Nabokikh Date: Fri, 27 Mar 2026 09:33:20 +0100 Subject: [PATCH] feat: cookies encryption support (#4676) Signed-off-by: maksim.nabokikh --- cmd/dex/config.go | 4 +++ cmd/dex/serve.go | 6 ++++ server/server.go | 1 + server/session.go | 75 ++++++++++++++++++++++++++++++++++++++---- server/session_test.go | 65 +++++++++++++++++++++++++----------- 5 files changed, 125 insertions(+), 26 deletions(-) diff --git a/cmd/dex/config.go b/cmd/dex/config.go index 846a924a..e80c1c57 100644 --- a/cmd/dex/config.go +++ b/cmd/dex/config.go @@ -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. diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go index 995ae0fc..0c26006d 100644 --- a/cmd/dex/serve.go +++ b/cmd/dex/serve.go @@ -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 } diff --git a/server/server.go b/server/server.go index 53ef2d81..4d55d5ee 100644 --- a/server/server.go +++ b/server/server.go @@ -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 diff --git a/server/session.go b/server/session.go index 52424c82..9640400e 100644 --- a/server/session.go +++ b/server/session.go @@ -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 } diff --git a/server/session_test.go b/server/session_test.go index f00eed59..eca04b95 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -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)