Skip to content

Commit 1e10955

Browse files
committed
feat(Api): Replaced IdentityServer developper signing credentials with automatically generated ones
fix(Api): Fixed the API to properly verify the signature of Service Account JwTs fix(Runner): Fixed the Do and For TaskExecutors to update the context upon changes Fixes #373
1 parent 5370d07 commit 1e10955

File tree

12 files changed

+158
-24
lines changed

12 files changed

+158
-24
lines changed
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright © 2024-Present The Synapse Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License"),
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
// http://www.apache.org/licenses/LICENSE-2.0
7+
//
8+
// Unless required by applicable law or agreed to in writing, software
9+
// distributed under the License is distributed on an "AS IS" BASIS,
10+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
// See the License for the specific language governing permissions and
12+
// limitations under the License.
13+
14+
using Microsoft.IdentityModel.Tokens;
15+
using System.Security.Cryptography;
16+
17+
namespace Synapse.Api.Application;
18+
19+
/// <summary>
20+
/// Expose static methods used to help manage the key used for signing service account JwTs
21+
/// </summary>
22+
public static class ServiceAccountSigningKey
23+
{
24+
25+
const string KeyDirectory = "keys";
26+
const string PrivateKeyFile = "service_account_private.pem";
27+
const string PublicKeyFile = "service_account_public.pem";
28+
29+
/// <summary>
30+
/// Creates the Service Account JWT signing key if it does not exist
31+
/// </summary>
32+
public static void Initialize()
33+
{
34+
var keyPath = Path.Combine(AppContext.BaseDirectory, KeyDirectory);
35+
if (!Directory.Exists(keyPath)) Directory.CreateDirectory(keyPath);
36+
var privateKeyPath = Path.Combine(keyPath, PrivateKeyFile);
37+
var publicKeyPath = Path.Combine(keyPath, PublicKeyFile);
38+
if (!File.Exists(privateKeyPath) || !File.Exists(publicKeyPath)) GenerateKeyPair(privateKeyPath, publicKeyPath);
39+
}
40+
41+
/// <summary>
42+
/// Loads the private service account signing key
43+
/// </summary>
44+
/// <returns>The private service account signing key</returns>
45+
public static SigningCredentials LoadPrivateKey()
46+
{
47+
var keyPath = Path.Combine(AppContext.BaseDirectory, KeyDirectory);
48+
if (!Directory.Exists(keyPath)) Directory.CreateDirectory(keyPath);
49+
var privateKeyPath = Path.Combine(keyPath, PrivateKeyFile);
50+
var privateKey = File.ReadAllText(privateKeyPath)
51+
.Replace("-----BEGIN PRIVATE KEY-----\n", string.Empty)
52+
.Replace("-----END PRIVATE KEY-----", string.Empty)
53+
.Replace("\n", string.Empty);
54+
var privateKeyBytes = Convert.FromBase64String(privateKey);
55+
var rsa = RSA.Create();
56+
rsa.ImportRSAPrivateKey(privateKeyBytes, out _);
57+
return new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
58+
}
59+
60+
/// <summary>
61+
/// Loads the public service account signing key
62+
/// </summary>
63+
/// <returns>The public service account signing key</returns>
64+
public static SecurityKey LoadPublicKey()
65+
{
66+
var keyPath = Path.Combine(AppContext.BaseDirectory, KeyDirectory);
67+
if (!Directory.Exists(keyPath)) Directory.CreateDirectory(keyPath);
68+
var publicKeyPath = Path.Combine(keyPath, PublicKeyFile);
69+
var publicKey = File.ReadAllText(publicKeyPath)
70+
.Replace("-----BEGIN PUBLIC KEY-----\n", string.Empty)
71+
.Replace("-----END PUBLIC KEY-----", string.Empty)
72+
.Replace("\n", string.Empty);
73+
var publicKeyBytes = Convert.FromBase64String(publicKey);
74+
var rsa = RSA.Create();
75+
rsa.ImportRSAPublicKey(publicKeyBytes, out _);
76+
return new RsaSecurityKey(rsa);
77+
}
78+
79+
static void GenerateKeyPair(string privateKeyPath, string publicKeyPath)
80+
{
81+
using var rsa = RSA.Create(2048);
82+
var privateKey = ExportPrivateKey(rsa);
83+
File.WriteAllText(privateKeyPath, privateKey);
84+
var publicKey = ExportPublicKey(rsa);
85+
File.WriteAllText(publicKeyPath, publicKey);
86+
}
87+
88+
static string ExportPrivateKey(RSA rsa)
89+
{
90+
var privateKeyBytes = rsa.ExportRSAPrivateKey();
91+
return $"-----BEGIN PRIVATE KEY-----\n{Convert.ToBase64String(privateKeyBytes, Base64FormattingOptions.InsertLineBreaks)}\n-----END PRIVATE KEY-----";
92+
}
93+
94+
static string ExportPublicKey(RSA rsa)
95+
{
96+
var publicKeyBytes = rsa.ExportRSAPublicKey();
97+
return $"-----BEGIN PUBLIC KEY-----\n{Convert.ToBase64String(publicKeyBytes, Base64FormattingOptions.InsertLineBreaks)}\n-----END PUBLIC KEY-----";
98+
}
99+
100+
}

