diff --git a/docs/requirements/Component-NotificationService.md b/docs/requirements/Component-NotificationService.md index a7703834..12555c6b 100644 --- a/docs/requirements/Component-NotificationService.md +++ b/docs/requirements/Component-NotificationService.md @@ -46,6 +46,8 @@ The SMTP configuration is defined centrally and used by the central Email delive - **Authentication mode**: One of: - **Basic Auth**: Username and password. For on-prem SMTP relays or servers that support basic authentication. - **OAuth2 Client Credentials**: Tenant ID, Client ID, and Client Secret. For Microsoft 365 and other modern SMTP providers that require OAuth2. The Email adapter handles the token lifecycle internally (fetch, cache, refresh on expiry). + - **OAuth2 authority** (optional): The token-endpoint URL requested during the client-credentials grant. When omitted it defaults to the Microsoft 365 endpoint `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token`, with the `{tenant}` placeholder substituted from the credential's tenant id; set it explicitly to target another identity provider (e.g. a non-M365 OAuth2 SMTP provider). + - **OAuth2 scope** (optional): The scope requested from the token endpoint. When omitted it defaults to the Microsoft 365 scope `https://outlook.office365.com/.default`; set it explicitly for another provider. Both fields are managed by Admin-role users via the CLI (`notification smtp update --oauth2-authority`/`--oauth2-scope`) and the Central UI `/notifications/smtp` page (the two inputs appear only for the OAuth2 auth type); a blank value is stored as null so the M365 default applies. Together they make "and other modern SMTP providers" true — a non-Microsoft OAuth2 relay is now configurable without a code change. - **TLS mode**: None, StartTLS, or SSL. - **From address**: The sender email address for all notifications (e.g., `scada-notifications@company.com`). - **Connection timeout**: Maximum time to wait for SMTP connection (default: 30 seconds). diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs index a6ae020a..27a5bd79 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/NotificationCommands.cs @@ -142,6 +142,8 @@ public static class NotificationCommands updateCmd.Add(SmtpFromOption); updateCmd.Add(SmtpTlsModeOption); updateCmd.Add(SmtpCredentialsOption); + updateCmd.Add(SmtpOAuth2AuthorityOption); + updateCmd.Add(SmtpOAuth2ScopeOption); updateCmd.SetAction(async (ParseResult result) => { return await CommandHelpers.ExecuteCommandAsync( @@ -172,6 +174,18 @@ public static class NotificationCommands Description = "SMTP credentials — 'username:password' for Basic, or client secret " + "for OAuth2 (optional; preserves existing if omitted)", }; + private static readonly Option SmtpOAuth2AuthorityOption = + new("--oauth2-authority") + { + Description = "OAuth2 token-endpoint URL (optional; a '{tenant}' placeholder is " + + "substituted from the credential; preserves existing / M365 default if omitted)", + }; + private static readonly Option SmtpOAuth2ScopeOption = + new("--oauth2-scope") + { + Description = "OAuth2 scope requested from the token endpoint " + + "(optional; preserves existing / M365 default if omitted)", + }; private static Option CreateTlsModeOption() { @@ -185,8 +199,9 @@ public static class NotificationCommands /// /// Builds the from a parsed smtp update - /// invocation. The optional --tls-mode / --credentials flags map to - /// null when omitted so the server-side handler preserves the existing values. + /// invocation. The optional --tls-mode / --credentials / + /// --oauth2-authority / --oauth2-scope flags map to null when omitted + /// so the server-side handler preserves the existing values. /// /// The parsed command-line result from the smtp update invocation. /// An populated from the parsed result. @@ -199,7 +214,9 @@ public static class NotificationCommands var from = result.GetValue(SmtpFromOption)!; var tlsMode = result.GetValue(SmtpTlsModeOption); var credentials = result.GetValue(SmtpCredentialsOption); - return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials); + var oauth2Authority = result.GetValue(SmtpOAuth2AuthorityOption); + var oauth2Scope = result.GetValue(SmtpOAuth2ScopeOption); + return new UpdateSmtpConfigCommand(id, server, port, authMode, from, tlsMode, credentials, oauth2Authority, oauth2Scope); } private static Command BuildSms(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor index 04b41d0d..c50ad77b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/SmtpConfiguration.razor @@ -56,6 +56,13 @@
@smtp.FromAddress
Credentials
@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")
+ @if (string.Equals(smtp.AuthType, "OAuth2", StringComparison.OrdinalIgnoreCase)) + { +
OAuth2 Authority
+
@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)
+
OAuth2 Scope
+
@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)
+ } @@ -101,6 +108,21 @@ + @if (_authType == "OAuth2") + { +
+ + +
Token-endpoint URL; leave blank for the Microsoft 365 default. A {tenant} placeholder is substituted from the credential.
+
+
+ + +
Scope requested from the token endpoint; leave blank for the Microsoft 365 default.
+
+ } @if (_formError != null) {
@_formError
@@ -135,6 +157,8 @@ private string? _tlsMode; private string? _credentials; private string _fromAddress = string.Empty; + private string? _oauth2Authority; + private string? _oauth2Scope; private string? _formError; private ToastNotification _toast = default!; @@ -168,6 +192,8 @@ _tlsMode = "None"; _credentials = null; _fromAddress = string.Empty; + _oauth2Authority = null; + _oauth2Scope = null; _formError = null; _showForm = true; } @@ -181,6 +207,8 @@ _tlsMode = smtp.TlsMode; _credentials = smtp.Credentials; _fromAddress = smtp.FromAddress; + _oauth2Authority = smtp.OAuth2Authority; + _oauth2Scope = smtp.OAuth2Scope; _formError = null; _showForm = true; } @@ -210,6 +238,8 @@ _editingSmtp.TlsMode = _tlsMode; _editingSmtp.Credentials = _credentials?.Trim(); _editingSmtp.FromAddress = _fromAddress.Trim(); + _editingSmtp.OAuth2Authority = NormalizeOptional(_oauth2Authority); + _editingSmtp.OAuth2Scope = NormalizeOptional(_oauth2Scope); await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp); } else @@ -218,7 +248,9 @@ { Port = _port, TlsMode = _tlsMode, - Credentials = _credentials?.Trim() + Credentials = _credentials?.Trim(), + OAuth2Authority = NormalizeOptional(_oauth2Authority), + OAuth2Scope = NormalizeOptional(_oauth2Scope) }; await NotificationRepository.AddSmtpConfigurationAsync(smtp); } @@ -232,4 +264,9 @@ _formError = ex.Message; } } + + // Blank OAuth2 authority/scope means "use the Microsoft 365 default" — persist null, + // not an empty string, so the delivery adapter's default-substitution path fires. + private static string? NormalizeOptional(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs index a7f8e2f7..38001eec 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/NotificationCommands.cs @@ -8,7 +8,7 @@ public record CreateNotificationListCommand(string Name, IReadOnlyList R public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList? RecipientPhones = null); public record DeleteNotificationListCommand(int NotificationListId); public record ListSmtpConfigsCommand; -public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null); +public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null); public record ListSmsConfigsCommand; // FromNumber is optional: a Twilio Messaging-Service-only config supplies MessagingServiceSid // instead. At-least-one-of (FromNumber, MessagingServiceSid) is validated at the CLI/UI boundary. diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index a4a9ac8d..e57faab2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -1820,6 +1820,8 @@ public class ManagementActor : ReceiveActor c.MaxConcurrentConnections, c.MaxRetries, c.RetryDelay, + c.OAuth2Authority, + c.OAuth2Scope, HasCredentials = !string.IsNullOrEmpty(c.Credentials), }; @@ -1846,6 +1848,10 @@ public class ManagementActor : ReceiveActor // existing values intact (non-breaking for callers that do not send them). if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode; if (cmd.Credentials is not null) config.Credentials = cmd.Credentials; + // Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an + // omitted authority/scope leaves the stored value (or its M365 default) intact. + if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority; + if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope; await repo.UpdateSmtpConfigurationAsync(config); await repo.SaveChangesAsync(); // Audit the credential-free shape — the *fact of* the change diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index ba4b9658..91b05630 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -1404,6 +1404,65 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal("Basic", existing.AuthType); } + [Fact] + public void UpdateSmtpConfig_AppliesOAuth2AuthorityAndScope() + { + var notifRepo = Substitute.For(); + var existing = new Commons.Entities.Notifications.SmtpConfiguration( + "old.example.com", "OAuth2", "old@example.com") + { + Id = 1, + Port = 25, + OAuth2Authority = "https://old.authority/token", + OAuth2Scope = "old.scope/.default", + }; + notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any()).Returns(existing); + _services.AddScoped(_ => notifRepo); + + var actor = CreateActor(); + var envelope = Envelope( + new UpdateSmtpConfigCommand( + 1, "new.example.com", 465, "OAuth2", "new@example.com", + OAuth2Authority: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", + OAuth2Scope: "https://outlook.office365.com/.default"), + "Administrator"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(envelope.CorrelationId, response.CorrelationId); + Assert.Equal("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", existing.OAuth2Authority); + Assert.Equal("https://outlook.office365.com/.default", existing.OAuth2Scope); + } + + [Fact] + public void UpdateSmtpConfig_WithNullOAuth2AuthorityAndScope_PreservesExistingValues() + { + var notifRepo = Substitute.For(); + var existing = new Commons.Entities.Notifications.SmtpConfiguration( + "old.example.com", "OAuth2", "old@example.com") + { + Id = 1, + Port = 25, + OAuth2Authority = "https://old.authority/token", + OAuth2Scope = "old.scope/.default", + }; + notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any()).Returns(existing); + _services.AddScoped(_ => notifRepo); + + var actor = CreateActor(); + var envelope = Envelope( + new UpdateSmtpConfigCommand(1, "new.example.com", 465, "OAuth2", "new@example.com"), + "Administrator"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(envelope.CorrelationId, response.CorrelationId); + Assert.Equal("https://old.authority/token", existing.OAuth2Authority); + Assert.Equal("old.scope/.default", existing.OAuth2Scope); + } + // ======================================================================== // SMS Notifications (S5) — list Type discriminator + SMS-config management // ========================================================================