feat(notifications): configurable OAuth2 authority + scope per SMTP configuration (defaults preserve M365) — entity + EF + migration + token service
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -25,6 +25,20 @@ public class SmtpConfiguration
|
|||||||
/// <summary>Gets or sets the delay between retry attempts.</summary>
|
/// <summary>Gets or sets the delay between retry attempts.</summary>
|
||||||
public TimeSpan RetryDelay { get; set; }
|
public TimeSpan RetryDelay { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the OAuth2 token-endpoint authority template, or null to use the
|
||||||
|
/// Microsoft 365 default (<c>https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token</c>).
|
||||||
|
/// The stored value is the full token-endpoint URL with an optional <c>{tenant}</c>
|
||||||
|
/// placeholder substituted from the credential's tenant id.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2Authority { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the OAuth2 scope requested from the token endpoint, or null to use the
|
||||||
|
/// Microsoft 365 default (<c>https://outlook.office365.com/.default</c>).
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2Scope { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new <see cref="SmtpConfiguration"/> with required fields.
|
/// Initializes a new <see cref="SmtpConfiguration"/> with required fields.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+9
@@ -126,5 +126,14 @@ public class SmtpConfigurationConfiguration : IEntityTypeConfiguration<SmtpConfi
|
|||||||
builder.Property(s => s.FromAddress)
|
builder.Property(s => s.FromAddress)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(500);
|
.HasMaxLength(500);
|
||||||
|
|
||||||
|
// Optional OAuth2 token-endpoint overrides — null preserves the M365 defaults.
|
||||||
|
builder.Property(s => s.OAuth2Authority)
|
||||||
|
.IsRequired(false)
|
||||||
|
.HasMaxLength(500);
|
||||||
|
|
||||||
|
builder.Property(s => s.OAuth2Scope)
|
||||||
|
.IsRequired(false)
|
||||||
|
.HasMaxLength(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2028
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSmtpOAuth2AuthorityScope : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OAuth2Authority",
|
||||||
|
table: "SmtpConfigurations",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OAuth2Scope",
|
||||||
|
table: "SmtpConfigurations",
|
||||||
|
type: "nvarchar(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OAuth2Authority",
|
||||||
|
table: "SmtpConfigurations");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OAuth2Scope",
|
||||||
|
table: "SmtpConfigurations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -984,6 +984,14 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
|
|||||||
b.Property<int>("MaxRetries")
|
b.Property<int>("MaxRetries")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("OAuth2Authority")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("OAuth2Scope")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
b.Property<int>("Port")
|
b.Property<int>("Port")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -185,7 +185,11 @@ public sealed class EmailNotificationDeliveryAdapter : INotificationDeliveryAdap
|
|||||||
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase)
|
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase)
|
||||||
&& _tokenService != null && credentials != null)
|
&& _tokenService != null && credentials != null)
|
||||||
{
|
{
|
||||||
credentials = await _tokenService.GetTokenAsync(credentials, cancellationToken);
|
credentials = await _tokenService.GetTokenAsync(
|
||||||
|
credentials,
|
||||||
|
config.OAuth2Authority,
|
||||||
|
config.OAuth2Scope,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2 XOAUTH2 requires the user identity (FromAddress)
|
// OAuth2 XOAUTH2 requires the user identity (FromAddress)
|
||||||
|
|||||||
@@ -38,11 +38,22 @@ public class OAuth2TokenService
|
|||||||
/// Credentials format: "tenantId:clientId:clientSecret"
|
/// Credentials format: "tenantId:clientId:clientSecret"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="credentials">Colon-separated string in the form <c>tenantId:clientId:clientSecret</c>.</param>
|
/// <param name="credentials">Colon-separated string in the form <c>tenantId:clientId:clientSecret</c>.</param>
|
||||||
|
/// <param name="authority">
|
||||||
|
/// Optional token-endpoint authority template (may contain a <c>{tenant}</c> placeholder);
|
||||||
|
/// null preserves the Microsoft 365 default endpoint.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="scope">Optional OAuth2 scope; null preserves the Microsoft 365 default scope.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
/// <returns>A valid OAuth2 access token string.</returns>
|
/// <returns>A valid OAuth2 access token string.</returns>
|
||||||
public async Task<string> GetTokenAsync(string credentials, CancellationToken cancellationToken = default)
|
public async Task<string> GetTokenAsync(
|
||||||
|
string credentials,
|
||||||
|
string? authority = null,
|
||||||
|
string? scope = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var key = CredentialKey(credentials);
|
// Cache key incorporates authority + scope so two configurations with the same
|
||||||
|
// credentials but different endpoints/scopes never share a token.
|
||||||
|
var key = CredentialKey($"{credentials}|{authority}|{scope}");
|
||||||
var entry = _cache.GetOrAdd(key, _ => new CacheEntry());
|
var entry = _cache.GetOrAdd(key, _ => new CacheEntry());
|
||||||
|
|
||||||
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
|
if (entry.Token != null && DateTimeOffset.UtcNow < entry.Expiry)
|
||||||
@@ -70,14 +81,22 @@ public class OAuth2TokenService
|
|||||||
var clientSecret = parts[2];
|
var clientSecret = parts[2];
|
||||||
|
|
||||||
var client = _httpClientFactory.CreateClient("OAuth2");
|
var client = _httpClientFactory.CreateClient("OAuth2");
|
||||||
var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
|
|
||||||
|
// Authority: null → today's hardcoded M365 endpoint; otherwise the stored
|
||||||
|
// template with an optional {tenant} placeholder resolved from the credential.
|
||||||
|
var tokenUrl = authority == null
|
||||||
|
? $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"
|
||||||
|
: authority.Replace("{tenant}", tenantId);
|
||||||
|
|
||||||
|
// Scope: null → the M365 default.
|
||||||
|
var tokenScope = scope ?? "https://outlook.office365.com/.default";
|
||||||
|
|
||||||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["grant_type"] = "client_credentials",
|
["grant_type"] = "client_credentials",
|
||||||
["client_id"] = clientId,
|
["client_id"] = clientId,
|
||||||
["client_secret"] = clientSecret,
|
["client_secret"] = clientSecret,
|
||||||
["scope"] = "https://outlook.office365.com/.default"
|
["scope"] = tokenScope
|
||||||
});
|
});
|
||||||
|
|
||||||
var response = await client.PostAsync(tokenUrl, form, cancellationToken);
|
var response = await client.PostAsync(tokenUrl, form, cancellationToken);
|
||||||
|
|||||||
@@ -169,6 +169,48 @@ public class OAuth2TokenServiceTests
|
|||||||
Assert.Equal(1, handler.CallCount);
|
Assert.Equal(1, handler.CallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Task 19: configurable OAuth2 authority + scope per SMTP configuration ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTokenAsync_CustomAuthorityAndScope_PostsToConfiguredEndpoint()
|
||||||
|
{
|
||||||
|
// A non-M365 IdP: the stored authority template carries a {tenant} placeholder
|
||||||
|
// and the scope is provider-specific. Both must flow verbatim into the request.
|
||||||
|
var handler = new CapturingHttpMessageHandler(
|
||||||
|
TokenJson("custom-token", expiresIn: 3600));
|
||||||
|
var client = new HttpClient(handler);
|
||||||
|
var factory = CreateMockFactory(client);
|
||||||
|
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||||
|
|
||||||
|
var token = await service.GetTokenAsync(
|
||||||
|
"T1:client:secret",
|
||||||
|
authority: "https://idp.example/{tenant}/token",
|
||||||
|
scope: "smtp.send");
|
||||||
|
|
||||||
|
Assert.Equal("custom-token", token);
|
||||||
|
Assert.Equal("https://idp.example/T1/token", handler.LastRequestUri!.ToString());
|
||||||
|
Assert.Equal("smtp.send", handler.LastForm!["scope"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTokenAsync_DefaultsPreserved_M365()
|
||||||
|
{
|
||||||
|
// No authority/scope → today's hardcoded Microsoft 365 endpoint + scope.
|
||||||
|
var handler = new CapturingHttpMessageHandler(
|
||||||
|
TokenJson("m365-token", expiresIn: 3600));
|
||||||
|
var client = new HttpClient(handler);
|
||||||
|
var factory = CreateMockFactory(client);
|
||||||
|
var service = new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
|
||||||
|
|
||||||
|
var token = await service.GetTokenAsync("tenant:client:secret");
|
||||||
|
|
||||||
|
Assert.Equal("m365-token", token);
|
||||||
|
Assert.Equal(
|
||||||
|
"https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
|
||||||
|
handler.LastRequestUri!.ToString());
|
||||||
|
Assert.Equal("https://outlook.office365.com/.default", handler.LastForm!["scope"]);
|
||||||
|
}
|
||||||
|
|
||||||
private static string TokenJson(string accessToken, int expiresIn) =>
|
private static string TokenJson(string accessToken, int expiresIn) =>
|
||||||
JsonSerializer.Serialize(new
|
JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
@@ -250,6 +292,38 @@ public class OAuth2TokenServiceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP handler that captures the request URI and the posted form fields so a
|
||||||
|
/// test can assert the token endpoint and scope actually sent.
|
||||||
|
/// </summary>
|
||||||
|
private class CapturingHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly string _response;
|
||||||
|
public Uri? LastRequestUri { get; private set; }
|
||||||
|
public IReadOnlyDictionary<string, string>? LastForm { get; private set; }
|
||||||
|
|
||||||
|
public CapturingHttpMessageHandler(string response) => _response = response;
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastRequestUri = request.RequestUri;
|
||||||
|
var body = request.Content == null
|
||||||
|
? string.Empty
|
||||||
|
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
LastForm = body
|
||||||
|
.Split('&', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(pair => pair.Split('=', 2))
|
||||||
|
.ToDictionary(
|
||||||
|
p => Uri.UnescapeDataString(p[0]),
|
||||||
|
p => p.Length > 1 ? Uri.UnescapeDataString(p[1]) : string.Empty);
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(_response)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Simple mock HTTP handler that returns a fixed response.
|
/// Simple mock HTTP handler that returns a fixed response.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user