feat: add SSO sharing policy (#4705)

Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
This commit is contained in:
Maksim Nabokikh
2026-04-02 14:18:53 +02:00
committed by GitHub
parent 546e66cb5d
commit 3bf25fd6e0
27 changed files with 1235 additions and 287 deletions
+295 -264
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -15,6 +15,7 @@ message Client {
string name = 6;
string logo_url = 7;
repeated string allowed_connectors = 8;
repeated string sso_shared_with = 9;
}
// ClientInfo represents an OAuth2 client without sensitive information.
@@ -26,6 +27,7 @@ message ClientInfo {
string name = 5;
string logo_url = 6;
repeated string allowed_connectors = 7;
repeated string sso_shared_with = 8;
}
// GetClientReq is a request to retrieve client details.
@@ -69,6 +71,7 @@ message UpdateClientReq {
string name = 4;
string logo_url = 5;
repeated string allowed_connectors = 6;
repeated string sso_shared_with = 7;
}
// UpdateClientResp returns the response from updating a client.
+3
View File
@@ -687,6 +687,9 @@ type Sessions struct {
// 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"`
// SSOSharedWithDefault is the default SSO sharing policy for clients without explicit ssoSharedWith.
// "all" = share with all clients, "none" = share with no one (default: "none").
SSOSharedWithDefault string `json:"ssoSharedWithDefault"`
}
// MFAAuthenticator defines a multi-factor authentication provider.
+9
View File
@@ -807,6 +807,9 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) {
if s.CookieEncryptionKey != "" {
sc.CookieEncryptionKey = []byte(s.CookieEncryptionKey)
}
if s.SSOSharedWithDefault != "" {
sc.SSOSharedWithDefault = s.SSOSharedWithDefault
}
}
if sc.AbsoluteLifetime <= 0 {
return nil, fmt.Errorf("absoluteLifetime must be positive, got %v", sc.AbsoluteLifetime)
@@ -820,6 +823,12 @@ func parseSessionConfig(s *Sessions) (*server.SessionConfig, error) {
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)
}
switch sc.SSOSharedWithDefault {
case "", "none", "all":
// valid
default:
return nil, fmt.Errorf("ssoSharedWithDefault must be \"none\" or \"all\", got %q", sc.SSOSharedWithDefault)
}
return sc, nil
}
+27
View File
@@ -101,6 +101,20 @@ web:
# keysRotationPeriod: "6h"
# algorithm: "RS256" # supported values: "RS256" (default) and "ES256"; changes apply on the next key rotation
# Authentication sessions configuration.
# Requires DEX_SESSIONS_ENABLED=true feature flag.
# sessions:
# cookieName: "dex_session"
# absoluteLifetime: "24h"
# validIfNotUsedFor: "1h"
# rememberMeCheckedByDefault: false
# # AES key for encrypting session cookies. Must be 16, 24, or 32 bytes.
# # If empty, cookies are not encrypted.
# cookieEncryptionKey: ""
# # Default SSO sharing policy for clients without explicit ssoSharedWith.
# # "all" = share with all clients (Keycloak-like), "none" = no sharing (default).
# ssoSharedWithDefault: "none"
# OAuth2 configuration
# oauth2:
# # use ["code", "token", "id_token"] to enable implicit flow for web-only clients
@@ -159,6 +173,19 @@ web:
# allowedConnectors:
# - github
# - google
#
# # Example of SSO sharing between clients.
# # ssoSharedWith defines which other clients can reuse this client's session.
# # ["*"] = share with all, [] = share with no one.
# # If omitted, ssoSharedWithDefault from sessions config is used.
# - id: portal-app
# secret: portal-secret
# redirectURIs:
# - 'https://portal.example.com/callback'
# name: 'Portal'
# ssoSharedWith:
# - "dashboard-app"
# - "admin-app"
# Connectors are used to authenticate users against upstream identity providers.
#
+8
View File
@@ -102,6 +102,9 @@ telemetry:
# absoluteLifetime: "24h"
# validIfNotUsedFor: "1h"
# rememberMeCheckedByDefault: false
# # Default SSO sharing policy for clients without explicit ssoSharedWith.
# # "all" = share with all clients (Keycloak-like), "none" = no sharing (default).
# ssoSharedWithDefault: "none"
# Options for controlling the logger.
# logger:
@@ -187,6 +190,11 @@ staticClients:
# If omitted, mfa.defaultMFAChain is used.
# mfaChain:
# - totp-1
# Optional: which other clients can reuse this client's authentication session (SSO).
# ["*"] = share with all clients, [] = share with no one.
# If omitted, ssoSharedWithDefault from sessions config is used.
# ssoSharedWith:
# - "*"
# Example using environment variables
# Set DEX_CLIENT_ID and DEX_SECURE_CLIENT_SECRET before starting Dex
+6
View File
@@ -66,6 +66,7 @@ func (d dexAPI) GetClient(ctx context.Context, req *api.GetClientReq) (*api.GetC
Public: c.Public,
LogoUrl: c.LogoURL,
AllowedConnectors: c.AllowedConnectors,
SsoSharedWith: c.SSOSharedWith,
},
}, nil
}
@@ -91,6 +92,7 @@ func (d dexAPI) CreateClient(ctx context.Context, req *api.CreateClientReq) (*ap
Name: req.Client.Name,
LogoURL: req.Client.LogoUrl,
AllowedConnectors: req.Client.AllowedConnectors,
SSOSharedWith: req.Client.SsoSharedWith,
}
if err := d.s.CreateClient(ctx, c); err != nil {
if err == storage.ErrAlreadyExists {
@@ -126,6 +128,9 @@ func (d dexAPI) UpdateClient(ctx context.Context, req *api.UpdateClientReq) (*ap
if req.AllowedConnectors != nil {
old.AllowedConnectors = req.AllowedConnectors
}
if req.SsoSharedWith != nil {
old.SSOSharedWith = req.SsoSharedWith
}
return old, nil
})
if err != nil {
@@ -167,6 +172,7 @@ func (d dexAPI) ListClients(ctx context.Context, req *api.ListClientReq) (*api.L
Public: client.Public,
LogoUrl: client.LogoURL,
AllowedConnectors: client.AllowedConnectors,
SsoSharedWith: client.SSOSharedWith,
}
clients = append(clients, &c)
}
+1
View File
@@ -246,6 +246,7 @@ func (s *Server) handleAuthorization(w http.ResponseWriter, r *http.Request) {
default:
panic("unsupported error type")
}
return
}
prompt, err := ParsePrompt(authReq.Prompt)
if err != nil {
+24
View File
@@ -1604,6 +1604,30 @@ func TestHandleAuthorizationConnectorGrantTypeFiltering(t *testing.T) {
}
}
func TestHandleAuthorizationInvalidRequestWithSessions(t *testing.T) {
ctx := t.Context()
httpServer, s := newTestServerMultipleConnectors(t, func(c *Config) {
c.SessionConfig = &SessionConfig{
CookieName: "dex_session",
AbsoluteLifetime: 24 * time.Hour,
ValidIfNotUsedFor: 1 * time.Hour,
}
c.Storage.CreateClient(ctx, storage.Client{
ID: "test",
RedirectURIs: []string{"http://example.com/callback"},
})
})
defer httpServer.Close()
// Send a request with an unregistered redirect_uri — should not panic.
rr := httptest.NewRecorder()
reqURL := fmt.Sprintf("%s/auth?response_type=code&client_id=test&redirect_uri=http://evil.com/callback&scope=openid", httpServer.URL)
req := httptest.NewRequest(http.MethodGet, reqURL, nil)
s.handleAuthorization(rr, req)
require.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestHandleConnectorLoginGrantTypeRejection(t *testing.T) {
ctx := t.Context()
httpServer, s := newTestServer(t, func(c *Config) {
+3
View File
@@ -156,6 +156,9 @@ type SessionConfig struct {
AbsoluteLifetime time.Duration
ValidIfNotUsedFor time.Duration
RememberMeCheckedByDefault bool
// SSOSharedWithDefault is the default SSO sharing policy for clients without explicit SSOSharedWith.
// "all" = share with all clients, "none" or "" = share with no one (default).
SSOSharedWithDefault string
}
// WebConfig holds the server's frontend templates and asset configuration.
+108 -11
View File
@@ -306,6 +306,65 @@ func (s *Server) trySessionLogin(ctx context.Context, r *http.Request, w http.Re
return s.trySessionLoginWithSession(ctx, r, w, authReq, session)
}
// clientSharesSessionWith checks if sourceClient shares its session with targetClientID.
// SSO sharing is unidirectional: source sharing with target does NOT mean target shares with source.
func (s *Server) clientSharesSessionWith(sourceClient storage.Client, targetClientID string) bool {
ssoSharedWith := sourceClient.SSOSharedWith
// If client has no explicit ssoSharedWith, use default from session config.
if ssoSharedWith == nil {
switch s.sessionConfig.SSOSharedWithDefault {
case "all":
return true
default: // "none" or ""
return false
}
}
// Explicit empty slice means share with no one.
if len(ssoSharedWith) == 0 {
return false
}
for _, peer := range ssoSharedWith {
if peer == "*" || peer == targetClientID {
return true
}
}
return false
}
// findSSOSession checks whether any active client in the session shares its
// authentication with targetClientID via the ssoSharedWith policy.
//
// Note: the caller already has the target client loaded (for AllowedConnectors
// validation), but here we need the *source* client configs - those are the
// clients the user previously authenticated for, and their ssoSharedWith
// policies determine whether SSO is allowed. These are different clients,
// so the GetClient calls below are not redundant.
func (s *Server) findSSOSession(ctx context.Context, session *storage.AuthSession, targetClientID string) *storage.ClientAuthState {
now := s.now()
for sourceClientID, state := range session.ClientStates {
if !state.Active || now.After(state.ExpiresAt) {
continue
}
sourceClient, err := s.storage.GetClient(ctx, sourceClientID)
if err != nil {
s.logger.DebugContext(ctx, "session: SSO lookup failed to get source client",
"source_client_id", sourceClientID, "err", err)
continue
}
if s.clientSharesSessionWith(sourceClient, targetClientID) {
return state
}
}
return nil
}
// trySessionLoginWithSession is like trySessionLogin but accepts a pre-retrieved session.
// This allows callers to inspect the session (e.g., for id_token_hint comparison) before
// attempting session-based login.
@@ -314,17 +373,47 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request
return "", false
}
clientState, ok := session.ClientStates[authReq.ClientID]
if !ok || !clientState.Active {
return "", false
}
now := s.now()
if now.After(clientState.ExpiresAt) {
return "", false
clientState, ok := session.ClientStates[authReq.ClientID]
fallbackToSSO := !ok || !clientState.Active || now.After(clientState.ExpiresAt)
if fallbackToSSO {
// No direct session for this client — try SSO from a sharing client.
sourceState := s.findSSOSession(ctx, session, authReq.ClientID)
if sourceState == nil {
return "", false
}
// Cap the derived state expiry at min(configured lifetime, source state expiry).
expiresAt := now.Add(s.sessionConfig.AbsoluteLifetime)
if sourceState.ExpiresAt.Before(expiresAt) {
expiresAt = sourceState.ExpiresAt
}
// Create a new client state for the target client via SSO.
if err := s.storage.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) {
if old.ClientStates == nil {
old.ClientStates = make(map[string]*storage.ClientAuthState)
}
old.ClientStates[authReq.ClientID] = &storage.ClientAuthState{
Active: true,
ExpiresAt: expiresAt,
LastActivity: now,
}
old.LastActivity = now
old.IdleExpiry = now.Add(s.sessionConfig.ValidIfNotUsedFor)
return old, nil
}); err != nil {
s.logger.ErrorContext(ctx, "session: failed to create SSO client state", "err", err)
return "", false
}
s.logger.DebugContext(ctx, "session: SSO login from sharing client",
"user_id", session.UserID, "connector_id", session.ConnectorID, "client_id", authReq.ClientID)
}
// Load identity from storage.
// Load identity from storage (same path for direct and SSO login).
ui, err := s.storage.GetUserIdentity(ctx, session.UserID, session.ConnectorID)
if err != nil {
s.logger.ErrorContext(ctx, "session: failed to get user identity", "err", err)
@@ -338,6 +427,17 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request
}
}
if !fallbackToSSO {
s.logger.DebugContext(ctx, "session: re-authenticated from session",
"user_id", session.UserID, "connector_id", session.ConnectorID)
}
return s.finishSessionLogin(ctx, r, w, authReq, session, &ui, now)
}
// finishSessionLogin completes a session-based login (direct or SSO) by updating the auth request
// with the user's identity, refreshing session activity, and returning the appropriate redirect URL.
func (s *Server) finishSessionLogin(ctx context.Context, r *http.Request, w http.ResponseWriter, authReq *storage.AuthRequest, session *storage.AuthSession, ui *storage.UserIdentity, now time.Time) (string, bool) {
claims := storage.Claims{
UserID: ui.Claims.UserID,
Username: ui.Claims.Username,
@@ -359,9 +459,6 @@ func (s *Server) trySessionLoginWithSession(ctx context.Context, r *http.Request
return "", false
}
s.logger.DebugContext(ctx, "session: re-authenticated from session",
"user_id", session.UserID, "connector_id", session.ConnectorID)
// Update session activity.
_ = s.storage.UpdateAuthSession(ctx, session.UserID, session.ConnectorID, func(old storage.AuthSession) (storage.AuthSession, error) {
old.LastActivity = now
+486
View File
@@ -869,3 +869,489 @@ func TestParseAuthRequest_PromptAndMaxAge(t *testing.T) {
assert.Equal(t, -1, authReq.MaxAge)
})
}
func TestClientSharesSessionWith(t *testing.T) {
tests := []struct {
name string
ssoSharedWith []string
defaultPolicy string
targetClientID string
want bool
}{
{
name: "nil uses default none",
ssoSharedWith: nil,
defaultPolicy: "none",
targetClientID: "client-b",
want: false,
},
{
name: "nil uses default all",
ssoSharedWith: nil,
defaultPolicy: "all",
targetClientID: "client-b",
want: true,
},
{
name: "nil with empty default",
ssoSharedWith: nil,
defaultPolicy: "",
targetClientID: "client-b",
want: false,
},
{
name: "empty slice means no sharing",
ssoSharedWith: []string{},
defaultPolicy: "all",
targetClientID: "client-b",
want: false,
},
{
name: "wildcard shares with everyone",
ssoSharedWith: []string{"*"},
defaultPolicy: "none",
targetClientID: "any-client",
want: true,
},
{
name: "explicit match",
ssoSharedWith: []string{"client-b", "client-c"},
defaultPolicy: "none",
targetClientID: "client-b",
want: true,
},
{
name: "no match in list",
ssoSharedWith: []string{"client-b", "client-c"},
defaultPolicy: "none",
targetClientID: "client-d",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestSessionServer(t)
s.sessionConfig.SSOSharedWithDefault = tt.defaultPolicy
client := storage.Client{
ID: "source-client",
SSOSharedWith: tt.ssoSharedWith,
}
got := s.clientSharesSessionWith(client, tt.targetClientID)
assert.Equal(t, tt.want, got)
})
}
}
func TestFindSSOSession(t *testing.T) {
ctx := t.Context()
t.Run("finds SSO session from sharing client", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-b"},
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.NotNil(t, s.findSSOSession(ctx, session, "client-b"))
})
t.Run("no SSO when client does not share", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-c"}, // Does not share with client-b
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.Nil(t, s.findSSOSession(ctx, session, "client-b"))
})
t.Run("skips inactive client states", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"*"},
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: false, // Inactive
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.Nil(t, s.findSSOSession(ctx, session, "client-b"))
})
t.Run("skips expired client states", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"*"},
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(-1 * time.Hour), // Expired
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.Nil(t, s.findSSOSession(ctx, session, "client-b"))
})
t.Run("wildcard SSO with default all", func(t *testing.T) {
s := newTestSessionServer(t)
s.sessionConfig.SSOSharedWithDefault = "all"
now := s.now()
// Client with nil SSOSharedWith — uses default "all"
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
// SSOSharedWith is nil → uses ssoSharedWithDefault="all"
}))
session := &storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-5 * time.Minute),
},
},
}
assert.NotNil(t, s.findSSOSession(ctx, session, "client-b"))
})
}
func TestTrySessionLogin_SSO(t *testing.T) {
ctx := t.Context()
t.Run("SSO login from sharing client", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
now := s.now()
// Create source client that shares with target
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-b"},
}))
// Create session with client-a authenticated
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{
UserID: "user-1",
Username: "testuser",
Email: "test@example.com",
},
Consents: map[string][]string{"client-b": {"openid", "email"}},
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: now.Add(-30 * time.Minute),
}))
// Auth request for client-b (not directly in session)
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-b",
ConnectorID: "mock",
Scopes: []string{"openid", "email"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok, "SSO login should succeed")
// Verify client-b state was created in session
updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
assert.Contains(t, updated.ClientStates, "client-b")
assert.True(t, updated.ClientStates["client-b"].Active)
})
t.Run("SSO derived state capped by source expiry", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-b"},
}))
// Source state expires in 1 hour — less than AbsoluteLifetime (24h).
sourceExpiry := now.Add(1 * time.Hour)
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: sourceExpiry,
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{
UserID: "user-1",
Username: "testuser",
Email: "test@example.com",
},
Consents: map[string][]string{"client-b": {"openid", "email"}},
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-b",
ConnectorID: "mock",
Scopes: []string{"openid", "email"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok, "SSO login should succeed")
updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
require.Contains(t, updated.ClientStates, "client-b")
assert.Equal(t, sourceExpiry, updated.ClientStates["client-b"].ExpiresAt,
"derived state expiry should be capped at source state expiry")
})
t.Run("SSO derived state uses configured lifetime when source expires later", func(t *testing.T) {
s := newTestSessionServer(t)
s.skipApproval = true
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{"client-b"},
}))
// Source state expires in 48 hours — more than AbsoluteLifetime (24h).
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(48 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
require.NoError(t, s.storage.CreateUserIdentity(ctx, storage.UserIdentity{
UserID: "user-1",
ConnectorID: "mock",
Claims: storage.Claims{
UserID: "user-1",
Username: "testuser",
Email: "test@example.com",
},
Consents: map[string][]string{"client-b": {"openid", "email"}},
CreatedAt: now.Add(-1 * time.Hour),
LastLogin: now.Add(-30 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-b",
ConnectorID: "mock",
Scopes: []string{"openid", "email"},
RedirectURI: "http://localhost/callback",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.True(t, ok, "SSO login should succeed")
updated, err := s.storage.GetAuthSession(ctx, "user-1", "mock")
require.NoError(t, err)
require.Contains(t, updated.ClientStates, "client-b")
assert.Equal(t, now.Add(s.sessionConfig.AbsoluteLifetime), updated.ClientStates["client-b"].ExpiresAt,
"derived state expiry should use configured AbsoluteLifetime when source expires later")
})
t.Run("no SSO when client does not share", func(t *testing.T) {
s := newTestSessionServer(t)
now := s.now()
require.NoError(t, s.storage.CreateClient(ctx, storage.Client{
ID: "client-a",
Secret: "secret",
Name: "Client A",
SSOSharedWith: []string{}, // Shares with nobody
}))
require.NoError(t, s.storage.CreateAuthSession(ctx, storage.AuthSession{
UserID: "user-1",
ConnectorID: "mock",
Nonce: "test-nonce",
ClientStates: map[string]*storage.ClientAuthState{
"client-a": {
Active: true,
ExpiresAt: now.Add(24 * time.Hour),
LastActivity: now.Add(-1 * time.Minute),
},
},
CreatedAt: now.Add(-30 * time.Minute),
LastActivity: now.Add(-1 * time.Minute),
IPAddress: "127.0.0.1",
UserAgent: "test",
AbsoluteExpiry: now.Add(24 * time.Hour),
IdleExpiry: now.Add(59 * time.Minute),
}))
authReq := storage.AuthRequest{
ID: storage.NewID(),
ClientID: "client-b",
ConnectorID: "mock",
MaxAge: -1,
HMACKey: storage.NewHMACKey(crypto.SHA256),
Expiry: now.Add(10 * time.Minute),
}
require.NoError(t, s.storage.CreateAuthRequest(ctx, authReq))
r := sessionCookieRequest("user-1", "mock", "test-nonce")
w := httptest.NewRecorder()
session := s.getValidAuthSession(ctx, w, r, &authReq)
require.NotNil(t, session)
_, ok := s.trySessionLoginWithSession(ctx, r, w, &authReq, session)
assert.False(t, ok, "SSO login should fail when client does not share")
})
}
+35
View File
@@ -369,6 +369,41 @@ func testClientCRUD(t *testing.T, s storage.Storage) {
c1.Secret = newSecret
getAndCompare(id1, c1)
// Verify SSOSharedWith nil vs empty slice roundtrip.
err = s.UpdateClient(ctx, id1, func(old storage.Client) (storage.Client, error) {
old.SSOSharedWith = []string{}
return old, nil
})
if err != nil {
t.Fatalf("update client ssoSharedWith to empty: %v", err)
}
gc, err := s.GetClient(ctx, id1)
if err != nil {
t.Fatalf("get client: %v", err)
}
if gc.SSOSharedWith == nil {
t.Error("expected empty slice for SSOSharedWith, got nil")
}
if len(gc.SSOSharedWith) != 0 {
t.Errorf("expected empty SSOSharedWith, got %v", gc.SSOSharedWith)
}
// Verify nil SSOSharedWith stays nil after roundtrip.
err = s.UpdateClient(ctx, id1, func(old storage.Client) (storage.Client, error) {
old.SSOSharedWith = nil
return old, nil
})
if err != nil {
t.Fatalf("update client ssoSharedWith to nil: %v", err)
}
gc, err = s.GetClient(ctx, id1)
if err != nil {
t.Fatalf("get client: %v", err)
}
if gc.SSOSharedWith != nil {
t.Errorf("expected nil SSOSharedWith, got %v", gc.SSOSharedWith)
}
if err := s.DeleteClient(ctx, id1); err != nil {
t.Fatalf("delete client: %v", err)
}
+2
View File
@@ -19,6 +19,7 @@ func (d *Database) CreateClient(ctx context.Context, client storage.Client) erro
SetAllowedConnectors(client.AllowedConnectors).
SetMfaChain(client.MFAChain).
SetPostLogoutRedirectUris(client.PostLogoutRedirectURIs).
SetSSOSharedWith(client.SSOSharedWith).
Save(ctx)
if err != nil {
return convertDBError("create oauth2 client: %w", err)
@@ -85,6 +86,7 @@ func (d *Database) UpdateClient(ctx context.Context, id string, updater func(old
SetAllowedConnectors(newClient.AllowedConnectors).
SetMfaChain(newClient.MFAChain).
SetPostLogoutRedirectUris(newClient.PostLogoutRedirectURIs).
SetSSOSharedWith(newClient.SSOSharedWith).
Save(ctx)
if err != nil {
return rollback(tx, "update client uploading: %w", err)
+1
View File
@@ -97,6 +97,7 @@ func toStorageClient(c *db.OAuth2Client) storage.Client {
AllowedConnectors: c.AllowedConnectors,
MFAChain: c.MfaChain,
PostLogoutRedirectURIs: c.PostLogoutRedirectUris,
SSOSharedWith: c.SSOSharedWith,
}
}
+1
View File
@@ -164,6 +164,7 @@ var (
{Name: "allowed_connectors", Type: field.TypeJSON, Nullable: true},
{Name: "mfa_chain", Type: field.TypeJSON, Nullable: true},
{Name: "post_logout_redirect_uris", Type: field.TypeJSON, Nullable: true},
{Name: "sso_shared_with", Type: field.TypeJSON, Nullable: true},
}
// Oauth2clientsTable holds the schema information for the "oauth2clients" table.
Oauth2clientsTable = &schema.Table{
+91 -1
View File
@@ -6470,6 +6470,8 @@ type OAuth2ClientMutation struct {
appendmfa_chain []string
post_logout_redirect_uris *[]string
appendpost_logout_redirect_uris []string
sso_shared_with *[]string
appendsso_shared_with []string
clearedFields map[string]struct{}
done bool
oldValue func(context.Context) (*OAuth2Client, error)
@@ -7049,6 +7051,71 @@ func (m *OAuth2ClientMutation) ResetPostLogoutRedirectUris() {
delete(m.clearedFields, oauth2client.FieldPostLogoutRedirectUris)
}
// SetSSOSharedWith sets the "sso_shared_with" field.
func (m *OAuth2ClientMutation) SetSSOSharedWith(s []string) {
m.sso_shared_with = &s
m.appendsso_shared_with = nil
}
// SSOSharedWith returns the value of the "sso_shared_with" field in the mutation.
func (m *OAuth2ClientMutation) SSOSharedWith() (r []string, exists bool) {
v := m.sso_shared_with
if v == nil {
return
}
return *v, true
}
// OldSSOSharedWith returns the old "sso_shared_with" field's value of the OAuth2Client entity.
// If the OAuth2Client object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *OAuth2ClientMutation) OldSSOSharedWith(ctx context.Context) (v []string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldSSOSharedWith is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldSSOSharedWith requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldSSOSharedWith: %w", err)
}
return oldValue.SSOSharedWith, nil
}
// AppendSSOSharedWith adds s to the "sso_shared_with" field.
func (m *OAuth2ClientMutation) AppendSSOSharedWith(s []string) {
m.appendsso_shared_with = append(m.appendsso_shared_with, s...)
}
// AppendedSSOSharedWith returns the list of values that were appended to the "sso_shared_with" field in this mutation.
func (m *OAuth2ClientMutation) AppendedSSOSharedWith() ([]string, bool) {
if len(m.appendsso_shared_with) == 0 {
return nil, false
}
return m.appendsso_shared_with, true
}
// ClearSSOSharedWith clears the value of the "sso_shared_with" field.
func (m *OAuth2ClientMutation) ClearSSOSharedWith() {
m.sso_shared_with = nil
m.appendsso_shared_with = nil
m.clearedFields[oauth2client.FieldSSOSharedWith] = struct{}{}
}
// SSOSharedWithCleared returns if the "sso_shared_with" field was cleared in this mutation.
func (m *OAuth2ClientMutation) SSOSharedWithCleared() bool {
_, ok := m.clearedFields[oauth2client.FieldSSOSharedWith]
return ok
}
// ResetSSOSharedWith resets all changes to the "sso_shared_with" field.
func (m *OAuth2ClientMutation) ResetSSOSharedWith() {
m.sso_shared_with = nil
m.appendsso_shared_with = nil
delete(m.clearedFields, oauth2client.FieldSSOSharedWith)
}
// Where appends a list predicates to the OAuth2ClientMutation builder.
func (m *OAuth2ClientMutation) Where(ps ...predicate.OAuth2Client) {
m.predicates = append(m.predicates, ps...)
@@ -7083,7 +7150,7 @@ func (m *OAuth2ClientMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *OAuth2ClientMutation) Fields() []string {
fields := make([]string, 0, 9)
fields := make([]string, 0, 10)
if m.secret != nil {
fields = append(fields, oauth2client.FieldSecret)
}
@@ -7111,6 +7178,9 @@ func (m *OAuth2ClientMutation) Fields() []string {
if m.post_logout_redirect_uris != nil {
fields = append(fields, oauth2client.FieldPostLogoutRedirectUris)
}
if m.sso_shared_with != nil {
fields = append(fields, oauth2client.FieldSSOSharedWith)
}
return fields
}
@@ -7137,6 +7207,8 @@ func (m *OAuth2ClientMutation) Field(name string) (ent.Value, bool) {
return m.MfaChain()
case oauth2client.FieldPostLogoutRedirectUris:
return m.PostLogoutRedirectUris()
case oauth2client.FieldSSOSharedWith:
return m.SSOSharedWith()
}
return nil, false
}
@@ -7164,6 +7236,8 @@ func (m *OAuth2ClientMutation) OldField(ctx context.Context, name string) (ent.V
return m.OldMfaChain(ctx)
case oauth2client.FieldPostLogoutRedirectUris:
return m.OldPostLogoutRedirectUris(ctx)
case oauth2client.FieldSSOSharedWith:
return m.OldSSOSharedWith(ctx)
}
return nil, fmt.Errorf("unknown OAuth2Client field %s", name)
}
@@ -7236,6 +7310,13 @@ func (m *OAuth2ClientMutation) SetField(name string, value ent.Value) error {
}
m.SetPostLogoutRedirectUris(v)
return nil
case oauth2client.FieldSSOSharedWith:
v, ok := value.([]string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetSSOSharedWith(v)
return nil
}
return fmt.Errorf("unknown OAuth2Client field %s", name)
}
@@ -7281,6 +7362,9 @@ func (m *OAuth2ClientMutation) ClearedFields() []string {
if m.FieldCleared(oauth2client.FieldPostLogoutRedirectUris) {
fields = append(fields, oauth2client.FieldPostLogoutRedirectUris)
}
if m.FieldCleared(oauth2client.FieldSSOSharedWith) {
fields = append(fields, oauth2client.FieldSSOSharedWith)
}
return fields
}
@@ -7310,6 +7394,9 @@ func (m *OAuth2ClientMutation) ClearField(name string) error {
case oauth2client.FieldPostLogoutRedirectUris:
m.ClearPostLogoutRedirectUris()
return nil
case oauth2client.FieldSSOSharedWith:
m.ClearSSOSharedWith()
return nil
}
return fmt.Errorf("unknown OAuth2Client nullable field %s", name)
}
@@ -7345,6 +7432,9 @@ func (m *OAuth2ClientMutation) ResetField(name string) error {
case oauth2client.FieldPostLogoutRedirectUris:
m.ResetPostLogoutRedirectUris()
return nil
case oauth2client.FieldSSOSharedWith:
m.ResetSSOSharedWith()
return nil
}
return fmt.Errorf("unknown OAuth2Client field %s", name)
}
+15 -2
View File
@@ -35,7 +35,9 @@ type OAuth2Client struct {
MfaChain []string `json:"mfa_chain,omitempty"`
// PostLogoutRedirectUris holds the value of the "post_logout_redirect_uris" field.
PostLogoutRedirectUris []string `json:"post_logout_redirect_uris,omitempty"`
selectValues sql.SelectValues
// SSOSharedWith holds the value of the "sso_shared_with" field.
SSOSharedWith []string `json:"sso_shared_with,omitempty"`
selectValues sql.SelectValues
}
// scanValues returns the types for scanning values from sql.Rows.
@@ -43,7 +45,7 @@ func (*OAuth2Client) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain, oauth2client.FieldPostLogoutRedirectUris:
case oauth2client.FieldRedirectUris, oauth2client.FieldTrustedPeers, oauth2client.FieldAllowedConnectors, oauth2client.FieldMfaChain, oauth2client.FieldPostLogoutRedirectUris, oauth2client.FieldSSOSharedWith:
values[i] = new([]byte)
case oauth2client.FieldPublic:
values[i] = new(sql.NullBool)
@@ -134,6 +136,14 @@ func (_m *OAuth2Client) assignValues(columns []string, values []any) error {
return fmt.Errorf("unmarshal field post_logout_redirect_uris: %w", err)
}
}
case oauth2client.FieldSSOSharedWith:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field sso_shared_with", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.SSOSharedWith); err != nil {
return fmt.Errorf("unmarshal field sso_shared_with: %w", err)
}
}
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -196,6 +206,9 @@ func (_m *OAuth2Client) String() string {
builder.WriteString(", ")
builder.WriteString("post_logout_redirect_uris=")
builder.WriteString(fmt.Sprintf("%v", _m.PostLogoutRedirectUris))
builder.WriteString(", ")
builder.WriteString("sso_shared_with=")
builder.WriteString(fmt.Sprintf("%v", _m.SSOSharedWith))
builder.WriteByte(')')
return builder.String()
}
@@ -29,6 +29,8 @@ const (
FieldMfaChain = "mfa_chain"
// FieldPostLogoutRedirectUris holds the string denoting the post_logout_redirect_uris field in the database.
FieldPostLogoutRedirectUris = "post_logout_redirect_uris"
// FieldSSOSharedWith holds the string denoting the sso_shared_with field in the database.
FieldSSOSharedWith = "sso_shared_with"
// Table holds the table name of the oauth2client in the database.
Table = "oauth2clients"
)
@@ -45,6 +47,7 @@ var Columns = []string{
FieldAllowedConnectors,
FieldMfaChain,
FieldPostLogoutRedirectUris,
FieldSSOSharedWith,
}
// ValidColumn reports if the column name is valid (part of the table columns).
+10
View File
@@ -337,6 +337,16 @@ func PostLogoutRedirectUrisNotNil() predicate.OAuth2Client {
return predicate.OAuth2Client(sql.FieldNotNull(FieldPostLogoutRedirectUris))
}
// SSOSharedWithIsNil applies the IsNil predicate on the "sso_shared_with" field.
func SSOSharedWithIsNil() predicate.OAuth2Client {
return predicate.OAuth2Client(sql.FieldIsNull(FieldSSOSharedWith))
}
// SSOSharedWithNotNil applies the NotNil predicate on the "sso_shared_with" field.
func SSOSharedWithNotNil() predicate.OAuth2Client {
return predicate.OAuth2Client(sql.FieldNotNull(FieldSSOSharedWith))
}
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.OAuth2Client) predicate.OAuth2Client {
return predicate.OAuth2Client(sql.AndPredicates(predicates...))

Some files were not shown because too many files have changed in this diff Show More