mirror of https://github.com/dexidp/dex.git
18 changed files with 982 additions and 55 deletions
@ -0,0 +1,27 @@
|
||||
Goal is to come up with a compact and minimal design for https://github.com/dexidp/dex/issues/32 |
||||
|
||||
|
||||
# Notes |
||||
|
||||
- Use cookies to identify a returning user |
||||
- Sign cookie using one of the internal private keys |
||||
- Verify cookie when present |
||||
- Generate and store cookie or cookie encrypted value on Store |
||||
- Requires extension of storage.Storage interface |
||||
- Feature Flag |
||||
- Only introduce code in code-path of Password-based login providers |
||||
- Write cookie to response on success login (see Ref(1)) |
||||
- Cookie ExpiresIn should be less or equal to the minted JWT ExpiresIn |
||||
- I think simple store the storage.Claims+identity.ConnectorData and inject them into finalizeLogin, if the user is already logged in |
||||
- An ActiveSession is only valid once it has an associated AccessToken |
||||
- The ActiveSession should expire after a configurable amount of time |
||||
|
||||
|
||||
|
||||
Ref(1): |
||||
|
||||
probably here, and only in the case of http.MethodPost. |
||||
|
||||
```go |
||||
func (s *Server) handlePasswordLogin(w http.ResponseWriter, r *http.Request) { |
||||
``` |
||||
@ -0,0 +1,93 @@
|
||||
# Dex Enhancement Proposal (DEP) <#32> - <2025-10-19> - Remembe Me |
||||
|
||||
## Table of Contents |
||||
|
||||
- [Summary](#summary) |
||||
- [Motivation](#motivation) |
||||
- [Goals/Pain](#goals) |
||||
- [Non-Goals](#non-goals) |
||||
- [Proposal](#proposal) |
||||
- [User Experience](#user-experience) |
||||
- [Implementation Details/Notes/Constraints](#implementation-detailsnotesconstraints) |
||||
- [Risks and Mitigations](#risks-and-mitigations) |
||||
- [Alternatives](#alternatives) |
||||
- [Future Improvements](#future-improvements) |
||||
|
||||
## Summary |
||||
|
||||
Avoid repeated re-authentications when using password-based (sessionless) connectors by |
||||
storing a server-side (dex) session of the user login and re-use it instead. |
||||
|
||||
## Context |
||||
|
||||
https://github.com/dexidp/dex/issues/32 |
||||
|
||||
## Motivation |
||||
|
||||
### Goals/Pain |
||||
|
||||
- Minimal viable implementation of remember me functionality scoped to only password-based connectors |
||||
- If the same user is authenticating through dex using n>1 applications (clients) during a session (predefined timeframe), the user should not be prompted to log in again |
||||
- Avoid bad UX where each application (client) triggers a new login with the password connector |
||||
- Implement for the in-memory storage backend |
||||
|
||||
### Non-goals |
||||
|
||||
- Implement for any other storage backend |
||||
- Implement for any non-password connector |
||||
|
||||
## Proposal |
||||
|
||||
### User Experience |
||||
|
||||
- When the user logs in once using the password-based connector he is never prompted to login again until his session expires |
||||
- Once a session has been obtained the authflow is frictionless and mostly automatic |
||||
|
||||
### Implementation Details/Notes/Constraints |
||||
|
||||
- Implementation is in a separate package to separate concerns and keep code isolated |
||||
- Add new specific interface for storage to avoid bloating the already huge storage (`storage.Storage`) interface |
||||
- Each connector has a specific cookie to allow having more than one password-based connector (also for security purposes) |
||||
- Cookies are signed just as JWT are and verified each time to ensure authenticity |
||||
|
||||
Regular password-based connector flow but with active sessions (no session found case). |
||||
|
||||
```mermaid |
||||
sequenceDiagram |
||||
User->>+Client: Start Auth Flow |
||||
Client-->>-User: Redirect to dex |
||||
User-->>+Dex: Auth Flow |
||||
Dex->>+Dex: Check for Cookie and Session |
||||
Dex-->>-User: Redirect to Login Page |
||||
User->>+Dex: Send Credentials |
||||
Dex->>+Connector: Forward Credentials |
||||
Connector-->>-Dex: Return Identity |
||||
Dex->>+Dex: Persist Session |
||||
Dex -->>- User: Redirect to client |
||||
|
||||
``` |
||||
|
||||
Improved UX flow with active session (session found). |
||||
|
||||
```mermaid |
||||
sequenceDiagram |
||||
User->>+Client: Start Auth Flow |
||||
Client-->>-User: Redirect to dex |
||||
User-->>+Dex: Auth Flow |
||||
Dex->>+Dex: Check for Cookie and Session |
||||
Dex->>+Dex: Retrieve Session |
||||
Dex -->>- User: Redirect to client |
||||
``` |
||||
|
||||
### Risks and Mitigations |
||||
|
||||
- I am not absolutely sure whether this introduces any attack vectors that could be exploited. |
||||
- This DEP does not introduce any breaking changes. |
||||
|
||||
### Alternatives |
||||
|
||||
- None. We can declare this out of scope, but other than developing. |
||||
|
||||
## Future Improvements |
||||
|
||||
- None |
||||
@ -0,0 +1,56 @@
|
||||
package jwt |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
|
||||
"github.com/go-jose/go-jose/v4" |
||||
|
||||
"github.com/dexidp/dex/storage" |
||||
) |
||||
|
||||
var ErrFailedVerify = errors.New("failed to verify id token signature") |
||||
|
||||
// StorageKeySet implements the oidc.KeySet interface backed by Dex storage
|
||||
type StorageKeySet struct { |
||||
storage.Storage |
||||
} |
||||
|
||||
func NewStorageKeySet(store storage.Storage) *StorageKeySet { |
||||
return &StorageKeySet{ |
||||
store, |
||||
} |
||||
} |
||||
|
||||
func (s *StorageKeySet) VerifySignature(ctx context.Context, jwt string) (payload []byte, err error) { |
||||
jws, err := jose.ParseSigned(jwt, []jose.SignatureAlgorithm{jose.RS256, jose.RS384, jose.RS512, jose.ES256, jose.ES384, jose.ES512}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
keyID := "" |
||||
for _, sig := range jws.Signatures { |
||||
keyID = sig.Header.KeyID |
||||
break |
||||
} |
||||
|
||||
skeys, err := s.Storage.GetKeys(ctx) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
keys := []*jose.JSONWebKey{skeys.SigningKeyPub} |
||||
for _, vk := range skeys.VerificationKeys { |
||||
keys = append(keys, vk.PublicKey) |
||||
} |
||||
|
||||
for _, key := range keys { |
||||
if keyID == "" || key.KeyID == keyID { |
||||
if payload, err := jws.Verify(key); err == nil { |
||||
return payload, nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
return nil, ErrFailedVerify |
||||
} |
||||
@ -0,0 +1,59 @@
|
||||
package jwt |
||||
|
||||
import ( |
||||
"crypto/ecdsa" |
||||
"crypto/elliptic" |
||||
"crypto/rsa" |
||||
"errors" |
||||
"fmt" |
||||
|
||||
"github.com/go-jose/go-jose/v4" |
||||
) |
||||
|
||||
// Determine the signature algorithm for a JWT.
|
||||
func SignatureAlgorithm(jwk *jose.JSONWebKey) (alg jose.SignatureAlgorithm, err error) { |
||||
if jwk.Key == nil { |
||||
return alg, errors.New("no signing key") |
||||
} |
||||
switch key := jwk.Key.(type) { |
||||
case *rsa.PrivateKey: |
||||
// Because OIDC mandates that we support RS256, we always return that
|
||||
// value. In the future, we might want to make this configurable on a
|
||||
// per client basis. For example allowing PS256 or ECDSA variants.
|
||||
//
|
||||
// See https://github.com/dexidp/dex/issues/692
|
||||
return jose.RS256, nil |
||||
case *ecdsa.PrivateKey: |
||||
// We don't actually support ECDSA keys yet, but they're tested for
|
||||
// in case we want to in the future.
|
||||
//
|
||||
// These values are prescribed depending on the ECDSA key type. We
|
||||
// can't return different values.
|
||||
switch key.Params() { |
||||
case elliptic.P256().Params(): |
||||
return jose.ES256, nil |
||||
case elliptic.P384().Params(): |
||||
return jose.ES384, nil |
||||
case elliptic.P521().Params(): |
||||
return jose.ES512, nil |
||||
default: |
||||
return alg, errors.New("unsupported ecdsa curve") |
||||
} |
||||
default: |
||||
return alg, fmt.Errorf("unsupported signing key type %T", key) |
||||
} |
||||
} |
||||
|
||||
func SignPayload(key *jose.JSONWebKey, alg jose.SignatureAlgorithm, payload []byte) (jws string, err error) { |
||||
signingKey := jose.SigningKey{Key: key, Algorithm: alg} |
||||
|
||||
signer, err := jose.NewSigner(signingKey, &jose.SignerOptions{}) |
||||
if err != nil { |
||||
return "", fmt.Errorf("new signer: %v", err) |
||||
} |
||||
signature, err := signer.Sign(payload) |
||||
if err != nil { |
||||
return "", fmt.Errorf("signing payload: %v", err) |
||||
} |
||||
return signature.CompactSerialize() |
||||
} |
||||
@ -0,0 +1,187 @@
|
||||
package rememberme |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/sha3" |
||||
"errors" |
||||
"fmt" |
||||
"log/slog" |
||||
"net/http" |
||||
"time" |
||||
|
||||
"github.com/dexidp/dex/connector" |
||||
"github.com/dexidp/dex/internal/jwt" |
||||
"github.com/dexidp/dex/storage" |
||||
) |
||||
|
||||
const ACTIVE_SESSION_COOKIE_NAME = "dex_active_session_cookie" |
||||
|
||||
var emptySession = storage.ActiveSession{} |
||||
|
||||
type AuthContext struct { |
||||
connectorName string |
||||
identity *connector.Identity |
||||
configuredExpiryDuration time.Duration |
||||
} |
||||
|
||||
func NewAnonymousAuthContext(connectorName string, configuredExpiryDuration time.Duration) AuthContext { |
||||
return AuthContext{connectorName, nil, configuredExpiryDuration} |
||||
} |
||||
|
||||
func NewAuthContextWithIdentity(connectorName string, identity connector.Identity, configuredExpiryDuration time.Duration) AuthContext { |
||||
return AuthContext{connectorName, &identity, configuredExpiryDuration} |
||||
} |
||||
|
||||
type GetOrUnsetCookie struct { |
||||
cookie *http.Cookie |
||||
unset bool |
||||
} |
||||
|
||||
func (c GetOrUnsetCookie) Empty() bool { |
||||
return c.unset == false && c.cookie == nil |
||||
} |
||||
|
||||
func (c GetOrUnsetCookie) Get() (*http.Cookie, bool) { |
||||
// TODO(juf): would prefer to not return internal pointer
|
||||
return c.cookie, c.unset |
||||
} |
||||
|
||||
func RequestUnsetCookie(cookieName string) GetOrUnsetCookie { |
||||
return GetOrUnsetCookie{ |
||||
&http.Cookie{Name: cookieName, Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}, true, |
||||
} |
||||
} |
||||
|
||||
func RequestSetCookie(cookie http.Cookie) GetOrUnsetCookie { |
||||
return GetOrUnsetCookie{ |
||||
&cookie, false, |
||||
} |
||||
} |
||||
|
||||
type RememberMeCtx struct { |
||||
Session storage.ActiveSession |
||||
Cookie GetOrUnsetCookie |
||||
} |
||||
|
||||
func (ctx RememberMeCtx) IsValid() bool { |
||||
return ctx.Session.Expiry.After(time.Now()) |
||||
} |
||||
|
||||
// connector_cookie_name creates a string which is used to identify the cookie that matches the given connector.
|
||||
// The purpose is to avoid having one cookie for multiple providers where you only authenticate once and suddenly would have
|
||||
// access to other connectors.
|
||||
func connector_cookie_name(connName string) string { |
||||
return fmt.Sprintf("%s_%s", ACTIVE_SESSION_COOKIE_NAME, connName) |
||||
} |
||||
|
||||
// HandleRememberMe either retrieves or creates a Session based on the cookie for the respective connector present in the http.Request.
|
||||
// It is also responsible for issuing the unsetting / expiration of either an invalid or expired cookie.
|
||||
//
|
||||
// The current "design" of the cookie is a sha3 hash of the connector.Identity object as JWK signed payload.
|
||||
func HandleRememberMe(ctx context.Context, logger *slog.Logger, req *http.Request, data AuthContext, store storage.Storage, sessionStore storage.ActiveSessionStorage) (*RememberMeCtx, error) { |
||||
keys, err := store.GetKeys(ctx) |
||||
if err != nil { |
||||
logger.ErrorContext(req.Context(), "failed to get keys", "err", err) |
||||
return nil, err |
||||
} |
||||
signAlg, err := jwt.SignatureAlgorithm(keys.SigningKey) |
||||
if err != nil { |
||||
logger.ErrorContext(req.Context(), "failed to get signAlg", "err", err) |
||||
return nil, err |
||||
} |
||||
if val, found := extractCookie(req, data.connectorName); found { |
||||
cookieName := connector_cookie_name(data.connectorName) |
||||
logger.DebugContext(req.Context(), "returning user cookie found, checking for active session", "connectorName", data.connectorName) |
||||
keyset := jwt.NewStorageKeySet(store) |
||||
logger.DebugContext(req.Context(), "verifying cookie", "connectorName", data.connectorName) |
||||
_, err := keyset.VerifySignature(ctx, val) |
||||
if err != nil { |
||||
return &RememberMeCtx{ |
||||
Session: emptySession, |
||||
Cookie: RequestUnsetCookie(cookieName), |
||||
}, err |
||||
} |
||||
session, err := sessionStore.GetSession(ctx, val) |
||||
if err != nil { |
||||
if errors.Is(err, storage.ErrNotFound) { |
||||
return &RememberMeCtx{ |
||||
Session: session, |
||||
Cookie: RequestUnsetCookie(cookieName), |
||||
}, nil |
||||
} |
||||
logger.ErrorContext(req.Context(), "failed to get active session", "err", err, "connectorName", data.connectorName) |
||||
return nil, err |
||||
} |
||||
cookie := GetOrUnsetCookie{nil, false} |
||||
if session.Expiry.Before(time.Now()) { |
||||
logger.DebugContext(req.Context(), "session expired unsetting cookie", "connectorName", data.connectorName) |
||||
cookie = RequestUnsetCookie(cookieName) |
||||
} |
||||
return &RememberMeCtx{ |
||||
Session: session, |
||||
Cookie: cookie, |
||||
}, nil |
||||
} else { |
||||
if data.identity == nil { |
||||
logger.DebugContext(req.Context(), "identity is empty, returning early", "connectorName", data.connectorName) |
||||
return nil, storage.ErrNotFound |
||||
} |
||||
h := sha3.New512() |
||||
h.Write([]byte(data.identity.Email)) |
||||
for _, g := range data.identity.Groups { |
||||
h.Write([]byte(g)) |
||||
} |
||||
h.Write([]byte(data.identity.UserID)) |
||||
h.Write([]byte(data.identity.Username)) |
||||
h.Write([]byte(data.identity.PreferredUsername)) |
||||
hash := fmt.Sprintf("%x", h.Sum(nil)) |
||||
signedHash, err := jwt.SignPayload(keys.SigningKey, signAlg, []byte(hash)) |
||||
if err != nil { |
||||
logger.ErrorContext(req.Context(), "failed to get sign payload", "err", err, "connectorName", data.connectorName) |
||||
return nil, err |
||||
} |
||||
// TODO(juf): Double check what we need to persist and are given
|
||||
// in the context of whether we need to make an "auto-redirect"
|
||||
// Because technically we do not return the ID nor RefreshToken to the user
|
||||
// instead we redirect him back to the caller with an authCode
|
||||
session := storage.ActiveSession{ |
||||
Identity: *data.identity, // TODO(juf): Avoid nil pointer
|
||||
// TODO(juf): Think about changing to use Token IssuedAt date instead of now to have
|
||||
// alignment with the token
|
||||
Expiry: time.Now().Add(data.configuredExpiryDuration), |
||||
} |
||||
logger.DebugContext(req.Context(), "creating active session for user", "connectorName", data.connectorName) |
||||
if err := sessionStore.CreateSession(ctx, signedHash, session); err != nil { |
||||
logger.ErrorContext(req.Context(), "failed to store active session", "err", err, "connectorName", data.connectorName) |
||||
return nil, err |
||||
} |
||||
|
||||
return &RememberMeCtx{ |
||||
Session: session, |
||||
Cookie: RequestSetCookie(http.Cookie{ |
||||
Name: connector_cookie_name(data.connectorName), |
||||
Value: signedHash, |
||||
Path: "/", |
||||
Domain: "", // TODO(juf): Check if we need to set this
|
||||
Expires: session.Expiry, |
||||
MaxAge: int(time.Until(session.Expiry).Seconds()), |
||||
Secure: true, |
||||
HttpOnly: true, |
||||
SameSite: http.SameSiteStrictMode, |
||||
}), |
||||
}, nil |
||||
} |
||||
} |
||||
|
||||
func extractCookie(req *http.Request, connName string) (value string, found bool) { |
||||
cookies := req.Cookies() |
||||
if len(cookies) > 0 { |
||||
for _, ck := range cookies { |
||||
if ck.Name != connector_cookie_name(connName) { |
||||
continue |
||||
} |
||||
return ck.Value, true |
||||
} |
||||
} |
||||
return "", false |
||||
} |
||||
@ -0,0 +1,373 @@
|
||||
package rememberme |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/ecdsa" |
||||
"crypto/elliptic" |
||||
"crypto/rand" |
||||
"errors" |
||||
"log/slog" |
||||
"net/http" |
||||
"net/http/httptest" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/go-jose/go-jose/v4" |
||||
"github.com/stretchr/testify/require" |
||||
|
||||
"github.com/dexidp/dex/connector" |
||||
"github.com/dexidp/dex/storage" |
||||
"github.com/dexidp/dex/storage/memory" |
||||
) |
||||
|
||||
// setupTestEnvironment creates a realistic test environment following dex patterns
|
||||
func setupTestEnvironment(t *testing.T) (storage.Storage, storage.ActiveSessionStorage, *slog.Logger) { |
||||
logger := slog.New(slog.NewTextHandler(nil, &slog.HandlerOptions{Level: slog.LevelError})) |
||||
|
||||
// Use in-memory storage
|
||||
store := memory.New(logger) |
||||
sessionStore := memory.NewSessionStore(logger) |
||||
|
||||
// Initialize with real keys
|
||||
ctx := context.Background() |
||||
err := store.UpdateKeys(ctx, func(old storage.Keys) (storage.Keys, error) { |
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
||||
require.NoError(t, err) |
||||
|
||||
signingKey := &jose.JSONWebKey{Key: key} |
||||
signingKeyPub := &jose.JSONWebKey{Key: &key.PublicKey} |
||||
|
||||
return storage.Keys{ |
||||
SigningKey: signingKey, |
||||
SigningKeyPub: signingKeyPub, |
||||
}, nil |
||||
}) |
||||
require.NoError(t, err) |
||||
|
||||
return store, sessionStore, logger |
||||
} |
||||
|
||||
// createTestRequest creates an HTTP request with optional cookie
|
||||
func createTestRequest(connectorName string, cookieValue string) *http.Request { |
||||
req := httptest.NewRequest("GET", "/auth", nil) |
||||
if cookieValue != "" { |
||||
cookieName := connector_cookie_name(connectorName) |
||||
req.AddCookie(&http.Cookie{Name: cookieName, Value: cookieValue}) |
||||
} |
||||
return req |
||||
} |
||||
|
||||
// createTestIdentity creates a sample identity for testing
|
||||
func createTestIdentity() connector.Identity { |
||||
return connector.Identity{ |
||||
UserID: "user123", |
||||
Username: "testuser", |
||||
PreferredUsername: "testuser", |
||||
Email: "test@example.com", |
||||
EmailVerified: true, |
||||
Groups: []string{"group1", "group2"}, |
||||
} |
||||
} |
||||
|
||||
func TestHandleRememberMe_Integration(t *testing.T) { |
||||
store, sessionStore, logger := setupTestEnvironment(t) |
||||
ctx := context.Background() |
||||
connectorName := "test-connector" |
||||
expiryDuration := 24 * time.Hour |
||||
identity := createTestIdentity() |
||||
|
||||
tests := []struct { |
||||
name string |
||||
setup func() (*http.Request, AuthContext) |
||||
want func(t *testing.T, result *RememberMeCtx, err error) |
||||
}{ |
||||
{ |
||||
name: "no cookie with anonymous context returns ErrNotFound", |
||||
setup: func() (*http.Request, AuthContext) { |
||||
req := createTestRequest(connectorName, "") |
||||
authCtx := NewAnonymousAuthContext(connectorName, expiryDuration) |
||||
return req, authCtx |
||||
}, |
||||
want: func(t *testing.T, result *RememberMeCtx, err error) { |
||||
require.Error(t, err) |
||||
require.True(t, errors.Is(err, storage.ErrNotFound)) |
||||
require.Nil(t, result) |
||||
}, |
||||
}, |
||||
{ |
||||
name: "no cookie with identity creates new session and sets cookie", |
||||
setup: func() (*http.Request, AuthContext) { |
||||
req := createTestRequest(connectorName, "") |
||||
authCtx := NewAuthContextWithIdentity(connectorName, identity, expiryDuration) |
||||
return req, authCtx |
||||
}, |
||||
want: func(t *testing.T, result *RememberMeCtx, err error) { |
||||
require.NoError(t, err) |
||||
require.NotNil(t, result) |
||||
require.True(t, result.IsValid()) |
||||
|
||||
// Verify session details
|
||||
require.Equal(t, identity.UserID, result.Session.Identity.UserID) |
||||
require.Equal(t, identity.Email, result.Session.Identity.Email) |
||||
require.Equal(t, identity.Groups, result.Session.Identity.Groups) |
||||
require.True(t, result.Session.Expiry.After(time.Now())) |
||||
|
||||
// Verify cookie is set
|
||||
require.False(t, result.Cookie.Empty()) |
||||
cookie, unset := result.Cookie.Get() |
||||
require.False(t, unset) |
||||
require.Equal(t, connector_cookie_name(connectorName), cookie.Name) |
||||
require.NotEmpty(t, cookie.Value) |
||||
require.True(t, cookie.Secure) |
||||
require.True(t, cookie.HttpOnly) |
||||
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite) |
||||
|
||||
// Verify session was stored
|
||||
storedSession, err := sessionStore.GetSession(ctx, cookie.Value) |
||||
require.NoError(t, err) |
||||
require.Equal(t, identity.UserID, storedSession.Identity.UserID) |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.name, func(t *testing.T) { |
||||
req, authCtx := tt.setup() |
||||
result, err := HandleRememberMe(ctx, logger, req, authCtx, store, sessionStore) |
||||
tt.want(t, result, err) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
func TestHandleRememberMe_WithExistingSessions(t *testing.T) { |
||||
store, sessionStore, logger := setupTestEnvironment(t) |
||||
ctx := context.Background() |
||||
connectorName := "test-connector" |
||||
expiryDuration := 24 * time.Hour |
||||
identity := createTestIdentity() |
||||
|
||||
// First create a session to test retrieval
|
||||
req := createTestRequest(connectorName, "") |
||||
authCtx := NewAuthContextWithIdentity(connectorName, identity, expiryDuration) |
||||
initialResult, err := HandleRememberMe(ctx, logger, req, authCtx, store, sessionStore) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, initialResult) |
||||
|
||||
cookie, _ := initialResult.Cookie.Get() |
||||
cookieValue := cookie.Value |
||||
|
||||
t.Run("valid cookie with active session returns session without new cookie", func(t *testing.T) { |
||||
req := createTestRequest(connectorName, cookieValue) |
||||
authCtx := NewAnonymousAuthContext(connectorName, expiryDuration) |
||||
|
||||
result, err := HandleRememberMe(ctx, logger, req, authCtx, store, sessionStore) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, result) |
||||
require.True(t, result.IsValid()) |
||||
require.Equal(t, identity.UserID, result.Session.Identity.UserID) |
||||
require.True(t, result.Cookie.Empty()) // No cookie change needed
|
||||
}) |
||||
|
||||
t.Run("expired session unsets cookie", func(t *testing.T) { |
||||
// Create a fresh session store to avoid ID conflicts
|
||||
freshStore, freshSessionStore, freshLogger := setupTestEnvironment(t) |
||||
|
||||
expiredIdentity := connector.Identity{ |
||||
UserID: "expired-user", |
||||
Username: "expireduser", |
||||
Email: "expired@example.com", |
||||
Groups: []string{"expired-group"}, |
||||
} |
||||
|
||||
// Create an expired session directly with a known identifier
|
||||
expiredSession := storage.ActiveSession{ |
||||
Identity: expiredIdentity, |
||||
Expiry: time.Now().Add(-time.Hour), |
||||
} |
||||
|
||||
// Create the session using a predictable signed identifier
|
||||
// First get a signed identifier by creating a valid session
|
||||
tempReq := createTestRequest(connectorName, "") |
||||
tempAuthCtx := NewAuthContextWithIdentity(connectorName, expiredIdentity, time.Hour) // short duration
|
||||
tempResult, err := HandleRememberMe(ctx, freshLogger, tempReq, tempAuthCtx, freshStore, freshSessionStore) |
||||
require.NoError(t, err) |
||||
|
||||
tempCookie, _ := tempResult.Cookie.Get() |
||||
sessionID := tempCookie.Value |
||||
|
||||
// Wait a moment and directly update the session in storage to be expired
|
||||
// by creating a new session store and directly setting expired session
|
||||
testSessionStore := memory.NewSessionStore(freshLogger) |
||||
err = testSessionStore.CreateSession(ctx, sessionID, expiredSession) |
||||
require.NoError(t, err) |
||||
|
||||
// Test with the expired session
|
||||
req := createTestRequest(connectorName, sessionID) |
||||
authCtx := NewAnonymousAuthContext(connectorName, expiryDuration) |
||||
result, err := HandleRememberMe(ctx, freshLogger, req, authCtx, freshStore, testSessionStore) |
||||
|
||||
require.NoError(t, err) |
||||
require.NotNil(t, result) |
||||
require.False(t, result.IsValid()) |
||||
|
||||
// Cookie should be unset
|
||||
require.False(t, result.Cookie.Empty()) |
||||
resultCookie, unset := result.Cookie.Get() |
||||
require.True(t, unset) |
||||
require.Equal(t, -1, resultCookie.MaxAge) |
||||
}) |
||||
|
||||
t.Run("invalid cookie signature unsets cookie", func(t *testing.T) { |
||||
// Use an invalid JWT format
|
||||
invalidCookie := "invalid.jwt.signature" |
||||
req := createTestRequest(connectorName, invalidCookie) |
||||
authCtx := NewAnonymousAuthContext(connectorName, expiryDuration) |
||||
|
||||
result, err := HandleRememberMe(ctx, logger, req, authCtx, store, sessionStore) |
||||
require.Error(t, err) |
||||
require.NotNil(t, result) |
||||
require.False(t, result.IsValid()) |
||||
|
||||
// Cookie should be unset
|
||||
require.False(t, result.Cookie.Empty()) |
||||
cookie, unset := result.Cookie.Get() |
||||
require.True(t, unset) |
||||
require.Equal(t, connector_cookie_name(connectorName), cookie.Name) |
||||
}) |
||||
} |
||||
|
||||
func TestHandleRememberMe_EndToEndWorkflow(t *testing.T) { |
||||
store, sessionStore, logger := setupTestEnvironment(t) |
||||
ctx := context.Background() |
||||
connectorName := "test-connector" |
||||
expiryDuration := 24 * time.Hour |
||||
identity := createTestIdentity() |
||||
|
||||
t.Run("complete login workflow", func(t *testing.T) { |
||||
// Step 1: Initial login - no cookie present
|
||||
req1 := createTestRequest(connectorName, "") |
||||
authCtx1 := NewAuthContextWithIdentity(connectorName, identity, expiryDuration) |
||||
|
||||
result1, err := HandleRememberMe(ctx, logger, req1, authCtx1, store, sessionStore) |
||||
require.NoError(t, err) |
||||
require.True(t, result1.IsValid()) |
||||
|
||||
cookie1, unset1 := result1.Cookie.Get() |
||||
require.False(t, unset1) |
||||
require.NotEmpty(t, cookie1.Value) |
||||
|
||||
// Step 2: Return visit with cookie - should recognize user
|
||||
req2 := createTestRequest(connectorName, cookie1.Value) |
||||
authCtx2 := NewAnonymousAuthContext(connectorName, expiryDuration) |
||||
|
||||
result2, err := HandleRememberMe(ctx, logger, req2, authCtx2, store, sessionStore) |
||||
require.NoError(t, err) |
||||
require.True(t, result2.IsValid()) |
||||
require.Equal(t, identity.UserID, result2.Session.Identity.UserID) |
||||
require.True(t, result2.Cookie.Empty()) // No cookie change needed
|
||||
|
||||
// Step 3: Verify session consistency
|
||||
require.Equal(t, result1.Session.Identity.UserID, result2.Session.Identity.UserID) |
||||
require.Equal(t, result1.Session.Identity.Email, result2.Session.Identity.Email) |
||||
}) |
||||
|
||||
t.Run("multiple connectors isolation", func(t *testing.T) { |
||||
connector1 := "connector-1" |
||||
connector2 := "connector-2" |
||||
|
||||
// Create session for connector1
|
||||
req1 := createTestRequest(connector1, "") |
||||
authCtx1 := NewAuthContextWithIdentity(connector1, identity, expiryDuration) |
||||
result1, err := HandleRememberMe(ctx, logger, req1, authCtx1, store, sessionStore) |
||||
require.NoError(t, err) |
||||
|
||||
cookie1, _ := result1.Cookie.Get() |
||||
|
||||
// Create a request for connector2 but with connector1's cookie
|
||||
// This simulates having both cookies in the browser
|
||||
req2 := httptest.NewRequest("GET", "/auth", nil) |
||||
req2.AddCookie(cookie1) // connector1's cookie
|
||||
|
||||
// Since connector2 looks for its own cookie name, it won't find connector1's cookie
|
||||
authCtx2 := NewAnonymousAuthContext(connector2, expiryDuration) |
||||
result2, err := HandleRememberMe(ctx, logger, req2, authCtx2, store, sessionStore) |
||||
require.Error(t, err) |
||||
require.True(t, errors.Is(err, storage.ErrNotFound)) |
||||
require.Nil(t, result2) |
||||
}) |
||||
} |
||||
|
||||
func TestHandleRememberMe_ErrorHandling(t *testing.T) { |
||||
store, sessionStore, logger := setupTestEnvironment(t) |
||||
ctx := context.Background() |
||||
connectorName := "test-connector" |
||||
expiryDuration := 24 * time.Hour |
||||
identity := createTestIdentity() |
||||
|
||||
t.Run("session not found after valid signature verification", func(t *testing.T) { |
||||
// Create a session first to get a valid signed cookie
|
||||
req := createTestRequest(connectorName, "") |
||||
authCtx := NewAuthContextWithIdentity(connectorName, identity, expiryDuration) |
||||
result, err := HandleRememberMe(ctx, logger, req, authCtx, store, sessionStore) |
||||
require.NoError(t, err) |
||||
|
||||
cookie, _ := result.Cookie.Get() |
||||
|
||||
// Create a different session store that doesn't have this session
|
||||
emptySessionStore := memory.NewSessionStore(logger) |
||||
|
||||
// Try to retrieve with valid cookie but empty session store
|
||||
req2 := createTestRequest(connectorName, cookie.Value) |
||||
authCtx2 := NewAnonymousAuthContext(connectorName, expiryDuration) |
||||
result2, err := HandleRememberMe(ctx, logger, req2, authCtx2, store, emptySessionStore) |
||||
|
||||
require.NoError(t, err) |
||||
require.NotNil(t, result2) |
||||
require.False(t, result2.IsValid()) |
||||
|
||||
// Should unset cookie when session not found
|
||||
require.False(t, result2.Cookie.Empty()) |
||||
unsetCookie, unset := result2.Cookie.Get() |
||||
require.True(t, unset) |
||||
require.Equal(t, connector_cookie_name(connectorName), unsetCookie.Name) |
||||
}) |
||||
} |
||||
|
||||
func TestExtractCookie(t *testing.T) { |
||||
connectorName := "test-connector" |
||||
|
||||
t.Run("cookie present", func(t *testing.T) { |
||||
req := httptest.NewRequest("GET", "/auth", nil) |
||||
req.AddCookie(&http.Cookie{Name: connector_cookie_name(connectorName), Value: "test-value"}) |
||||
req.AddCookie(&http.Cookie{Name: "other-cookie", Value: "ignored"}) |
||||
|
||||
value, found := extractCookie(req, connectorName) |
||||
require.True(t, found) |
||||
require.Equal(t, "test-value", value) |
||||
}) |
||||
|
||||
t.Run("cookie not present", func(t *testing.T) { |
||||
req := httptest.NewRequest("GET", "/auth", nil) |
||||
value, found := extractCookie(req, connectorName) |
||||
require.False(t, found) |
||||
require.Equal(t, "", value) |
||||
}) |
||||
} |
||||
|
||||
func TestConnectorCookieName(t *testing.T) { |
||||
tests := []struct { |
||||
connector string |
||||
expected string |
||||
}{ |
||||
{"test", "dex_active_session_cookie_test"}, |
||||
{"google", "dex_active_session_cookie_google"}, // just an example, google would not be use-case for this feature
|
||||
{"ldap-local", "dex_active_session_cookie_ldap-local"}, |
||||
} |
||||
|
||||
for _, tt := range tests { |
||||
t.Run(tt.connector, func(t *testing.T) { |
||||
result := connector_cookie_name(tt.connector) |
||||
require.Equal(t, tt.expected, result) |
||||
}) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue