mirror of
https://github.com/netbirdio/dex.git
synced 2026-05-22 18:43:53 -07:00
feat: cookies encryption support (#4676)
Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user