|
| 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 | +} |
0 commit comments