From 1d5fe0334f0015c21df5f14beea0ec3f83faaaa1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 07:00:45 +0000 Subject: [PATCH 1/2] Initial plan From 2dfbf992c4abfac3e2b3369d2497bd6465f76b1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Sep 2025 07:12:24 +0000 Subject: [PATCH 2/2] Implement client secret expiry notification mechanism for OAuth and MSAAD Co-authored-by: bosvos <2437699+bosvos@users.noreply.github.com> --- config.go | 19 ++- msaad.go | 72 ++++++++-- oauth.go | 83 +++++++++--- secret_expiry_test.go | 296 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 442 insertions(+), 28 deletions(-) create mode 100644 secret_expiry_test.go diff --git a/config.go b/config.go index 961afb4..b20151e 100644 --- a/config.go +++ b/config.go @@ -7,10 +7,19 @@ import ( "io/ioutil" "os" "strconv" + "time" _ "github.com/lib/pq" ) +// ClientSecretExpiryNotificationFunc is a callback function type for client secret expiry notifications. +// It's called when a client secret is about to expire within the configured threshold. +// Parameters: +// - providerName: Name of the OAuth provider or "MSAAD" for MSAAD configuration +// - daysUntilExpiry: Number of days until the secret expires +// - expiryDate: The actual expiry date of the secret +type ClientSecretExpiryNotificationFunc func(providerName string, daysUntilExpiry int, expiryDate time.Time) + /* Full populated config: @@ -57,13 +66,17 @@ Full populated config: "RedirectURL": "https://mysite.example.com/auth/oauth/finish", "ClientID": "your client UUID here", "Scope": "openid email offline_access", - "ClientSecret": "your secret here" + "ClientSecret": "your secret here", + "ClientSecretExpiryDate": "2024-12-31T23:59:59Z" } - } + }, + "SecretExpiryNotificationDays": 14, + "SecretExpiryCheckIntervalHours": 1 }, "MSAAD": { "ClientID": "your client UUID", - "ClientSecret": "your secret" + "ClientSecret": "your secret", + "ClientSecretExpiryDate": "2024-12-31T23:59:59Z" }, "SessionDB": { "MaxActiveSessions": 0, diff --git a/msaad.go b/msaad.go index 64a327b..baea3fe 100644 --- a/msaad.go +++ b/msaad.go @@ -28,16 +28,20 @@ import ( // ConfigMSAAD is the JSON definition for the Microsoft Azure Active Directory synchronization settings type ConfigMSAAD struct { - Verbose bool // If true, then emit verbose logging - DryRun bool // If true, don't actually take any action, just log the intended actions - TenantID string // Your tenant UUID (ie ID of your AAD instance) - ClientID string // Your client UUID (ie ID of your application) - ClientSecret string // Secrets used for authenticating Azure AD requests - MergeIntervalSeconds int // If non-zero, then overrides the merge interval - DefaultRoles []string // Roles that are activated by default if a user has any one of the AAD roles - RoleToGroup map[string]string // Map from principleName of AAD role, to Authaus group. - AllowArchiveUser bool // If true, then archive users who no longer have the relevant roles in the AAD - PassthroughClientIDs []string // Client IDs of trusted IMQS apps utilising app-to-app passthrough auth + Verbose bool // If true, then emit verbose logging + DryRun bool // If true, don't actually take any action, just log the intended actions + TenantID string // Your tenant UUID (ie ID of your AAD instance) + ClientID string // Your client UUID (ie ID of your application) + ClientSecret string // Secrets used for authenticating Azure AD requests + ClientSecretExpiryDate *time.Time // Optional expiry date for the client secret (RFC3339 format) + MergeIntervalSeconds int // If non-zero, then overrides the merge interval + DefaultRoles []string // Roles that are activated by default if a user has any one of the AAD roles + RoleToGroup map[string]string // Map from principleName of AAD role, to Authaus group. + AllowArchiveUser bool // If true, then archive users who no longer have the relevant roles in the AAD + PassthroughClientIDs []string // Client IDs of trusted IMQS apps utilising app-to-app passthrough auth + SecretExpiryNotificationDays int // Number of days before expiry to trigger notification (default: 14) + SecretExpiryCheckIntervalHours int // Hours between secret expiry checks (default: 1) + SecretExpiryNotificationCallback ClientSecretExpiryNotificationFunc // Callback function for secret expiry notifications } // MSAADInterface @@ -222,6 +226,20 @@ func (m *MSAAD) Initialize(parent *Central, log *log.Logger) error { return fmt.Errorf("MSAAD provider is null") } + // Run a loop that checks for MSAAD client secret expiry and triggers notifications + go func() { + interval := m.config.SecretExpiryCheckIntervalHours + if interval == 0 { + interval = 1 // Default to 1 hour + } + // Startup grace + time.Sleep(15 * time.Second) + for !m.IsShuttingDown() { + m.checkSecretExpiry() + time.Sleep(time.Duration(interval) * time.Hour) + } + }() + return nil } @@ -756,3 +774,37 @@ func removeFromGroupList(list []GroupIDU32, i int) []GroupIDU32 { list[i] = list[len(list)-1] return list[:len(list)-1] } + +// checkSecretExpiry checks the MSAAD client secret for upcoming expiry +// and triggers notifications if it expires within the configured threshold. +func (m *MSAAD) checkSecretExpiry() { + if m.config.SecretExpiryNotificationCallback == nil { + return // No callback configured + } + + if m.config.ClientSecretExpiryDate == nil { + return // No expiry date configured + } + + notificationDays := m.config.SecretExpiryNotificationDays + if notificationDays == 0 { + notificationDays = 14 // Default to 2 weeks + } + + now := time.Now() + threshold := now.Add(time.Duration(notificationDays) * 24 * time.Hour) + + if m.config.ClientSecretExpiryDate.Before(threshold) && m.config.ClientSecretExpiryDate.After(now) { + // Calculate days based on date difference, not time difference + nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + expiryDate := time.Date(m.config.ClientSecretExpiryDate.Year(), m.config.ClientSecretExpiryDate.Month(), m.config.ClientSecretExpiryDate.Day(), 0, 0, 0, 0, m.config.ClientSecretExpiryDate.Location()) + daysUntilExpiry := int(expiryDate.Sub(nowDate).Hours() / 24) + + m.config.SecretExpiryNotificationCallback("MSAAD", daysUntilExpiry, *m.config.ClientSecretExpiryDate) + + if m.config.Verbose { + m.log.Warnf("MSAAD client secret expires in %d days (%s)", + daysUntilExpiry, m.config.ClientSecretExpiryDate.Format(time.RFC3339)) + } + } +} diff --git a/oauth.go b/oauth.go index 14506e8..3138afd 100644 --- a/oauth.go +++ b/oauth.go @@ -23,24 +23,28 @@ const ( ) type ConfigOAuthProvider struct { - Type string // See OAuthProvider___ constants for legal values - Title string // Name of provider that user sees (probably need an image too) - ClientID string // For MSAAD - LoginURL string // eg https://login.microsoftonline.com/e1ff61b3-a3da-4639-ae31-c6dff3ce7bfb/oauth2/v2.0/authorize - TokenURL string // eg https://login.microsoftonline.com/e1ff61b3-a3da-4639-ae31-c6dff3ce7bfb/oauth2/v2.0/token - RedirectURL string // eg https://stellenbosch.imqs.co.za/auth2/oauth/finish. URL must be listed in IMQS app in Azure. Can be http://localhost/auth2/oauth/finish for testing. - Scope string - ClientSecret string - AllowCreateUser bool // If true, then automatically create an Authaus user for an OAuth user, if the user succeeds in logging in + Type string // See OAuthProvider___ constants for legal values + Title string // Name of provider that user sees (probably need an image too) + ClientID string // For MSAAD + LoginURL string // eg https://login.microsoftonline.com/e1ff61b3-a3da-4639-ae31-c6dff3ce7bfb/oauth2/v2.0/authorize + TokenURL string // eg https://login.microsoftonline.com/e1ff61b3-a3da-4639-ae31-c6dff3ce7bfb/oauth2/v2.0/token + RedirectURL string // eg https://stellenbosch.imqs.co.za/auth2/oauth/finish. URL must be listed in IMQS app in Azure. Can be http://localhost/auth2/oauth/finish for testing. + Scope string + ClientSecret string + ClientSecretExpiryDate *time.Time // Optional expiry date for the client secret (RFC3339 format) + AllowCreateUser bool // If true, then automatically create an Authaus user for an OAuth user, if the user succeeds in logging in } type ConfigOAuth struct { - Providers map[string]*ConfigOAuthProvider - Verbose bool // If true, then print a lot of debugging information - ForceFastTokenRefresh bool // If true, then force a token refresh every 120 seconds. This is for testing the token refresh code. - LoginExpirySeconds int64 // A session that starts must be completed within this time period (eg 5 minutes) - TokenCheckIntervalSeconds int // Override interval at which we check that OAuth tokens are still valid, and if not, invalidate the Authaus session. Set to -1 to disable this check. - DefaultProvider string // Can be set to the name of one of the Providers. This was created for the login JS front-end, to act as though the user has pressed the "Sign-in with XYZ" button as soon as the page is loaded. + Providers map[string]*ConfigOAuthProvider + Verbose bool // If true, then print a lot of debugging information + ForceFastTokenRefresh bool // If true, then force a token refresh every 120 seconds. This is for testing the token refresh code. + LoginExpirySeconds int64 // A session that starts must be completed within this time period (eg 5 minutes) + TokenCheckIntervalSeconds int // Override interval at which we check that OAuth tokens are still valid, and if not, invalidate the Authaus session. Set to -1 to disable this check. + DefaultProvider string // Can be set to the name of one of the Providers. This was created for the login JS front-end, to act as though the user has pressed the "Sign-in with XYZ" button as soon as the page is loaded. + SecretExpiryNotificationDays int // Number of days before expiry to trigger notification (default: 14) + SecretExpiryCheckIntervalHours int // Hours between secret expiry checks (default: 1) + SecretExpiryNotificationCallback ClientSecretExpiryNotificationFunc // Callback function for secret expiry notifications } type OAuthCompletedResult struct { @@ -179,6 +183,20 @@ func (x *OAuth) Initialize(parent *Central) { } }() } + + // Run a loop that checks for client secret expiry and triggers notifications + go func() { + interval := x.Config.SecretExpiryCheckIntervalHours + if interval == 0 { + interval = 1 // Default to 1 hour + } + // Startup grace + time.Sleep(10 * time.Second) + for !x.parent.IsShuttingDown() { + x.checkSecretExpiry() + time.Sleep(time.Duration(interval) * time.Hour) + } + }() } // HttpHandlerOAuthStart This is a GET or POST request that the frontend calls, in order to start an OAuth login sequence @@ -1039,3 +1057,38 @@ func buildPOSTBodyForm(params map[string]string) string { } return s } + +// checkSecretExpiry checks all OAuth provider client secrets for upcoming expiry +// and triggers notifications if they expire within the configured threshold. +func (x *OAuth) checkSecretExpiry() { + if x.Config.SecretExpiryNotificationCallback == nil { + return // No callback configured + } + + notificationDays := x.Config.SecretExpiryNotificationDays + if notificationDays == 0 { + notificationDays = 14 // Default to 2 weeks + } + + now := time.Now() + threshold := now.Add(time.Duration(notificationDays) * 24 * time.Hour) + + // Check OAuth providers + for providerName, provider := range x.Config.Providers { + if provider.ClientSecretExpiryDate != nil { + if provider.ClientSecretExpiryDate.Before(threshold) && provider.ClientSecretExpiryDate.After(now) { + // Calculate days based on date difference, not time difference + nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + expiryDate := time.Date(provider.ClientSecretExpiryDate.Year(), provider.ClientSecretExpiryDate.Month(), provider.ClientSecretExpiryDate.Day(), 0, 0, 0, 0, provider.ClientSecretExpiryDate.Location()) + daysUntilExpiry := int(expiryDate.Sub(nowDate).Hours() / 24) + + x.Config.SecretExpiryNotificationCallback(providerName, daysUntilExpiry, *provider.ClientSecretExpiryDate) + + if x.Config.Verbose { + x.parent.Log.Warnf("OAuth provider '%s' client secret expires in %d days (%s)", + providerName, daysUntilExpiry, provider.ClientSecretExpiryDate.Format(time.RFC3339)) + } + } + } + } +} diff --git a/secret_expiry_test.go b/secret_expiry_test.go new file mode 100644 index 0000000..53f3f73 --- /dev/null +++ b/secret_expiry_test.go @@ -0,0 +1,296 @@ +package authaus + +import ( + "testing" + "time" + + "github.com/IMQS/log" + "github.com/stretchr/testify/assert" +) + +// TestClientSecretExpiryNotification tests the client secret expiry notification mechanism +func TestClientSecretExpiryNotification(t *testing.T) { + // Test data for tracking notifications + var notificationCalls []secretExpiryNotification + notificationCallback := func(providerName string, daysUntilExpiry int, expiryDate time.Time) { + notificationCalls = append(notificationCalls, secretExpiryNotification{ + ProviderName: providerName, + DaysUntilExpiry: daysUntilExpiry, + ExpiryDate: expiryDate, + }) + } + + t.Run("OAuth provider secret expiry notification", func(t *testing.T) { + notificationCalls = nil // Reset + + now := time.Now() + expiryDate := now.Add(7 * 24 * time.Hour) // 7 days from now + + // Create OAuth config with provider that expires soon + oauth := &OAuth{ + Config: ConfigOAuth{ + Providers: map[string]*ConfigOAuthProvider{ + "testProvider": { + Type: OAuthProviderMSAAD, + Title: "Test Provider", + ClientID: "test-client-id", + ClientSecret: "test-secret", + ClientSecretExpiryDate: &expiryDate, + }, + }, + SecretExpiryNotificationDays: 14, // Notify 14 days before expiry + SecretExpiryNotificationCallback: notificationCallback, + Verbose: true, + }, + } + + // Mock parent with logger + central := &Central{ + Log: log.New("stdout", true), + } + oauth.parent = central + + // Call the secret expiry check + oauth.checkSecretExpiry() + + // Verify notification was triggered + assert.Len(t, notificationCalls, 1, "Expected exactly one notification") + if len(notificationCalls) > 0 { + notification := notificationCalls[0] + assert.Equal(t, "testProvider", notification.ProviderName) + assert.Equal(t, 7, notification.DaysUntilExpiry) + assert.True(t, notification.ExpiryDate.Equal(expiryDate)) + } + }) + + t.Run("MSAAD secret expiry notification", func(t *testing.T) { + notificationCalls = nil // Reset + + now := time.Now() + expiryDate := now.Add(10 * 24 * time.Hour) // 10 days from now + + // Create MSAAD config with secret that expires soon + msaad := &MSAAD{ + config: ConfigMSAAD{ + ClientID: "test-msaad-client", + ClientSecret: "test-msaad-secret", + ClientSecretExpiryDate: &expiryDate, + SecretExpiryNotificationDays: 14, // Notify 14 days before expiry + SecretExpiryNotificationCallback: notificationCallback, + Verbose: true, + }, + log: log.New("stdout", true), + } + + // Call the secret expiry check + msaad.checkSecretExpiry() + + // Verify notification was triggered + assert.Len(t, notificationCalls, 1, "Expected exactly one notification") + if len(notificationCalls) > 0 { + notification := notificationCalls[0] + assert.Equal(t, "MSAAD", notification.ProviderName) + assert.Equal(t, 10, notification.DaysUntilExpiry) + assert.True(t, notification.ExpiryDate.Equal(expiryDate)) + } + }) + + t.Run("No notification when secret not expiring soon", func(t *testing.T) { + notificationCalls = nil // Reset + + now := time.Now() + expiryDate := now.Add(30 * 24 * time.Hour) // 30 days from now + + oauth := &OAuth{ + Config: ConfigOAuth{ + Providers: map[string]*ConfigOAuthProvider{ + "testProvider": { + Type: OAuthProviderMSAAD, + Title: "Test Provider", + ClientID: "test-client-id", + ClientSecret: "test-secret", + ClientSecretExpiryDate: &expiryDate, + }, + }, + SecretExpiryNotificationDays: 14, // Notify 14 days before expiry + SecretExpiryNotificationCallback: notificationCallback, + }, + } + + central := &Central{ + Log: log.New("stdout", true), + } + oauth.parent = central + + oauth.checkSecretExpiry() + + // Verify no notification was triggered + assert.Len(t, notificationCalls, 0, "Expected no notifications for secrets expiring in 30 days") + }) + + t.Run("No notification when secret already expired", func(t *testing.T) { + notificationCalls = nil // Reset + + now := time.Now() + expiryDate := now.Add(-5 * 24 * time.Hour) // 5 days ago + + oauth := &OAuth{ + Config: ConfigOAuth{ + Providers: map[string]*ConfigOAuthProvider{ + "testProvider": { + Type: OAuthProviderMSAAD, + Title: "Test Provider", + ClientID: "test-client-id", + ClientSecret: "test-secret", + ClientSecretExpiryDate: &expiryDate, + }, + }, + SecretExpiryNotificationDays: 14, + SecretExpiryNotificationCallback: notificationCallback, + }, + } + + central := &Central{ + Log: log.New("stdout", true), + } + oauth.parent = central + + oauth.checkSecretExpiry() + + // Verify no notification was triggered for expired secret + assert.Len(t, notificationCalls, 0, "Expected no notifications for expired secrets") + }) + + t.Run("No notification when no callback configured", func(t *testing.T) { + notificationCalls = nil // Reset + + now := time.Now() + expiryDate := now.Add(7 * 24 * time.Hour) // 7 days from now + + oauth := &OAuth{ + Config: ConfigOAuth{ + Providers: map[string]*ConfigOAuthProvider{ + "testProvider": { + Type: OAuthProviderMSAAD, + Title: "Test Provider", + ClientID: "test-client-id", + ClientSecret: "test-secret", + ClientSecretExpiryDate: &expiryDate, + }, + }, + SecretExpiryNotificationDays: 14, + // No callback configured + }, + } + + central := &Central{ + Log: log.New("stdout", true), + } + oauth.parent = central + + oauth.checkSecretExpiry() + + // Verify no notification was triggered when no callback configured + assert.Len(t, notificationCalls, 0, "Expected no notifications when callback not configured") + }) + + t.Run("Default notification threshold is 14 days", func(t *testing.T) { + notificationCalls = nil // Reset + + now := time.Now() + expiryDate := now.Add(13 * 24 * time.Hour) // 13 days from now + + oauth := &OAuth{ + Config: ConfigOAuth{ + Providers: map[string]*ConfigOAuthProvider{ + "testProvider": { + Type: OAuthProviderMSAAD, + Title: "Test Provider", + ClientID: "test-client-id", + ClientSecret: "test-secret", + ClientSecretExpiryDate: &expiryDate, + }, + }, + // SecretExpiryNotificationDays not set, should default to 14 + SecretExpiryNotificationCallback: notificationCallback, + }, + } + + central := &Central{ + Log: log.New("stdout", true), + } + oauth.parent = central + + oauth.checkSecretExpiry() + + // Verify notification was triggered with default 14-day threshold + assert.Len(t, notificationCalls, 1, "Expected notification with default 14-day threshold") + }) +} + +// TestMultipleProvidersExpiryCheck tests checking multiple OAuth providers at once +func TestMultipleProvidersExpiryCheck(t *testing.T) { + var notificationCalls []secretExpiryNotification + notificationCallback := func(providerName string, daysUntilExpiry int, expiryDate time.Time) { + notificationCalls = append(notificationCalls, secretExpiryNotification{ + ProviderName: providerName, + DaysUntilExpiry: daysUntilExpiry, + ExpiryDate: expiryDate, + }) + } + + now := time.Now() + expiryDateSoon := now.Add(5 * 24 * time.Hour) // 5 days from now + expiryDateLater := now.Add(30 * 24 * time.Hour) // 30 days from now + + oauth := &OAuth{ + Config: ConfigOAuth{ + Providers: map[string]*ConfigOAuthProvider{ + "providerExpiringSoon": { + Type: OAuthProviderMSAAD, + Title: "Provider Expiring Soon", + ClientID: "test-client-id-1", + ClientSecret: "test-secret-1", + ClientSecretExpiryDate: &expiryDateSoon, + }, + "providerExpiringLater": { + Type: OAuthProviderMSAAD, + Title: "Provider Expiring Later", + ClientID: "test-client-id-2", + ClientSecret: "test-secret-2", + ClientSecretExpiryDate: &expiryDateLater, + }, + "providerNoExpiry": { + Type: OAuthProviderMSAAD, + Title: "Provider No Expiry", + ClientID: "test-client-id-3", + ClientSecret: "test-secret-3", + // No expiry date set + }, + }, + SecretExpiryNotificationDays: 14, + SecretExpiryNotificationCallback: notificationCallback, + }, + } + + central := &Central{ + Log: log.New("stdout", true), + } + oauth.parent = central + + oauth.checkSecretExpiry() + + // Verify only the provider expiring soon triggered a notification + assert.Len(t, notificationCalls, 1, "Expected exactly one notification") + if len(notificationCalls) > 0 { + assert.Equal(t, "providerExpiringSoon", notificationCalls[0].ProviderName) + assert.Equal(t, 5, notificationCalls[0].DaysUntilExpiry) + } +} + +// secretExpiryNotification represents a captured notification call for testing +type secretExpiryNotification struct { + ProviderName string + DaysUntilExpiry int + ExpiryDate time.Time +} \ No newline at end of file