Skip to content

Commit 39602f8

Browse files
beyondnetPeruclaude
andcommitted
feat(sdk): add Phase 2 packages — HTTP clients and framework middleware
Extends the multi-runtime SDK with four new packages on top of the Phase 1 contracts/validator foundation. All consume the same canonical schemaVersion compatibility check. .NET Ums.Sdk.Client (1.0.0) - Typed IUmsAuthClient backed by HttpClientFactory. - Calls POST /api/v1/client/authenticate, deserializes the ClientAuthResult (token + AuthorizationGraph + bookkeeping). - Validates schemaVersion: missing → AUTH_204, unsupported MAJOR → AUTH_205. - HTTP status mapping: 400 → AUTH_001, 401 → AUTH_006, 403 → AUTH_005, 404 → AUTH_002, 423 → AUTH_007, other → AUTH_013. - AddUmsSdkClient(opt => opt.BaseAddress = …) DI extension. Ums.Sdk.Authorization.AspNetCore (1.0.0) - UmsAuthGraphMiddleware decodes the Bearer JWT body claim (configurable, default "graph") and stores the AuthorizationGraph on HttpContext.Items["UmsAuthGraph"]. - Honors RejectExpiredGraphs and RejectIncompatibleGraphs options: 401 + structured body {code, message} on AUTH_201/AUTH_204/AUTH_205. - app.UseUmsAuthGraph() / .UseUmsAuthGraph(configure) extensions. - FrameworkReference Microsoft.AspNetCore.App keeps the package shared-framework-friendly. Ums.Sdk.Tests (expanded) - UmsAuthClientTests (4): happy path, AUTH_205 on MAJOR mismatch, 401 → AUTH_006, 404 → AUTH_002. - UmsAuthGraphMiddlewareTests (4): real WebHost + TestServer: bearer decoded into HttpContext.Items, no-bearer passthrough, expired graph → 401 AUTH_201, MAJOR mismatch → 401 AUTH_205. - Total: 38/38 PASS across the .NET SDK (was 30, now 38). TypeScript @ums/sdk-client (1.0.0) - Fetch-based UmsAuthClient with injectable fetchImpl for tests. - Same schema-version pre-flight as the .NET client; same HTTP status → AUTH_xxx mapping. - Configurable timeout (AbortController). @ums/sdk-express (1.0.0) - umsAuthGraph({ accessor, rejectExpired, rejectIncompatible }) middleware. Decodes JWT body claim, runs schema-compat checks, then binds the graph through AsyncLocalAuthGraphAccessor (Node scope) or MemoryAuthGraphAccessor (single-session). - 401 + structured body on AUTH_201/AUTH_204/AUTH_205. Tests - sdk-client (4): happy path, AUTH_205 MAJOR, 401 → AUTH_006, 404 → AUTH_002. - sdk-express (5): MemoryAccessor binding, AsyncLocalAccessor binding observed inside the bound scope, AUTH_205 rejection, AUTH_201 expiry rejection, no-bearer passthrough. - Total: 42/42 PASS across the TypeScript workspace (was 33). package.json workspaces extended to include sdk-client and sdk-express. NestJS regression: 7/7 PASS unchanged. UMS SDK aggregate test count: 87 (was 70). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 061ada2 commit 39602f8

31 files changed

