Skip to content

Commit fc053ec

Browse files
committed
durable task scheduler auth extension
save initial
1 parent c8cb34c commit fc053ec

9 files changed

+875
-0
lines changed

Microsoft.DurableTask.sln

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Analyzers.Tests", "test\Ana
7171
EndProject
7272
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AzureFunctionsApp.Tests", "samples\AzureFunctionsUnitTests\AzureFunctionsApp.Tests.csproj", "{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}"
7373
EndProject
74+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{5227C712-2355-403F-90D6-51D0BCAE4D38}"
75+
EndProject
76+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure", "src\Extensions\Azure\Azure.csproj", "{662BF73D-A4DD-4910-8625-7C12F1ACDBEC}"
77+
EndProject
7478
Global
7579
GlobalSection(SolutionConfigurationPlatforms) = preSolution
7680
Debug|Any CPU = Debug|Any CPU
@@ -185,6 +189,10 @@ Global
185189
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Debug|Any CPU.Build.0 = Debug|Any CPU
186190
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Release|Any CPU.ActiveCfg = Release|Any CPU
187191
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9}.Release|Any CPU.Build.0 = Release|Any CPU
192+
{662BF73D-A4DD-4910-8625-7C12F1ACDBEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
193+
{662BF73D-A4DD-4910-8625-7C12F1ACDBEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
194+
{662BF73D-A4DD-4910-8625-7C12F1ACDBEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
195+
{662BF73D-A4DD-4910-8625-7C12F1ACDBEC}.Release|Any CPU.Build.0 = Release|Any CPU
188196
EndGlobalSection
189197
GlobalSection(SolutionProperties) = preSolution
190198
HideSolutionNode = FALSE
@@ -220,6 +228,8 @@ Global
220228
{998E9D97-BD36-4A9D-81FC-5DAC1CE40083} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
221229
{541FCCCE-1059-4691-B027-F761CD80DE92} = {E5637F81-2FB9-4CD7-900D-455363B142A7}
222230
{FC2692E7-79AE-400E-A50F-8E0BCC8C9BD9} = {EFF7632B-821E-4CFC-B4A0-ED4B24296B17}
231+
{5227C712-2355-403F-90D6-51D0BCAE4D38} = {8AFC9781-F6F1-4696-BB4A-9ED7CA9D612B}
232+
{662BF73D-A4DD-4910-8625-7C12F1ACDBEC} = {5227C712-2355-403F-90D6-51D0BCAE4D38}
223233
EndGlobalSection
224234
GlobalSection(ExtensibilityGlobals) = postSolution
225235
SolutionGuid = {AB41CB55-35EA-4986-A522-387AB3402E71}

src/Extensions/Azure/Azure.csproj

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>netstandard2.0;net6.0</TargetFrameworks>
5+
<PackageDescription>Azure extensions for the Durable Task Framework.</PackageDescription>
6+
<EnableStyleCop>true</EnableStyleCop>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Azure.Identity" Version="1.13.1" />
11+
<PackageReference Include="Grpc.Net.Client" Version="2.67.0" />
12+
</ItemGroup>
13+
14+
<ItemGroup>
15+
<ProjectReference Include="../../Client/Core/Client.csproj" />
16+
<ProjectReference Include="../../Worker/Core/Worker.csproj" />
17+
<ProjectReference Include="../../Worker/Grpc/Worker.Grpc.csproj" />
18+
<ProjectReference Include="../../Client/Grpc/Client.Grpc.csproj" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<SharedSection Include="Core" />
23+
<SharedSection Include="DependencyInjection" />
24+
<SharedSection Include="Grpc" />
25+
</ItemGroup>
26+
27+
</Project>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// ------------------------------------------------------------
2+
// Copyright (c) Microsoft Corporation.All rights reserved.
3+
// ------------------------------------------------------------
4+
5+
using System.Data.Common;
6+
7+
namespace DurableTask.Extensions.Azure;
8+
9+
/// <summary>
10+
/// Represents the constituent parts of a connection string for a Durable Task Scheduler service.
11+
/// </summary>
12+
public sealed class DurableTaskSchedulerConnectionString
13+
{
14+
readonly DbConnectionStringBuilder builder;
15+
16+
/// <summary>
17+
/// Initializes a new instance of the <see cref="DurableTaskSchedulerConnectionString"/> class.
18+
/// </summary>
19+
/// <param name="connectionString">A connection string for a Durable Task Scheduler service.</param>
20+
public DurableTaskSchedulerConnectionString(string connectionString)
21+
{
22+
this.builder = new() { ConnectionString = connectionString };
23+
}
24+
25+
/// <summary>
26+
/// Gets the authentication method specified in the connection string (if any).
27+
/// </summary>
28+
public string Authentication => this.GetRequiredValue("Authentication");
29+
30+
/// <summary>
31+
/// Gets the managed identity or workload identity client ID specified in the connection string (if any).
32+
/// </summary>
33+
public string? ClientId => this.GetValue("ClientID");
34+
35+
/// <summary>
36+
/// Gets the "AdditionallyAllowedTenants" property, optionally used by Workload Identity.
37+
/// Multiple values can be separated by a comma.
38+
/// </summary>
39+
public IList<string>? AdditionallyAllowedTenants =>
40+
string.IsNullOrEmpty(this.AdditionallyAllowedTenantsStr)
41+
? null
42+
: this.AdditionallyAllowedTenantsStr!.Split(',');
43+
44+
/// <summary>
45+
/// Gets the "TenantId" property, optionally used by Workload Identity.
46+
/// </summary>
47+
public string? TenantId => this.GetValue("TenantId");
48+
49+
/// <summary>
50+
/// Gets the "TokenFilePath" property, optionally used by Workload Identity.
51+
/// </summary>
52+
public string? TokenFilePath => this.GetValue("TokenFilePath");
53+
54+
/// <summary>
55+
/// Gets the endpoint specified in the connection string (if any).
56+
/// </summary>
57+
public string Endpoint => this.GetRequiredValue("Endpoint");
58+
59+
/// <summary>
60+
/// Gets the task hub name specified in the connection string.
61+
/// </summary>
62+
public string TaskHubName => this.GetRequiredValue("TaskHub");
63+
64+
string? AdditionallyAllowedTenantsStr => this.GetValue("AdditionallyAllowedTenants");
65+
66+
string? GetValue(string name) =>
67+
this.builder.TryGetValue(name, out object? value)
68+
? value as string
69+
: null;
70+
71+
string GetRequiredValue(string name)
72+
{
73+
string? value = this.GetValue(name);
74+
if (string.IsNullOrEmpty(value))
75+
{
76+
throw new ArgumentNullException(
77+
$"The connection string is missing the required '{name}' property.");
78+
}
79+
80+
return value!;
81+
}
82+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using Azure.Core;
2+
using Microsoft.DurableTask.Client;
3+
using Microsoft.DurableTask.Worker;
4+
using System.Diagnostics;
5+
6+
namespace DurableTask.Extensions.Azure;
7+
8+
// NOTE: These extension methods will eventually be provided by the Durable Task SDK itself.
9+
public static class DurableTaskSchedulerExtensions
10+
{
11+
// Configure the Durable Task *Worker* to use the Durable Task Scheduler service with the specified options.
12+
public static void UseDurableTaskScheduler(
13+
this IDurableTaskWorkerBuilder builder,
14+
string endpointAddress,
15+
string taskHubName,
16+
TokenCredential credential,
17+
Action<DurableTaskSchedulerOptions>? configure = null)
18+
{
19+
DurableTaskSchedulerOptions options = new(endpointAddress, taskHubName, credential);
20+
21+
configure?.Invoke(options);
22+
23+
builder.UseGrpc(GetGrpcChannelForOptions(options));
24+
}
25+
26+
public static void UseDurableTaskScheduler(
27+
this IDurableTaskWorkerBuilder builder,
28+
string connectionString,
29+
Action<DurableTaskSchedulerOptions>? configure = null)
30+
{
31+
var options = DurableTaskSchedulerOptions.FromConnectionString(connectionString);
32+
configure?.Invoke(options);
33+
builder.UseGrpc(GetGrpcChannelForOptions(options));
34+
}
35+
36+
// Configure the Durable Task *Client* to use the Durable Task Scheduler service with the specified options.
37+
public static void UseDurableTaskScheduler(
38+
this IDurableTaskClientBuilder builder,
39+
string endpointAddress,
40+
string taskHubName,
41+
TokenCredential credential,
42+
Action<DurableTaskSchedulerOptions>? configure = null)
43+
{
44+
DurableTaskSchedulerOptions options = new(endpointAddress, taskHubName, credential);
45+
46+
configure?.Invoke(options);
47+
48+
builder.UseGrpc(GetGrpcChannelForOptions(options));
49+
}
50+
51+
public static void UseDurableTaskScheduler(
52+
this IDurableTaskClientBuilder builder,
53+
string connectionString,
54+
Action<DurableTaskSchedulerOptions>? configure = null)
55+
{
56+
var options = DurableTaskSchedulerOptions.FromConnectionString(connectionString);
57+
configure?.Invoke(options);
58+
builder.UseGrpc(GetGrpcChannelForOptions(options));
59+
}
60+
61+
static GrpcChannel GetGrpcChannelForOptions(DurableTaskSchedulerOptions options)
62+
{
63+
if (string.IsNullOrEmpty(options.EndpointAddress))
64+
{
65+
throw RequiredOptionMissing(nameof(options.TaskHubName));
66+
}
67+
68+
if (string.IsNullOrEmpty(options.TaskHubName))
69+
{
70+
throw RequiredOptionMissing(nameof(options.TaskHubName));
71+
}
72+
73+
TokenCredential credential = options.Credential ?? throw RequiredOptionMissing(nameof(options.Credential));
74+
75+
string taskHubName = options.TaskHubName;
76+
string endpoint = options.EndpointAddress;
77+
78+
if (!endpoint.Contains("://"))
79+
{
80+
endpoint = $"https://{endpoint}";
81+
}
82+
83+
string resourceId = options.ResourceId ?? "https://durabletask.io";
84+
#if NET6_0
85+
int processId = Environment.ProcessId;
86+
#else
87+
int processId = Process.GetCurrentProcess().Id;
88+
#endif
89+
string workerId = options.WorkerId ?? $"{Environment.MachineName},{processId},{Guid.NewGuid()}";
90+
91+
TokenCache? cache =
92+
options.Credential is not null
93+
? new(
94+
options.Credential,
95+
new(new[] { $"{options.ResourceId}/.default" }),
96+
TimeSpan.FromMinutes(5))
97+
: null;
98+
99+
CallCredentials managedBackendCreds = CallCredentials.FromInterceptor(
100+
async (context, metadata) =>
101+
{
102+
metadata.Add("taskhub", taskHubName);
103+
metadata.Add("workerid", workerId);
104+
105+
if (cache is null)
106+
{
107+
return;
108+
}
109+
110+
AccessToken token = await cache.GetTokenAsync(context.CancellationToken);
111+
112+
metadata.Add("Authorization", $"Bearer {token.Token}");
113+
});
114+
115+
#if NET6_0
116+
return GrpcChannel.ForAddress(
117+
endpoint,
118+
new GrpcChannelOptions
119+
{
120+
Credentials = ChannelCredentials.Create(ChannelCredentials.SecureSsl, managedBackendCreds),
121+
});
122+
#else
123+
return new GrpcChannel(
124+
endpoint,
125+
ChannelCredentials.Create(ChannelCredentials.SecureSsl, managedBackendCreds));
126+
#endif
127+
}
128+
129+
static Exception RequiredOptionMissing(string optionName)
130+
{
131+
return new ArgumentException(message: $"Required option '{optionName}' was not provided.");
132+
}
133+
134+
sealed class TokenCache(TokenCredential credential, TokenRequestContext context, TimeSpan margin)
135+
{
136+
readonly TokenCredential credential = credential;
137+
readonly TokenRequestContext context = context;
138+
readonly TimeSpan margin = margin;
139+
140+
AccessToken? token;
141+
142+
public async Task<AccessToken> GetTokenAsync(CancellationToken cancellationToken)
143+
{
144+
DateTimeOffset nowWithMargin = DateTimeOffset.UtcNow.Add(this.margin);
145+
146+
if (this.token is null
147+
|| this.token.Value.RefreshOn < nowWithMargin
148+
|| this.token.Value.ExpiresOn < nowWithMargin)
149+
{
150+
this.token = await this.credential.GetTokenAsync(this.context, cancellationToken);
151+
}
152+
153+
return this.token.Value;
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)