feat(notifications): OAuth2 authority/scope management + CLI + UI surfaces
Additive UpdateSmtpConfigCommand params (OAuth2Authority/OAuth2Scope), preserve-on-null handler application, CLI --oauth2-authority/--oauth2-scope on smtp update, Central UI text inputs shown only for the OAuth2 auth type, and the Component-NotificationService doc paragraph with M365 defaults. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -46,6 +46,8 @@ The SMTP configuration is defined centrally and used by the central Email delive
|
|||||||
- **Authentication mode**: One of:
|
- **Authentication mode**: One of:
|
||||||
- **Basic Auth**: Username and password. For on-prem SMTP relays or servers that support basic authentication.
|
- **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 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.
|
- **TLS mode**: None, StartTLS, or SSL.
|
||||||
- **From address**: The sender email address for all notifications (e.g., `scada-notifications@company.com`).
|
- **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).
|
- **Connection timeout**: Maximum time to wait for SMTP connection (default: 30 seconds).
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ public static class NotificationCommands
|
|||||||
updateCmd.Add(SmtpFromOption);
|
updateCmd.Add(SmtpFromOption);
|
||||||
updateCmd.Add(SmtpTlsModeOption);
|
updateCmd.Add(SmtpTlsModeOption);
|
||||||
updateCmd.Add(SmtpCredentialsOption);
|
updateCmd.Add(SmtpCredentialsOption);
|
||||||
|
updateCmd.Add(SmtpOAuth2AuthorityOption);
|
||||||
|
updateCmd.Add(SmtpOAuth2ScopeOption);
|
||||||
updateCmd.SetAction(async (ParseResult result) =>
|
updateCmd.SetAction(async (ParseResult result) =>
|
||||||
{
|
{
|
||||||
return await CommandHelpers.ExecuteCommandAsync(
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
@@ -172,6 +174,18 @@ public static class NotificationCommands
|
|||||||
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
|
Description = "SMTP credentials — 'username:password' for Basic, or client secret " +
|
||||||
"for OAuth2 (optional; preserves existing if omitted)",
|
"for OAuth2 (optional; preserves existing if omitted)",
|
||||||
};
|
};
|
||||||
|
private static readonly Option<string?> 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<string?> SmtpOAuth2ScopeOption =
|
||||||
|
new("--oauth2-scope")
|
||||||
|
{
|
||||||
|
Description = "OAuth2 scope requested from the token endpoint " +
|
||||||
|
"(optional; preserves existing / M365 default if omitted)",
|
||||||
|
};
|
||||||
|
|
||||||
private static Option<string?> CreateTlsModeOption()
|
private static Option<string?> CreateTlsModeOption()
|
||||||
{
|
{
|
||||||
@@ -185,8 +199,9 @@ public static class NotificationCommands
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the <see cref="UpdateSmtpConfigCommand"/> from a parsed <c>smtp update</c>
|
/// Builds the <see cref="UpdateSmtpConfigCommand"/> from a parsed <c>smtp update</c>
|
||||||
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> flags map to
|
/// invocation. The optional <c>--tls-mode</c> / <c>--credentials</c> /
|
||||||
/// null when omitted so the server-side handler preserves the existing values.
|
/// <c>--oauth2-authority</c> / <c>--oauth2-scope</c> flags map to null when omitted
|
||||||
|
/// so the server-side handler preserves the existing values.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
|
/// <param name="result">The parsed command-line result from the <c>smtp update</c> invocation.</param>
|
||||||
/// <returns>An <see cref="UpdateSmtpConfigCommand"/> populated from the parsed result.</returns>
|
/// <returns>An <see cref="UpdateSmtpConfigCommand"/> populated from the parsed result.</returns>
|
||||||
@@ -199,7 +214,9 @@ public static class NotificationCommands
|
|||||||
var from = result.GetValue(SmtpFromOption)!;
|
var from = result.GetValue(SmtpFromOption)!;
|
||||||
var tlsMode = result.GetValue(SmtpTlsModeOption);
|
var tlsMode = result.GetValue(SmtpTlsModeOption);
|
||||||
var credentials = result.GetValue(SmtpCredentialsOption);
|
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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
private static Command BuildSms(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||||
|
|||||||
+38
-1
@@ -56,6 +56,13 @@
|
|||||||
<div class="col-md-8">@smtp.FromAddress</div>
|
<div class="col-md-8">@smtp.FromAddress</div>
|
||||||
<div class="col-md-4 text-muted">Credentials</div>
|
<div class="col-md-4 text-muted">Credentials</div>
|
||||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
|
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
|
||||||
|
@if (string.Equals(smtp.AuthType, "OAuth2", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
<div class="col-md-4 text-muted">OAuth2 Authority</div>
|
||||||
|
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</div>
|
||||||
|
<div class="col-md-4 text-muted">OAuth2 Scope</div>
|
||||||
|
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,6 +108,21 @@
|
|||||||
<input type="email" class="form-control" @bind="_fromAddress"
|
<input type="email" class="form-control" @bind="_fromAddress"
|
||||||
placeholder="noreply@example.com" />
|
placeholder="noreply@example.com" />
|
||||||
</div>
|
</div>
|
||||||
|
@if (_authType == "OAuth2")
|
||||||
|
{
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">OAuth2 Authority</label>
|
||||||
|
<input type="text" class="form-control" @bind="_oauth2Authority"
|
||||||
|
placeholder="https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" />
|
||||||
|
<div class="form-text">Token-endpoint URL; leave blank for the Microsoft 365 default. A <code>{tenant}</code> placeholder is substituted from the credential.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">OAuth2 Scope</label>
|
||||||
|
<input type="text" class="form-control" @bind="_oauth2Scope"
|
||||||
|
placeholder="https://outlook.office365.com/.default" />
|
||||||
|
<div class="form-text">Scope requested from the token endpoint; leave blank for the Microsoft 365 default.</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (_formError != null)
|
@if (_formError != null)
|
||||||
{
|
{
|
||||||
<div class="col-12"><div class="text-danger small">@_formError</div></div>
|
<div class="col-12"><div class="text-danger small">@_formError</div></div>
|
||||||
@@ -135,6 +157,8 @@
|
|||||||
private string? _tlsMode;
|
private string? _tlsMode;
|
||||||
private string? _credentials;
|
private string? _credentials;
|
||||||
private string _fromAddress = string.Empty;
|
private string _fromAddress = string.Empty;
|
||||||
|
private string? _oauth2Authority;
|
||||||
|
private string? _oauth2Scope;
|
||||||
private string? _formError;
|
private string? _formError;
|
||||||
|
|
||||||
private ToastNotification _toast = default!;
|
private ToastNotification _toast = default!;
|
||||||
@@ -168,6 +192,8 @@
|
|||||||
_tlsMode = "None";
|
_tlsMode = "None";
|
||||||
_credentials = null;
|
_credentials = null;
|
||||||
_fromAddress = string.Empty;
|
_fromAddress = string.Empty;
|
||||||
|
_oauth2Authority = null;
|
||||||
|
_oauth2Scope = null;
|
||||||
_formError = null;
|
_formError = null;
|
||||||
_showForm = true;
|
_showForm = true;
|
||||||
}
|
}
|
||||||
@@ -181,6 +207,8 @@
|
|||||||
_tlsMode = smtp.TlsMode;
|
_tlsMode = smtp.TlsMode;
|
||||||
_credentials = smtp.Credentials;
|
_credentials = smtp.Credentials;
|
||||||
_fromAddress = smtp.FromAddress;
|
_fromAddress = smtp.FromAddress;
|
||||||
|
_oauth2Authority = smtp.OAuth2Authority;
|
||||||
|
_oauth2Scope = smtp.OAuth2Scope;
|
||||||
_formError = null;
|
_formError = null;
|
||||||
_showForm = true;
|
_showForm = true;
|
||||||
}
|
}
|
||||||
@@ -210,6 +238,8 @@
|
|||||||
_editingSmtp.TlsMode = _tlsMode;
|
_editingSmtp.TlsMode = _tlsMode;
|
||||||
_editingSmtp.Credentials = _credentials?.Trim();
|
_editingSmtp.Credentials = _credentials?.Trim();
|
||||||
_editingSmtp.FromAddress = _fromAddress.Trim();
|
_editingSmtp.FromAddress = _fromAddress.Trim();
|
||||||
|
_editingSmtp.OAuth2Authority = NormalizeOptional(_oauth2Authority);
|
||||||
|
_editingSmtp.OAuth2Scope = NormalizeOptional(_oauth2Scope);
|
||||||
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
|
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -218,7 +248,9 @@
|
|||||||
{
|
{
|
||||||
Port = _port,
|
Port = _port,
|
||||||
TlsMode = _tlsMode,
|
TlsMode = _tlsMode,
|
||||||
Credentials = _credentials?.Trim()
|
Credentials = _credentials?.Trim(),
|
||||||
|
OAuth2Authority = NormalizeOptional(_oauth2Authority),
|
||||||
|
OAuth2Scope = NormalizeOptional(_oauth2Scope)
|
||||||
};
|
};
|
||||||
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
|
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
|
||||||
}
|
}
|
||||||
@@ -232,4 +264,9 @@
|
|||||||
_formError = ex.Message;
|
_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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public record CreateNotificationListCommand(string Name, IReadOnlyList<string> R
|
|||||||
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
|
public record UpdateNotificationListCommand(int NotificationListId, string Name, IReadOnlyList<string> RecipientEmails, NotificationType Type = NotificationType.Email, IReadOnlyList<string>? RecipientPhones = null);
|
||||||
public record DeleteNotificationListCommand(int NotificationListId);
|
public record DeleteNotificationListCommand(int NotificationListId);
|
||||||
public record ListSmtpConfigsCommand;
|
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;
|
public record ListSmsConfigsCommand;
|
||||||
// FromNumber is optional: a Twilio Messaging-Service-only config supplies MessagingServiceSid
|
// 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.
|
// instead. At-least-one-of (FromNumber, MessagingServiceSid) is validated at the CLI/UI boundary.
|
||||||
|
|||||||
@@ -1820,6 +1820,8 @@ public class ManagementActor : ReceiveActor
|
|||||||
c.MaxConcurrentConnections,
|
c.MaxConcurrentConnections,
|
||||||
c.MaxRetries,
|
c.MaxRetries,
|
||||||
c.RetryDelay,
|
c.RetryDelay,
|
||||||
|
c.OAuth2Authority,
|
||||||
|
c.OAuth2Scope,
|
||||||
HasCredentials = !string.IsNullOrEmpty(c.Credentials),
|
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).
|
// existing values intact (non-breaking for callers that do not send them).
|
||||||
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
|
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
|
||||||
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
|
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.UpdateSmtpConfigurationAsync(config);
|
||||||
await repo.SaveChangesAsync();
|
await repo.SaveChangesAsync();
|
||||||
// Audit the credential-free shape — the *fact of* the change
|
// Audit the credential-free shape — the *fact of* the change
|
||||||
|
|||||||
@@ -1404,6 +1404,65 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
Assert.Equal("Basic", existing.AuthType);
|
Assert.Equal("Basic", existing.AuthType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UpdateSmtpConfig_AppliesOAuth2AuthorityAndScope()
|
||||||
|
{
|
||||||
|
var notifRepo = Substitute.For<INotificationRepository>();
|
||||||
|
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<CancellationToken>()).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<ManagementSuccess>(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<INotificationRepository>();
|
||||||
|
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<CancellationToken>()).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<ManagementSuccess>(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
|
// SMS Notifications (S5) — list Type discriminator + SMS-config management
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user