src/api/Synapse.Api.Http/Extensions/IServiceCollectionExtensions.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
using Microsoft.OpenApi.Models;
1717
using Neuroglia;
1818
using Neuroglia.Security.Services;
19+
using Synapse.Api.Application;
1920
using Synapse.Api.Application.Services;
2021
using Synapse.Api.Http.Controllers;
2122
using Synapse.Core.Api.Services;
@@ -36,6 +37,7 @@ public static class IServiceCollectionExtensions
3637
/// <returns>The configured <see cref="IServiceCollection"/></returns>
3738
public static IServiceCollection AddSynapseHttpApi(this IServiceCollection services)
3839
{
40+
ServiceAccountSigningKey.Initialize();
3941
services.AddHttpContextAccessor();
4042
services.AddScoped<IUserAccessor, HttpContextUserAccessor>();
4143
services.AddControllers()
@@ -45,7 +47,7 @@ public static IServiceCollection AddSynapseHttpApi(this IServiceCollection servi
4547
})
4648
.AddApplicationPart(typeof(WorkflowsController).Assembly);
4749
services.AddIdentityServer()
48-
.AddDeveloperSigningCredential()
50+
.AddSigningCredential(ServiceAccountSigningKey.LoadPrivateKey())
4951
.AddInMemoryApiResources(SynapseApiDefaults.OpenIDConnect.ApiResources.AsEnumerable())
5052
.AddInMemoryIdentityResources(SynapseApiDefaults.OpenIDConnect.IdentityResources.AsEnumerable())
5153
.AddInMemoryApiScopes(SynapseApiDefaults.OpenIDConnect.ApiScopes.AsEnumerable())

src/api/Synapse.Api.Server/Program.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@
3939
{
4040
NameClaimType = JwtClaimTypes.Name,
4141
RoleClaimType = JwtClaimTypes.Role,
42-
//ValidAudience = "api", //todo
43-
ValidateAudience = false, //todo
42+
ValidAudience = "api",
43+
ValidateAudience = false,
4444
ValidIssuer = options.Authority,
45-
ValidateIssuer = true
45+
ValidateIssuer = true,
46+
IssuerSigningKey = ServiceAccountSigningKey.LoadPublicKey()
4647
};
4748
});
4849
authentication.AddPolicyScheme(FallbackPolicySchemeDefaults.AuthenticationScheme, FallbackPolicySchemeDefaults.AuthenticationScheme, options =>
@@ -144,7 +145,7 @@
144145
{
145146
app.MapFallbackToFile("index.html");
146147
app.MapFallbackToFile("/workflows/details/{namespace}/{name}/{version?}/{instanceName?}", "index.html");
147-
app.MapFallbackToFile("/workflow-intances/{instanceName?}", "index.html");
148+
app.MapFallbackToFile("/workflow-instances/{instanceName?}", "index.html");
148149
}
149150

150151
await app.RunAsync();

src/api/Synapse.Api.Server/tempkey.jwk

Lines changed: 0 additions & 1 deletion
This file was deleted.

src/core/Synapse.Core.Infrastructure/OAuth2Token.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,12 @@ public record OAuth2Token
6363
/// Gets the UTC date and time at which the <see cref="OAuth2Token"/> expires
6464
/// </summary>
6565
[DataMember(Order = 6, Name = "expires_on"), JsonPropertyName("expires_on"), JsonPropertyOrder(6), YamlMember(Alias = "expires_on", Order = 6)]
66-
public virtual DateTime ExpiresAt { get; set; }
66+
public virtual DateTime? ExpiresAt { get; set; }
6767

6868
/// <summary>
6969
/// Gets a boolean indicating whether or not the <see cref="OAuth2Token"/> has expired
7070
/// </summary>
7171
[IgnoreDataMember, JsonIgnore, YamlIgnore]
72-
public virtual bool HasExpired => DateTime.UtcNow > this.ExpiresAt;
72+
public virtual bool HasExpired => this.ExpiresAt.HasValue ? DateTime.UtcNow > this.ExpiresAt : DateTime.UtcNow > this.CreatedAt.Add(TimeSpan.FromSeconds(this.Ttl));
7373

7474
}

