Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -12,10 +12,18 @@ namespace Management.Security;
/// Authentication middleware for Management API.
///
/// Auth paths:
/// - /api/onboarding/* → JWT (user may not have session yet)
/// - /api/admin/* → Session + Admin role
/// - /api/monitoring/* → Session + Admin role
/// - /api/test/* → Anonymous
/// - /api/onboarding/* → JWT (Entra, any staff role)
/// - /api/monitoring/* → JWT (Entra, Staff.Admin or Staff.Tech role)
/// - /api/admin/* → JWT (Entra, Staff.Admin role only)
/// - /api/registration/*→ JWT (Entra, Staff.Admin role only)
/// - /api/staff/* → JWT (Entra, Staff.Admin role only)
/// - /api/test/* → Anonymous
/// - /api/documents/* → JWT (Entra, Staff.Admin or Staff.Tech role)
/// - /api/help/* → Anonymous
///
/// App Role values (defined in Entra portal on the staff app registration):
/// Staff.Admin → full platform access
/// Staff.Tech → monitoring/health only
/// </summary>
public sealed class ClientAuthMiddleware
{
@@ -28,9 +36,10 @@ public sealed class ClientAuthMiddleware
"/", "/health"
};
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" };
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test", "/api/help" };
private static readonly string[] _jwtOnlyPrefixes = { "/api/onboarding" };
private static readonly string[] _adminRequiredPrefixes = { "/api/monitoring", "/api/admin" };
private static readonly string[] _staffRequiredPrefixes = { "/api/monitoring", "/api/documents" }; // Admin or Tech
private static readonly string[] _adminRequiredPrefixes = { "/api/admin", "/api/registration", "/api/staff" }; // Admin only
private static ConfigurationManager<OpenIdConnectConfiguration>? _oidcConfigManager;
private static readonly object _oidcLock = new();
@@ -75,10 +84,36 @@ public sealed class ClientAuthMiddleware
return;
}
// Admin-required paths
// Staff-required paths (Admin or Tech role) — monitoring/health
// Try session auth (client session tokens for CIAM users).
// then fall back to direct JWT for service-to-service calls.
if (IsStaffRequiredPath(path))
{
var staffAuthed = await TrySessionAuthAsync(context, clientContext, sql)
|| await TryJwtAuthAsync(context, clientContext);
if (staffAuthed)
{
if (!clientContext.IsStaff)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Staff access required" });
return;
}
await _next(context);
return;
}
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid staff authentication required" });
return;
}
// Admin-required paths (Admin role only)
if (IsAdminRequiredPath(path))
{
if (await TrySessionAuthAsync(context, clientContext, sql))
var adminAuthed = await TrySessionAuthAsync(context, clientContext, sql)
|| await TryJwtAuthAsync(context, clientContext);
if (adminAuthed)
{
if (!clientContext.IsAdmin)
{
@@ -86,13 +121,12 @@ public sealed class ClientAuthMiddleware
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Admin access required" });
return;
}
await _next(context);
return;
}
context.Response.StatusCode = 401;
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin session required" });
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Valid admin authentication required" });
return;
}
@@ -113,6 +147,9 @@ public sealed class ClientAuthMiddleware
private static bool IsJwtOnlyPath(string path) =>
_jwtOnlyPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
private static bool IsStaffRequiredPath(string path) =>
_staffRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
private static bool IsAdminRequiredPath(string path) =>
_adminRequiredPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
@@ -164,8 +201,9 @@ public sealed class ClientAuthMiddleware
try
{
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
var validateProc = "dbo.spClientSession"; // Staff use JWT Bearer; only client sessions exist
var resp = await sql.ExecProcAsync(validateProc, "validate", rqst, ct: context.RequestAborted);
if (string.IsNullOrWhiteSpace(resp))
return false;
@@ -178,12 +216,20 @@ public sealed class ClientAuthMiddleware
var data = root.TryGetProperty("data", out var dataProp) ? dataProp : root;
clientContext.SessionId = data.TryGetProperty("sessionId", out var sid) ? sid.GetString() : null;
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
clientContext.PlatformClientId = clientContext.ClientId;
// Admin sessions return adminId; client sessions return clientId — handle both
var clientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
if (string.IsNullOrWhiteSpace(clientId))
clientId = data.TryGetProperty("adminId", out var aid) ? aid.GetString() : null;
clientContext.ClientId = clientId;
clientContext.PlatformClientId = clientId;
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
clientContext.UserId = data.TryGetProperty("userId", out var uid) ? uid.GetString() : null;
clientContext.Email = data.TryGetProperty("userEmail", out var ue) ? ue.GetString() : null;
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
// IsStaff is computed from Role in ClientContext — no assignment needed
return clientContext.IsAuthenticated;
}
@@ -207,9 +253,9 @@ public sealed class ClientAuthMiddleware
if (string.IsNullOrWhiteSpace(token))
return false;
var tenantId = _config["Auth:EntraId:TenantId"];
var clientId = _config["Auth:EntraId:ClientId"];
var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/";
var tenantId = _config["Auth:Staff:TenantId"];
var clientId = _config["Auth:Staff:ClientId"];
var instance = _config["Auth:Staff:Instance"] ?? "https://usimclients.ciamlogin.com/";
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
return false;
@@ -217,28 +263,44 @@ public sealed class ClientAuthMiddleware
try
{
var handler = new JwtSecurityTokenHandler();
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
// Disable default claim type remapping so JWT claim names (roles, oid, etc.)
// are preserved as-is. Without this, "roles" is remapped to ClaimTypes.Role
// and FindAll("roles") returns empty — causing IsAdmin to be false.
handler.InboundClaimTypeMap.Clear();
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
var metadataAddress = $"{authority}/.well-known/openid-configuration";
var mgr = GetOrCreateConfigManager(metadataAddress);
var mgr = GetOrCreateConfigManager(metadataAddress);
var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted);
var validationParams = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
ValidateAudience = true,
ValidAudiences = new[] { clientId, $"api://{clientId}" },
ValidateLifetime = true,
ValidateIssuer = true,
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
ValidateAudience = true,
ValidAudiences = new[] { clientId, $"api://{clientId}" },
ValidateLifetime = true,
IssuerSigningKeys = openIdConfig.SigningKeys,
ClockSkew = TimeSpan.FromMinutes(5)
ClockSkew = TimeSpan.FromMinutes(5)
};
var principal = handler.ValidateToken(token, validationParams, out _);
clientContext.ClientId = principal.FindFirstValue("oid") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
clientContext.Email = principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue(ClaimTypes.Email);
clientContext.ClientName = principal.FindFirstValue("name") ?? principal.FindFirstValue(ClaimTypes.Name);
// Map Entra App Role values to internal role names.
// Users with no recognized role get null — middleware rejects them.
var roles = principal.FindAll("roles").Select(c => c.Value).ToList();
var role = roles.Contains("Staff.Admin") ? "Admin"
: roles.Contains("Staff.Tech") ? "Tech"
: null; // no valid role assigned — reject
clientContext.ClientId = principal.FindFirstValue("oid")
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
clientContext.Email = principal.FindFirstValue("preferred_username")
?? principal.FindFirstValue(ClaimTypes.Email);
clientContext.ClientName = principal.FindFirstValue("name")
?? principal.FindFirstValue(ClaimTypes.Name);
clientContext.Role = role;
return clientContext.IsAuthenticated;
}