@jtl-software/cloud-app-template-backend-dotnet 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/HelloWorldApp.Api/Endpoints/ConnectTenantEndpoint.cs +41 -0
- package/HelloWorldApp.Api/Endpoints/CurrentTenantEndpoint.cs +37 -0
- package/HelloWorldApp.Api/Endpoints/ErpInfoEndpoint.cs +74 -0
- package/HelloWorldApp.Api/Endpoints/HomeEndpoint.cs +15 -0
- package/HelloWorldApp.Api/HelloWorldApp.Api.csproj +18 -0
- package/HelloWorldApp.Api/Models/ConnectTenantRequest.cs +3 -0
- package/HelloWorldApp.Api/Models/SessionTokenPayload.cs +3 -0
- package/HelloWorldApp.Api/Program.cs +29 -0
- package/HelloWorldApp.Api/Properties/launchSettings.json +24 -0
- package/HelloWorldApp.Api/Services/ErpApiService.cs +102 -0
- package/HelloWorldApp.Api/Services/JwtVerificationService.cs +99 -0
- package/HelloWorldApp.Api/Services/TenantMappingService.cs +86 -0
- package/HelloWorldApp.Api/appsettings.Development.json +8 -0
- package/HelloWorldApp.Api/appsettings.Local.json.example +7 -0
- package/HelloWorldApp.Api/appsettings.json +18 -0
- package/package.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
using FastEndpoints;
|
|
2
|
+
using {{APP_NAME_PASCAL}}.Api.Models;
|
|
3
|
+
using {{APP_NAME_PASCAL}}.Api.Services;
|
|
4
|
+
|
|
5
|
+
namespace {{APP_NAME_PASCAL}}.Api.Endpoints;
|
|
6
|
+
|
|
7
|
+
public class ConnectTenantEndpoint : Endpoint<ConnectTenantRequest>
|
|
8
|
+
{
|
|
9
|
+
private readonly JwtVerificationService _jwtService;
|
|
10
|
+
private readonly TenantMappingService _tenantMapping;
|
|
11
|
+
|
|
12
|
+
public ConnectTenantEndpoint(JwtVerificationService jwtService, TenantMappingService tenantMapping)
|
|
13
|
+
{
|
|
14
|
+
_jwtService = jwtService;
|
|
15
|
+
_tenantMapping = tenantMapping;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public override void Configure()
|
|
19
|
+
{
|
|
20
|
+
Post("/connect-tenant");
|
|
21
|
+
AllowAnonymous();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public override async Task HandleAsync(ConnectTenantRequest req, CancellationToken ct)
|
|
25
|
+
{
|
|
26
|
+
var payload = await _jwtService.VerifySessionTokenAsync(req.SessionToken);
|
|
27
|
+
|
|
28
|
+
// Use current timestamp as local tenant ID.
|
|
29
|
+
// In a real application this would come from your own auth system.
|
|
30
|
+
var localTenantId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString();
|
|
31
|
+
|
|
32
|
+
_tenantMapping.Set(localTenantId, payload.TenantId, payload.TenantSlug);
|
|
33
|
+
|
|
34
|
+
await Send.OkAsync(new
|
|
35
|
+
{
|
|
36
|
+
tenantId = localTenantId,
|
|
37
|
+
jtlTenantId = payload.TenantId,
|
|
38
|
+
tenantSlug = payload.TenantSlug
|
|
39
|
+
}, ct);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
using FastEndpoints;
|
|
2
|
+
using {{APP_NAME_PASCAL}}.Api.Services;
|
|
3
|
+
|
|
4
|
+
namespace {{APP_NAME_PASCAL}}.Api.Endpoints;
|
|
5
|
+
|
|
6
|
+
public class CurrentTenantEndpoint : EndpointWithoutRequest
|
|
7
|
+
{
|
|
8
|
+
private readonly TenantMappingService _tenantMapping;
|
|
9
|
+
|
|
10
|
+
public CurrentTenantEndpoint(TenantMappingService tenantMapping)
|
|
11
|
+
=> _tenantMapping = tenantMapping;
|
|
12
|
+
|
|
13
|
+
public override void Configure()
|
|
14
|
+
{
|
|
15
|
+
Get("/current-tenant");
|
|
16
|
+
AllowAnonymous();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public override async Task HandleAsync(CancellationToken ct)
|
|
20
|
+
{
|
|
21
|
+
var all = _tenantMapping.GetAll();
|
|
22
|
+
if (all.Count == 0)
|
|
23
|
+
{
|
|
24
|
+
await Send.NotFoundAsync(cancellation: ct);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
var latest = all.MaxBy(kv => long.TryParse(kv.Key, out var n) ? n : 0);
|
|
29
|
+
|
|
30
|
+
await Send.OkAsync(new
|
|
31
|
+
{
|
|
32
|
+
tenantId = latest.Key,
|
|
33
|
+
jtlTenantId = latest.Value.JtlTenantId,
|
|
34
|
+
tenantSlug = latest.Value.TenantSlug
|
|
35
|
+
}, ct);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
using System.Text.Json;
|
|
2
|
+
using FastEndpoints;
|
|
3
|
+
using {{APP_NAME_PASCAL}}.Api.Services;
|
|
4
|
+
|
|
5
|
+
namespace {{APP_NAME_PASCAL}}.Api.Endpoints;
|
|
6
|
+
|
|
7
|
+
/// <summary>
|
|
8
|
+
/// Proxies requests to the JTL Platform ERP API.
|
|
9
|
+
/// Resolves the local tenant ID to the JTL tenant ID before forwarding.
|
|
10
|
+
/// </summary>
|
|
11
|
+
public class ErpInfoEndpoint : EndpointWithoutRequest
|
|
12
|
+
{
|
|
13
|
+
private readonly ErpApiService _erpApiService;
|
|
14
|
+
private readonly TenantMappingService _tenantMapping;
|
|
15
|
+
|
|
16
|
+
public ErpInfoEndpoint(ErpApiService erpApiService, TenantMappingService tenantMapping)
|
|
17
|
+
{
|
|
18
|
+
_erpApiService = erpApiService;
|
|
19
|
+
_tenantMapping = tenantMapping;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public override void Configure()
|
|
23
|
+
{
|
|
24
|
+
Verbs(Http.GET, Http.POST, Http.PUT, Http.PATCH, Http.DELETE);
|
|
25
|
+
Routes("/erp-info/{tenantId}/{*endpoint}");
|
|
26
|
+
AllowAnonymous();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
public override async Task HandleAsync(CancellationToken ct)
|
|
30
|
+
{
|
|
31
|
+
var localTenantId = Route<string>("tenantId")!;
|
|
32
|
+
var endpoint = Route<string>("endpoint")!;
|
|
33
|
+
var method = new HttpMethod(HttpContext.Request.Method);
|
|
34
|
+
string? jsonBody = null;
|
|
35
|
+
|
|
36
|
+
if (HttpContext.Request.Method is "POST" or "PUT" or "PATCH")
|
|
37
|
+
{
|
|
38
|
+
using var reader = new StreamReader(HttpContext.Request.Body);
|
|
39
|
+
var rawBody = await reader.ReadToEndAsync(ct);
|
|
40
|
+
|
|
41
|
+
if (!string.IsNullOrWhiteSpace(rawBody))
|
|
42
|
+
{
|
|
43
|
+
var bodyJson = JsonSerializer.Deserialize<JsonElement>(rawBody);
|
|
44
|
+
|
|
45
|
+
// Allow overriding tenantId and endpoint from request body
|
|
46
|
+
if (bodyJson.TryGetProperty("_tenantId", out var tenantOverride))
|
|
47
|
+
localTenantId = tenantOverride.GetString()!;
|
|
48
|
+
|
|
49
|
+
if (bodyJson.TryGetProperty("_endpoint", out var endpointOverride))
|
|
50
|
+
endpoint = endpointOverride.GetString()!;
|
|
51
|
+
|
|
52
|
+
var cleaned = bodyJson.EnumerateObject()
|
|
53
|
+
.Where(p => p.Name is not "_tenantId" and not "_endpoint")
|
|
54
|
+
.ToDictionary(p => p.Name, p => p.Value);
|
|
55
|
+
|
|
56
|
+
jsonBody = JsonSerializer.Serialize(cleaned);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
var entry = _tenantMapping.Get(localTenantId);
|
|
61
|
+
if (entry is null)
|
|
62
|
+
{
|
|
63
|
+
await Send.NotFoundAsync(cancellation: ct);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
var (statusCode, responseBody) = await _erpApiService.ProxyErpRequestAsync(
|
|
68
|
+
entry.JtlTenantId, endpoint, method, jsonBody);
|
|
69
|
+
|
|
70
|
+
HttpContext.Response.StatusCode = statusCode;
|
|
71
|
+
HttpContext.Response.ContentType = "application/json";
|
|
72
|
+
await HttpContext.Response.WriteAsync(responseBody, ct);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
using FastEndpoints;
|
|
2
|
+
|
|
3
|
+
namespace {{APP_NAME_PASCAL}}.Api.Endpoints;
|
|
4
|
+
|
|
5
|
+
public class HomeEndpoint : EndpointWithoutRequest<string>
|
|
6
|
+
{
|
|
7
|
+
public override void Configure()
|
|
8
|
+
{
|
|
9
|
+
Get("/");
|
|
10
|
+
AllowAnonymous();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
public override async Task HandleAsync(CancellationToken ct)
|
|
14
|
+
=> await Send.OkAsync("Hello from C# + FastEndpoints!", ct);
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
2
|
+
|
|
3
|
+
<PropertyGroup>
|
|
4
|
+
<TargetFramework>net8.0</TargetFramework>
|
|
5
|
+
<Nullable>enable</Nullable>
|
|
6
|
+
<ImplicitUsings>enable</ImplicitUsings>
|
|
7
|
+
<RootNamespace>{{APP_NAME_PASCAL}}.Api</RootNamespace>
|
|
8
|
+
</PropertyGroup>
|
|
9
|
+
|
|
10
|
+
<ItemGroup>
|
|
11
|
+
<!-- Ed25519 signature verification for JWKS token validation -->
|
|
12
|
+
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
|
|
13
|
+
<!-- FastEndpoints + Swagger -->
|
|
14
|
+
<PackageReference Include="FastEndpoints" Version="7.1.1" />
|
|
15
|
+
<PackageReference Include="FastEndpoints.Swagger" Version="7.1.1" />
|
|
16
|
+
</ItemGroup>
|
|
17
|
+
|
|
18
|
+
</Project>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
using FastEndpoints;
|
|
2
|
+
using FastEndpoints.Swagger;
|
|
3
|
+
using {{APP_NAME_PASCAL}}.Api.Services;
|
|
4
|
+
|
|
5
|
+
var builder = WebApplication.CreateBuilder(args);
|
|
6
|
+
|
|
7
|
+
builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false);
|
|
8
|
+
|
|
9
|
+
builder.Services.AddFastEndpoints();
|
|
10
|
+
builder.Services.SwaggerDocument();
|
|
11
|
+
|
|
12
|
+
builder.Services.AddHttpClient();
|
|
13
|
+
builder.Services.AddSingleton<TenantMappingService>();
|
|
14
|
+
builder.Services.AddScoped<ErpApiService>();
|
|
15
|
+
builder.Services.AddScoped<JwtVerificationService>();
|
|
16
|
+
|
|
17
|
+
builder.Services.AddCors(options =>
|
|
18
|
+
options.AddDefaultPolicy(policy =>
|
|
19
|
+
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()));
|
|
20
|
+
|
|
21
|
+
var app = builder.Build();
|
|
22
|
+
|
|
23
|
+
app.UseCors();
|
|
24
|
+
app.UseFastEndpoints();
|
|
25
|
+
|
|
26
|
+
if (app.Environment.IsDevelopment())
|
|
27
|
+
app.UseSwaggerGen();
|
|
28
|
+
|
|
29
|
+
app.Run();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
3
|
+
"profiles": {
|
|
4
|
+
"http": {
|
|
5
|
+
"commandName": "Project",
|
|
6
|
+
"dotnetRunMessages": true,
|
|
7
|
+
"launchBrowser": false,
|
|
8
|
+
"applicationUrl": "http://localhost:50143",
|
|
9
|
+
"environmentVariables": {
|
|
10
|
+
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"http (with Swagger)": {
|
|
14
|
+
"commandName": "Project",
|
|
15
|
+
"dotnetRunMessages": true,
|
|
16
|
+
"launchBrowser": true,
|
|
17
|
+
"launchUrl": "swagger",
|
|
18
|
+
"applicationUrl": "http://localhost:50143",
|
|
19
|
+
"environmentVariables": {
|
|
20
|
+
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
using System.Text;
|
|
2
|
+
using System.Text.Json;
|
|
3
|
+
|
|
4
|
+
namespace {{APP_NAME_PASCAL}}.Api.Services;
|
|
5
|
+
|
|
6
|
+
public class ErpApiService
|
|
7
|
+
{
|
|
8
|
+
private readonly IHttpClientFactory _httpClientFactory;
|
|
9
|
+
private readonly IConfiguration _configuration;
|
|
10
|
+
private readonly ILogger<ErpApiService> _logger;
|
|
11
|
+
|
|
12
|
+
public ErpApiService(
|
|
13
|
+
IHttpClientFactory httpClientFactory,
|
|
14
|
+
IConfiguration configuration,
|
|
15
|
+
ILogger<ErpApiService> logger)
|
|
16
|
+
{
|
|
17
|
+
_httpClientFactory = httpClientFactory;
|
|
18
|
+
_configuration = configuration;
|
|
19
|
+
_logger = logger;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// <summary>
|
|
23
|
+
/// Fetches a short-lived access token via the OAuth2 client credentials flow.
|
|
24
|
+
/// Equivalent to getJwt() in the Node.js sample.
|
|
25
|
+
/// </summary>
|
|
26
|
+
public async Task<string> GetAccessTokenAsync()
|
|
27
|
+
{
|
|
28
|
+
var clientId = _configuration["JtlPlatform:ClientId"];
|
|
29
|
+
var clientSecret = _configuration["JtlPlatform:ClientSecret"];
|
|
30
|
+
|
|
31
|
+
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
|
|
32
|
+
throw new InvalidOperationException(
|
|
33
|
+
"JtlPlatform:ClientId and JtlPlatform:ClientSecret must be set in appsettings.Local.json " +
|
|
34
|
+
"or via environment variables JtlPlatform__ClientId / JtlPlatform__ClientSecret.");
|
|
35
|
+
|
|
36
|
+
_logger.LogInformation("Fetching access token for client {ClientId}", clientId);
|
|
37
|
+
|
|
38
|
+
var authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{clientId}:{clientSecret}"));
|
|
39
|
+
var client = _httpClientFactory.CreateClient();
|
|
40
|
+
client.DefaultRequestHeaders.Authorization =
|
|
41
|
+
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
|
|
42
|
+
|
|
43
|
+
var body = new FormUrlEncodedContent([
|
|
44
|
+
new KeyValuePair<string, string>("grant_type", "client_credentials")
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
var response = await client.PostAsync(GetAuthEndpoint(), body);
|
|
48
|
+
var json = await response.Content.ReadAsStringAsync();
|
|
49
|
+
|
|
50
|
+
if (!response.IsSuccessStatusCode)
|
|
51
|
+
{
|
|
52
|
+
var error = JsonSerializer.Deserialize<JsonElement>(json);
|
|
53
|
+
throw new InvalidOperationException(
|
|
54
|
+
$"Failed to fetch access token ({response.StatusCode}): {error.GetProperty("error")}");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
|
58
|
+
return data.GetProperty("access_token").GetString()!;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// <summary>
|
|
62
|
+
/// Proxies an arbitrary HTTP request to the JTL Platform ERP API.
|
|
63
|
+
/// Equivalent to the /erp-info route in the Node.js sample.
|
|
64
|
+
/// </summary>
|
|
65
|
+
public async Task<(int StatusCode, string Body)> ProxyErpRequestAsync(
|
|
66
|
+
string tenantId, string endpoint, HttpMethod method, string? jsonBody)
|
|
67
|
+
{
|
|
68
|
+
var jwt = await GetAccessTokenAsync();
|
|
69
|
+
var client = _httpClientFactory.CreateClient();
|
|
70
|
+
client.DefaultRequestHeaders.Authorization =
|
|
71
|
+
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", jwt);
|
|
72
|
+
client.DefaultRequestHeaders.Add("X-Tenant-ID", tenantId);
|
|
73
|
+
|
|
74
|
+
var request = new HttpRequestMessage(method,
|
|
75
|
+
$"https://api{EnvironmentSuffix}.jtl-cloud.com/erp/{endpoint}");
|
|
76
|
+
|
|
77
|
+
if (jsonBody is not null && (method == HttpMethod.Post || method == HttpMethod.Put || method == HttpMethod.Patch))
|
|
78
|
+
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
|
|
79
|
+
|
|
80
|
+
var response = await client.SendAsync(request);
|
|
81
|
+
var responseBody = await response.Content.ReadAsStringAsync();
|
|
82
|
+
return ((int)response.StatusCode, responseBody);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private string GetAuthEndpoint()
|
|
86
|
+
{
|
|
87
|
+
// prod and beta share the same auth host
|
|
88
|
+
var env = _configuration["JtlPlatform:Environment"] ?? "prod";
|
|
89
|
+
return env is "prod" or "beta"
|
|
90
|
+
? "https://auth.jtl-cloud.com/oauth2/token"
|
|
91
|
+
: $"https://auth.{env}.jtl-cloud.com/oauth2/token";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private string EnvironmentSuffix
|
|
95
|
+
{
|
|
96
|
+
get
|
|
97
|
+
{
|
|
98
|
+
var env = _configuration["JtlPlatform:Environment"] ?? "prod";
|
|
99
|
+
return env == "prod" ? "" : $".{env}";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
using System.Text;
|
|
2
|
+
using System.Text.Json;
|
|
3
|
+
using {{APP_NAME_PASCAL}}.Api.Models;
|
|
4
|
+
using NSec.Cryptography;
|
|
5
|
+
|
|
6
|
+
namespace {{APP_NAME_PASCAL}}.Api.Services;
|
|
7
|
+
|
|
8
|
+
/// <summary>
|
|
9
|
+
/// Verifies JWTs signed with Ed25519 (EdDSA / OKP keys) using the JTL Platform JWKS endpoint.
|
|
10
|
+
/// Equivalent to verifySessionTokenAndExtractPayload() in the Node.js sample.
|
|
11
|
+
/// </summary>
|
|
12
|
+
public class JwtVerificationService
|
|
13
|
+
{
|
|
14
|
+
private readonly IHttpClientFactory _httpClientFactory;
|
|
15
|
+
private readonly ErpApiService _erpApiService;
|
|
16
|
+
private readonly IConfiguration _configuration;
|
|
17
|
+
private readonly ILogger<JwtVerificationService> _logger;
|
|
18
|
+
|
|
19
|
+
public JwtVerificationService(
|
|
20
|
+
IHttpClientFactory httpClientFactory,
|
|
21
|
+
ErpApiService erpApiService,
|
|
22
|
+
IConfiguration configuration,
|
|
23
|
+
ILogger<JwtVerificationService> logger)
|
|
24
|
+
{
|
|
25
|
+
_httpClientFactory = httpClientFactory;
|
|
26
|
+
_erpApiService = erpApiService;
|
|
27
|
+
_configuration = configuration;
|
|
28
|
+
_logger = logger;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public async Task<SessionTokenPayload> VerifySessionTokenAsync(string sessionToken)
|
|
32
|
+
{
|
|
33
|
+
var env = _configuration["JtlPlatform:Environment"] ?? "prod";
|
|
34
|
+
var suffix = env == "prod" ? "" : $".{env}";
|
|
35
|
+
var wellKnownUrl = $"https://api{suffix}.jtl-cloud.com/account/.well-known/jwks.json";
|
|
36
|
+
|
|
37
|
+
// Use client credentials token to access the JWKS endpoint
|
|
38
|
+
var accessToken = await _erpApiService.GetAccessTokenAsync();
|
|
39
|
+
|
|
40
|
+
var client = _httpClientFactory.CreateClient();
|
|
41
|
+
client.DefaultRequestHeaders.Authorization =
|
|
42
|
+
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
|
|
43
|
+
|
|
44
|
+
var response = await client.GetAsync(wellKnownUrl);
|
|
45
|
+
response.EnsureSuccessStatusCode();
|
|
46
|
+
|
|
47
|
+
var jwksJson = await response.Content.ReadAsStringAsync();
|
|
48
|
+
var jwks = JsonSerializer.Deserialize<JsonElement>(jwksJson);
|
|
49
|
+
|
|
50
|
+
// Use the first key in the JWKS (OKP / Ed25519)
|
|
51
|
+
var key = jwks.GetProperty("keys")[0];
|
|
52
|
+
var publicKeyBytes = Base64UrlDecode(key.GetProperty("x").GetString()!);
|
|
53
|
+
|
|
54
|
+
return VerifyAndDecode(sessionToken, publicKeyBytes);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private SessionTokenPayload VerifyAndDecode(string token, byte[] publicKeyBytes)
|
|
58
|
+
{
|
|
59
|
+
var parts = token.Split('.');
|
|
60
|
+
if (parts.Length != 3)
|
|
61
|
+
throw new ArgumentException("Invalid JWT format – expected 3 dot-separated parts.");
|
|
62
|
+
|
|
63
|
+
// The signed data is the ASCII bytes of "header.payload"
|
|
64
|
+
var signedData = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
|
|
65
|
+
var signature = Base64UrlDecode(parts[2]);
|
|
66
|
+
|
|
67
|
+
var algorithm = SignatureAlgorithm.Ed25519;
|
|
68
|
+
var publicKey = PublicKey.Import(algorithm, publicKeyBytes, KeyBlobFormat.RawPublicKey);
|
|
69
|
+
|
|
70
|
+
if (!algorithm.Verify(publicKey, signedData, signature))
|
|
71
|
+
{
|
|
72
|
+
_logger.LogError("JWT signature verification failed");
|
|
73
|
+
throw new UnauthorizedAccessException("Invalid token signature.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_logger.LogInformation("JWT signature verified successfully");
|
|
77
|
+
|
|
78
|
+
var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[1]));
|
|
79
|
+
var payload = JsonSerializer.Deserialize<JsonElement>(payloadJson);
|
|
80
|
+
|
|
81
|
+
return new SessionTokenPayload(
|
|
82
|
+
UserId: payload.GetProperty("userId").GetString()!,
|
|
83
|
+
TenantId: payload.GetProperty("tenantId").GetString()!,
|
|
84
|
+
TenantSlug: payload.TryGetProperty("tenantSlug", out var slug) ? slug.GetString() : null
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private static byte[] Base64UrlDecode(string input)
|
|
89
|
+
{
|
|
90
|
+
var padded = input.Replace('-', '+').Replace('_', '/');
|
|
91
|
+
padded += (padded.Length % 4) switch
|
|
92
|
+
{
|
|
93
|
+
2 => "==",
|
|
94
|
+
3 => "=",
|
|
95
|
+
_ => ""
|
|
96
|
+
};
|
|
97
|
+
return Convert.FromBase64String(padded);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
using System.Collections.Concurrent;
|
|
2
|
+
using System.Text.Json;
|
|
3
|
+
|
|
4
|
+
namespace {{APP_NAME_PASCAL}}.Api.Services;
|
|
5
|
+
|
|
6
|
+
/// <summary>
|
|
7
|
+
/// Persists the mapping between this application's local tenant IDs and JTL Platform tenant IDs.
|
|
8
|
+
/// Data is stored as JSON in the platform-appropriate user data directory:
|
|
9
|
+
/// Windows : %APPDATA%\{{APP_NAME_PASCAL}}\tenant-mapping.json
|
|
10
|
+
/// Linux : ~/.config/{{APP_NAME_PASCAL}}/tenant-mapping.json
|
|
11
|
+
/// macOS : ~/Library/Application Support/{{APP_NAME_PASCAL}}/tenant-mapping.json
|
|
12
|
+
/// </summary>
|
|
13
|
+
public class TenantMappingService
|
|
14
|
+
{
|
|
15
|
+
private readonly string _filePath;
|
|
16
|
+
private readonly ILogger<TenantMappingService> _logger;
|
|
17
|
+
private readonly ConcurrentDictionary<string, TenantEntry> _cache;
|
|
18
|
+
|
|
19
|
+
public TenantMappingService(ILogger<TenantMappingService> logger)
|
|
20
|
+
{
|
|
21
|
+
_logger = logger;
|
|
22
|
+
|
|
23
|
+
var dataDir = Path.Combine(
|
|
24
|
+
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
25
|
+
"{{APP_NAME_PASCAL}}");
|
|
26
|
+
|
|
27
|
+
Directory.CreateDirectory(dataDir);
|
|
28
|
+
_filePath = Path.Combine(dataDir, "tenant-mapping.json");
|
|
29
|
+
|
|
30
|
+
_cache = Load();
|
|
31
|
+
_logger.LogInformation("Tenant mapping loaded from {Path} ({Count} entries)", _filePath, _cache.Count);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
public void Set(string localTenantId, string jtlTenantId, string? tenantSlug = null)
|
|
35
|
+
{
|
|
36
|
+
_cache[localTenantId] = new TenantEntry(jtlTenantId, tenantSlug);
|
|
37
|
+
Save();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public TenantEntry? Get(string localTenantId) =>
|
|
41
|
+
_cache.TryGetValue(localTenantId, out var entry) ? entry : null;
|
|
42
|
+
|
|
43
|
+
public IReadOnlyDictionary<string, TenantEntry> GetAll() => _cache;
|
|
44
|
+
|
|
45
|
+
/// <summary>Looks up local tenant ID by JTL Platform tenant ID.</summary>
|
|
46
|
+
public (string LocalTenantId, TenantEntry Entry)? FindByJtlTenantId(string jtlTenantId)
|
|
47
|
+
{
|
|
48
|
+
var match = _cache.FirstOrDefault(kv => kv.Value.JtlTenantId == jtlTenantId);
|
|
49
|
+
return match.Key is null ? null : (match.Key, match.Value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private ConcurrentDictionary<string, TenantEntry> Load()
|
|
53
|
+
{
|
|
54
|
+
try
|
|
55
|
+
{
|
|
56
|
+
if (!File.Exists(_filePath))
|
|
57
|
+
return new ConcurrentDictionary<string, TenantEntry>();
|
|
58
|
+
|
|
59
|
+
var json = File.ReadAllText(_filePath);
|
|
60
|
+
var data = JsonSerializer.Deserialize<Dictionary<string, TenantEntry>>(json);
|
|
61
|
+
return data is null
|
|
62
|
+
? new ConcurrentDictionary<string, TenantEntry>()
|
|
63
|
+
: new ConcurrentDictionary<string, TenantEntry>(data);
|
|
64
|
+
}
|
|
65
|
+
catch (Exception ex)
|
|
66
|
+
{
|
|
67
|
+
_logger.LogWarning(ex, "Could not load tenant mapping from {Path}, starting fresh", _filePath);
|
|
68
|
+
return new ConcurrentDictionary<string, TenantEntry>();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private void Save()
|
|
73
|
+
{
|
|
74
|
+
try
|
|
75
|
+
{
|
|
76
|
+
var json = JsonSerializer.Serialize(_cache, new JsonSerializerOptions { WriteIndented = true });
|
|
77
|
+
File.WriteAllText(_filePath, json);
|
|
78
|
+
}
|
|
79
|
+
catch (Exception ex)
|
|
80
|
+
{
|
|
81
|
+
_logger.LogError(ex, "Could not persist tenant mapping to {Path}", _filePath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public record TenantEntry(string JtlTenantId, string? TenantSlug);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Logging": {
|
|
3
|
+
"LogLevel": {
|
|
4
|
+
"Default": "Information",
|
|
5
|
+
"Microsoft.AspNetCore": "Warning"
|
|
6
|
+
}
|
|
7
|
+
},
|
|
8
|
+
"AllowedHosts": "*",
|
|
9
|
+
"JtlPlatform": {
|
|
10
|
+
// "prod" | "dev" | "beta" | "qa"
|
|
11
|
+
// Translates to the API host suffix: prod → no suffix, others → .<env>
|
|
12
|
+
"Environment": "prod",
|
|
13
|
+
// Set these in appsettings.Local.json or via environment variables:
|
|
14
|
+
// JtlPlatform__ClientId and JtlPlatform__ClientSecret
|
|
15
|
+
"ClientId": "",
|
|
16
|
+
"ClientSecret": ""
|
|
17
|
+
}
|
|
18
|
+
}
|