Lines changed: 1393 additions & 1 deletion
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Options;
4+
5+
namespace Ums.Sdk.Authorization.AspNetCore;
6+
7+
public static class ApplicationBuilderExtensions
8+
{
9+
/// <summary>
10+
/// Plugs <see cref="UmsAuthGraphMiddleware"/> into the pipeline with default options.
11+
/// </summary>
12+
public static IApplicationBuilder UseUmsAuthGraph(this IApplicationBuilder app) =>
13+
app.UseMiddleware<UmsAuthGraphMiddleware>();
14+
15+
/// <summary>
16+
/// Plugs <see cref="UmsAuthGraphMiddleware"/> with an inline configuration callback.
17+
/// </summary>
18+
public static IApplicationBuilder UseUmsAuthGraph(this IApplicationBuilder app, Action<UmsAuthGraphMiddlewareOptions> configure)
19+
{
20+
var monitor = app.ApplicationServices.GetService<IOptionsMonitor<UmsAuthGraphMiddlewareOptions>>();
21+
if (monitor is not null) configure(monitor.CurrentValue);
22+
return app.UseMiddleware<UmsAuthGraphMiddleware>();
23+
}
24+
25+
/// <summary>Registers the middleware options for DI.</summary>
26+
public static IServiceCollection AddUmsAuthGraphMiddleware(this IServiceCollection services)
27+
{
28+
services.AddOptions<UmsAuthGraphMiddlewareOptions>();
29+
return services;
30+
}
31+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Ums.Sdk.Authorization.AspNetCore
2+
3+
> Part of the [UMS SDK](https://github.com/beyondnetcode/ums/tree/main/docs/sdk).
4+
5+
ASP.NET Core middleware that places the `AuthorizationGraph` on `HttpContext.Items` for the rest of the pipeline (validator, AOP aspect, controllers).
6+
7+
## Install
8+
9+
```bash
10+
dotnet add package Ums.Sdk.Authorization.AspNetCore
11+
```
12+
13+
## Use
14+
15+
```csharp
16+
builder.Services.AddUmsSdkAuthorization();
17+
builder.Services.AddHttpContextAuthGraphAccessor();
18+
19+
var app = builder.Build();
20+
21+
// Decodes JWT body → AuthorizationGraph → HttpContext.Items.
22+
app.UseUmsAuthGraph(options =>
23+
{
24+
options.JwtBodyClaim = "graph"; // claim name carrying the serialized graph
25+
options.RejectExpiredGraphs = true;
26+
});
27+
28+
app.UseAuthorization();
29+
```
30+
31+
The middleware reads the `Authorization: Bearer ...` header, decodes the JWT body, and stores the resulting `AuthorizationGraph` instance at `HttpContext.Items["UmsAuthGraph"]`.
32+
33+
## License
34+
35+
MIT — see [LICENSE](https://github.com/beyondnetcode/ums/blob/main/src/libs/sdk/LICENSE).
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>true</IsPackable>
8+
<PackageId>Ums.Sdk.Authorization.AspNetCore</PackageId>
9+
<Version>1.0.0</Version>
10+
<Description>UMS SDK — ASP.NET Core integration: UseUmsAuthGraph middleware that decodes the per-request authorization graph and stores it on HttpContext.Items for the validator/aspect to consume. See ADR-0073.</Description>
11+
<Authors>BeyondNetCode</Authors>
12+
<PackageProjectUrl>https://github.com/beyondnetcode/ums</PackageProjectUrl>
13+
<PackageLicenseExpression>MIT</PackageLicenseExpression>
14+
<PackageTags>ums;sdk;authorization;aspnetcore;middleware</PackageTags>
15+
<PackageReadmeFile>README.md</PackageReadmeFile>
16+
<UmsSchemaCompatibility>&gt;=1.0.0 &lt;2.0.0</UmsSchemaCompatibility>
17+
</PropertyGroup>
18+
19+
<ItemGroup>
20+
<None Include="README.md" Pack="true" PackagePath="\" />
21+
</ItemGroup>
22+
23+
<ItemGroup>
24+
<ProjectReference Include="../Ums.Sdk.Contracts/Ums.Sdk.Contracts.csproj" />
25+
<ProjectReference Include="../Ums.Sdk.Authorization/Ums.Sdk.Authorization.csproj" />
26+
</ItemGroup>
27+
28+
<ItemGroup>
29+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
30+
</ItemGroup>
31+
32+
<ItemGroup>
33+
<InternalsVisibleTo Include="Ums.Sdk.Tests" />
34+
</ItemGroup>
35+
36+
</Project>
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Logging;
5+
using Microsoft.Extensions.Options;
6+
using Ums.Sdk.Authorization;
7+
using Ums.Sdk.Contracts;
8+
9+
namespace Ums.Sdk.Authorization.AspNetCore;
10+
11+
/// <summary>
12+
/// Middleware that extracts the AuthorizationGraph from an incoming bearer JWT and stores it on
13+
/// <c>HttpContext.Items["UmsAuthGraph"]</c> so the <see cref="HttpContextAuthGraphAccessor"/>
14+
/// (and any downstream validator or AOP aspect) can read it.
15+
///
16+
/// The JWT is expected to be a 3-segment compact serialization. The payload section is base64url-
17+
/// decoded as JSON and one of its top-level claims is treated as the serialized graph: either an
18+
/// embedded object (when the claim is itself the graph) or a string containing the JSON to
19+
/// re-parse (when the server packaged the graph as a string claim).
20+
/// </summary>
21+
public sealed class UmsAuthGraphMiddleware
22+
{
23+
private static readonly JsonSerializerOptions JsonOptions = new()
24+
{
25+
PropertyNameCaseInsensitive = false
26+
};
27+
28+
private readonly RequestDelegate _next;
29+
private readonly IOptions<UmsAuthGraphMiddlewareOptions> _options;
30+
private readonly ILogger<UmsAuthGraphMiddleware>? _logger;
31+
32+
public UmsAuthGraphMiddleware(
33+
RequestDelegate next,
34+
IOptions<UmsAuthGraphMiddlewareOptions> options,
35+
ILogger<UmsAuthGraphMiddleware>? logger = null)
36+
{
37+
_next = next;
38+
_options = options;
39+
_logger = logger;
40+
}
41+
42+
public async Task InvokeAsync(HttpContext context)
43+
{
44+
var opt = _options.Value;
45+
var graph = ExtractGraph(context, opt);
46+
47+
if (graph is not null)
48+
{
49+
if (string.IsNullOrWhiteSpace(graph.SchemaVersion))
50+
{
51+
if (opt.RejectIncompatibleGraphs)
52+
{
53+
await Reject(context, "AUTH_204", "AuthorizationGraph payload does not declare a schemaVersion.").ConfigureAwait(false);
54+
return;
55+
}
56+
_logger?.LogWarning("UmsAuthGraphMiddleware: incoming graph lacks schemaVersion — proceeding without binding.");
57+
}
58+
else if (!SchemaVersion.IsSupported(graph.SchemaVersion))
59+
{
60+
if (opt.RejectIncompatibleGraphs)
61+
{
62+
await Reject(context, "AUTH_205",
63+
$"Server emitted schemaVersion '{graph.SchemaVersion}' which is outside SDK compatibility.").ConfigureAwait(false);
64+
return;
65+
}
66+
_logger?.LogWarning("UmsAuthGraphMiddleware: incompatible schemaVersion '{Version}' — proceeding without binding.", graph.SchemaVersion);
67+
}
68+
else if (opt.RejectExpiredGraphs && graph.ValidUntil <= DateTimeOffset.UtcNow)
69+
{
70+
await Reject(context, "AUTH_201", "AuthorizationGraph has expired.").ConfigureAwait(false);
71+
return;
72+
}
73+
else
74+
{
75+
context.Items[HttpContextAuthGraphAccessor.ItemsKey] = graph;
76+
}
77+
}
78+
79+
await _next(context).ConfigureAwait(false);
80+
}
81+
82+
private AuthorizationGraph? ExtractGraph(HttpContext context, UmsAuthGraphMiddlewareOptions opt)
83+
{
84+
string? token = ExtractBearerToken(context);
85+
if (token is null) return null;
86+
87+
// Three-segment compact JWT.
88+
var segments = token.Split('.');
89+
if (segments.Length < 2) return null;
90+
91+
byte[] payload;
92+
try
93+
{
94+
payload = Base64UrlDecode(segments[1]);
95+
}
96+
catch
97+
{
98+
_logger?.LogWarning("UmsAuthGraphMiddleware: JWT payload is not valid base64url.");
99+
return null;
100+
}
101+
102+
JsonElement payloadJson;
103+
try
104+
{
105+
payloadJson = JsonSerializer.Deserialize<JsonElement>(payload, JsonOptions);
106+
}
107+
catch
108+
{
109+
_logger?.LogWarning("UmsAuthGraphMiddleware: JWT payload is not valid JSON.");
110+
return null;
111+
}
112+
113+
if (!payloadJson.TryGetProperty(opt.JwtBodyClaim, out var graphClaim))
114+
return null;
115+
116+
try
117+
{
118+
return graphClaim.ValueKind switch
119+
{
120+
JsonValueKind.Object => graphClaim.Deserialize<AuthorizationGraph>(JsonOptions),
121+
JsonValueKind.String => JsonSerializer.Deserialize<AuthorizationGraph>(graphClaim.GetString()!, JsonOptions),
122+
_ => null
123+
};
124+
}
125+
catch (JsonException ex)
126+
{
127+
_logger?.LogWarning(ex, "UmsAuthGraphMiddleware: failed to deserialize '{Claim}' claim.", opt.JwtBodyClaim);
128+
return null;
129+
}
130+
}
131+
132+
private static string? ExtractBearerToken(HttpContext context)
133+
{
134+
string? header = context.Request.Headers.Authorization.ToString();
135+
if (string.IsNullOrWhiteSpace(header)) return null;
136+
const string prefix = "Bearer ";
137+
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return null;
138+
var token = header[prefix.Length..].Trim();
139+
return token.Length == 0 ? null : token;
140+
}
141+
142+
private static byte[] Base64UrlDecode(string input)
143+
{
144+
var normalized = input.Replace('-', '+').Replace('_', '/');
145+
switch (normalized.Length % 4)
146+
{
147+
case 2: normalized += "=="; break;
148+
case 3: normalized += "="; break;
149+
case 1: throw new FormatException("Invalid base64url length.");
150+
}
151+
return Convert.FromBase64String(normalized);
152+
}
153+
154+
private static async Task Reject(HttpContext context, string code, string message)
155+
{
156+
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
157+
context.Response.ContentType = "application/json";
158+
var body = JsonSerializer.Serialize(new { code, message });
159+
await context.Response.Body.WriteAsync(Encoding.UTF8.GetBytes(body)).ConfigureAwait(false);
160+
}
161+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Ums.Sdk.Authorization.AspNetCore;
2+
3+
/// <summary>Behavior knobs for <see cref="UmsAuthGraphMiddleware"/>.</summary>
4+
public sealed class UmsAuthGraphMiddlewareOptions
5+
{
6+
/// <summary>JWT body claim that carries the serialized graph. Default: <c>"graph"</c>.</summary>
7+
public string JwtBodyClaim { get; set; } = "graph";
8+
9+
/// <summary>When true, the middleware returns 401 + AUTH_201 when the bound graph is expired.</summary>
10+
public bool RejectExpiredGraphs { get; set; } = false;
11+
12+
/// <summary>
13+
/// When true, the middleware returns 401 + AUTH_204/AUTH_205 on missing or incompatible
14+
/// schemaVersion. When false (default), the graph is silently dropped and the request
15+
/// proceeds without authorization context — downstream code will see <c>Current = null</c>.
16+
/// </summary>
17+
public bool RejectIncompatibleGraphs { get; set; } = true;
18+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Ums.Sdk.Client;
4+
5+
/// <summary>Body sent to <c>POST /api/v1/client/authenticate</c>.</summary>
6+
public sealed record ClientAuthRequest(
7+
[property: JsonPropertyName("tenantCode")] string TenantCode,
8+
[property: JsonPropertyName("username")] string Username,
9+
[property: JsonPropertyName("password")] string Password,
10+
[property: JsonPropertyName("format")] string? Format = "JSON");
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Text.Json.Serialization;
2+
using Ums.Sdk.Contracts;
3+
4+
namespace Ums.Sdk.Client;
5+
6+
/// <summary>
7+
/// Successful response from <c>POST /api/v1/client/authenticate</c>. Carries the JWT used for
8+
/// subsequent calls, the deserialized graph, and bookkeeping fields.
9+
/// </summary>
10+
public sealed record ClientAuthResult(
11+
[property: JsonPropertyName("token")] string Token,
12+
[property: JsonPropertyName("tokenType")] string TokenType,
13+
[property: JsonPropertyName("expiresIn")] int ExpiresInSeconds,
14+
[property: JsonPropertyName("issuedAt")] DateTimeOffset IssuedAt,
15+
[property: JsonPropertyName("format")] string Format,
16+
[property: JsonPropertyName("graph")] AuthorizationGraph Graph,
17+
[property: JsonPropertyName("requestId")] Guid RequestId);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using Ums.Sdk.Authorization;
2+
3+
namespace Ums.Sdk.Client;
4+
5+
/// <summary>HTTP client for the UMS authentication surface.</summary>
6+
public interface IUmsAuthClient
7+
{
8+
Task<Result<ClientAuthResult>> AuthenticateAsync(ClientAuthRequest request, CancellationToken ct = default);
9+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Ums.Sdk.Client
2+
3+
> Part of the [UMS SDK](https://github.com/beyondnetcode/ums/tree/main/docs/sdk).
4+
5+
Typed HTTP client for `POST /api/v1/client/authenticate`. Calls UMS, deserializes the `AuthorizationGraph`, validates schema compatibility and returns a typed `Result<ClientAuthResult>`.
6+
7+
## Install
8+
9+
```bash
10+
dotnet add package Ums.Sdk.Client
11+
```
12+
13+
## Use
14+
15+
```csharp
16+
builder.Services.AddUmsSdkClient(opt => opt.BaseAddress = new Uri("https://ums.example.com"));
17+
18+
public class Login(IUmsAuthClient client)
19+
{
20+
public async Task<Result<ClientAuthResult>> AuthenticateAsync(string tenant, string user, string password)
21+
=> await client.AuthenticateAsync(new ClientAuthRequest(tenant, user, password));
22+
}
23+
```
24+
25+
The response includes the JWT (`Token`), the `AuthorizationGraph` and the schema compat outcome.
26+
27+
## License
28+
29+
MIT — see [LICENSE](https://github.com/beyondnetcode/ums/blob/main/src/libs/sdk/LICENSE).
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.DependencyInjection.Extensions;
3+
4+
namespace Ums.Sdk.Client;
5+
6+
public static class ServiceCollectionExtensions
7+
{
8+
/// <summary>
9+
/// Registers a typed <see cref="IUmsAuthClient"/> backed by HttpClientFactory.
10+
/// Configure the base address through <paramref name="configure"/>.
11+
/// </summary>
12+
public static IServiceCollection AddUmsSdkClient(this IServiceCollection services, Action<UmsSdkClientOptions> configure)
13+
{
14+
ArgumentNullException.ThrowIfNull(configure);
15+
services.AddOptions<UmsSdkClientOptions>().Configure(configure);
16+
services.AddHttpClient<IUmsAuthClient, UmsAuthClient>();
17+
services.TryAddSingleton<IUmsAuthClient>(sp => sp.GetRequiredService<UmsAuthClient>());
18+
return services;
19+
}
20+
}

0 commit comments

Comments
 (0)