src/core/Synapse.Core.Infrastructure/Services/OAuth2TokenManager.cs

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313

1414
using IdentityModel.Client;
1515
using Microsoft.Extensions.Logging;
16+
using Microsoft.IdentityModel.JsonWebTokens;
1617
using Microsoft.IdentityModel.Tokens;
1718
using Neuroglia.Serialization;
1819
using ServerlessWorkflow.Sdk;
1920
using ServerlessWorkflow.Sdk.Models.Authentication;
2021
using System.Collections.Concurrent;
21-
using System.IdentityModel.Tokens.Jwt;
2222
using System.Net.Mime;
2323
using System.Security.Claims;
2424
using System.Text;
@@ -59,6 +59,8 @@ public class OAuth2TokenManager(ILogger<OAuth2TokenManager> logger, IJsonSeriali
5959
public virtual async Task<OAuth2Token> GetTokenAsync(OAuth2AuthenticationSchemeDefinitionBase configuration, CancellationToken cancellationToken = default)
6060
{
6161
ArgumentNullException.ThrowIfNull(configuration);
62+
var tokenKey = $"{configuration.Client?.Id}@{configuration.Authority}";
63+
if (this.Tokens.TryGetValue(tokenKey, out var token) && token != null && !token.HasExpired) return token;
6264
Uri tokenEndpoint;
6365
if (configuration is OpenIDConnectSchemeDefinition)
6466
{
@@ -68,7 +70,6 @@ public virtual async Task<OAuth2Token> GetTokenAsync(OAuth2AuthenticationSchemeD
6870
}
6971
else if (configuration is OAuth2AuthenticationSchemeDefinition oauth2) tokenEndpoint = oauth2.Endpoints.Token;
7072
else throw new NotSupportedException($"The specified scheme type '{configuration.GetType().FullName}' is not supported in this context");
71-
var tokenKey = $"{configuration.Client?.Id}@{configuration.Authority}";
7273
var properties = new Dictionary<string, string>()
7374
{
7475
{ "grant_type", configuration.Grant }
@@ -113,15 +114,10 @@ public virtual async Task<OAuth2Token> GetTokenAsync(OAuth2AuthenticationSchemeD
113114
properties["actor_token"] = configuration.Actor.Token;
114115
properties["actor_token_type"] = configuration.Actor.Type;
115116
}
116-
if (this.Tokens.TryGetValue(tokenKey, out var token) && token != null)
117+
if (token != null && token.HasExpired && !string.IsNullOrWhiteSpace(token.RefreshToken))
117118
{
118-
if (token.HasExpired
119-
&& !string.IsNullOrWhiteSpace(token.RefreshToken))
120-
{
121-
properties["grant_type"] = "refresh_token";
122-
properties["refresh_token"] = token.RefreshToken;
123-
}
124-
else return token;
119+
properties["grant_type"] = "refresh_token";
120+
properties["refresh_token"] = token.RefreshToken;
125121
}
126122
using var content = configuration.Request.Encoding switch
127123
{
@@ -168,15 +164,23 @@ protected virtual string CreateClientAssertionJwt(string clientId, string audien
168164
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
169165
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
170166
ArgumentNullException.ThrowIfNull(signingCredentials);
171-
var tokenHandler = new JwtSecurityTokenHandler();
172167
var claims = new List<Claim>
173168
{
174169
new(JwtRegisteredClaimNames.Sub, clientId),
175170
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
176171
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
177172
};
178-
var token = new JwtSecurityToken(clientId, audience, claims, DateTime.UtcNow, DateTime.UtcNow.AddMinutes(5), signingCredentials);
179-
return tokenHandler.WriteToken(token);
173+
var tokenDescriptor = new SecurityTokenDescriptor
174+
{
175+
Subject = new ClaimsIdentity(claims),
176+
Issuer = clientId,
177+
Audience = audience,
178+
NotBefore = DateTime.UtcNow,
179+
Expires = DateTime.UtcNow.AddMinutes(5),
180+
SigningCredentials = signingCredentials
181+
};
182+
var tokenHandler = new JsonWebTokenHandler();
183+
return tokenHandler.CreateToken(tokenDescriptor);
180184
}
181185

182186
}

src/core/Synapse.Core.Infrastructure/Synapse.Core.Infrastructure.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@
1010

1111
<ItemGroup>
1212
<PackageReference Include="IdentityModel" Version="7.0.0" />
13+
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.2" />
1314
<PackageReference Include="Neuroglia.Data.Expressions.Abstractions" Version="4.15.3" />
1415
<PackageReference Include="Neuroglia.Data.Infrastructure.Redis" Version="4.15.3" />
1516
<PackageReference Include="Neuroglia.Data.Infrastructure.ResourceOriented.Redis" Version="4.15.3" />
1617
<PackageReference Include="Neuroglia.Mediation" Version="4.15.3" />
1718
<PackageReference Include="Neuroglia.Plugins" Version="4.15.3" />
1819
<PackageReference Include="ServerlessWorkflow.Sdk.IO" Version="1.0.0-alpha2.12" />
19-
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
2020
</ItemGroup>
2121

2222
<ItemGroup>

src/runner/Synapse.Runner/Services/Executors/DoTaskExecutor.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ protected virtual async Task OnSubtaskCompletedAsync(ITaskExecutor executor, Can
103103
var output = executor.Task.Output!;
104104
var nextDefinition = this.Task.Definition.Do.GetTaskAfter(last);
105105
this.Executors.Remove(executor);
106+
if (this.Task.ContextData != executor.Task.ContextData) await this.Task.SetContextDataAsync(executor.Task.ContextData, cancellationToken).ConfigureAwait(false);
106107
if (nextDefinition == null || nextDefinition.Value == null)
107108
{
108109
await this.SetResultAsync(output, last.Next == FlowDirective.End ? FlowDirective.End : this.Task.Definition.Then, cancellationToken).ConfigureAwait(false);

src/runner/Synapse.Runner/Services/Executors/ForTaskExecutor.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ protected virtual async Task OnIterationCompletedAsync(ITaskExecutor executor, C
122122
var last = executor.Task.Instance;
123123
var output = executor.Task.Output!;
124124
this.Executors.Remove(executor);
125+
if (this.Task.ContextData != executor.Task.ContextData) await this.Task.SetContextDataAsync(executor.Task.ContextData, cancellationToken).ConfigureAwait(false);
125126
await executor.DisposeAsync().ConfigureAwait(false);
126127
var index = int.Parse(last.Reference.OriginalString.Split('/', StringSplitOptions.RemoveEmptyEntries)[^2]) + 1;
127128
if (index == this.Collection.Count)
@@ -161,4 +162,4 @@ protected virtual async Task OnIterationCompletedAsync(ITaskExecutor executor, C
161162
}
162163
}
163164

164-
}
165+
}

tests/Synapse.UnitTests/Services/MockCloudFlowsApiClient.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ internal class MockSynapseApiClient(IServiceProvider serviceProvider)
3434

3535
public IDocumentApiClient Documents { get; } = ActivatorUtilities.CreateInstance<MockDocumentApiClient>(serviceProvider);
3636

37+
public ICloudEventApiClient Events { get; } = ActivatorUtilities.CreateInstance<MockEventApiClient>(serviceProvider);
38+
3739
public INamespacedResourceApiClient<ServiceAccount> ServiceAccounts => throw new NotImplementedException();
3840

3941
public IUserApiClient Users => throw new NotImplementedException();

0 commit comments

Comments
 (0)