Initial import into Gitea
This commit is contained in:
396
Gateway/Security/AuthorizationGuard.cs
Normal file
396
Gateway/Security/AuthorizationGuard.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Centralized authorization guard for resource ownership, role checks,
|
||||
/// and status transition enforcement.
|
||||
///
|
||||
/// DEFENSE IN DEPTH:
|
||||
/// Layer 1: Middleware authenticates session → populates ClientContext
|
||||
/// Layer 2: This guard validates resource ownership before operations
|
||||
/// Layer 3: Stored procs SHOULD also have WHERE clientId = @clientId
|
||||
///
|
||||
/// All public methods return (bool Allowed, string? Error) to keep
|
||||
/// controller code clean and consistent.
|
||||
/// </summary>
|
||||
public sealed class AuthorizationGuard
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ILogger<AuthorizationGuard> _log;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public AuthorizationGuard(
|
||||
SqlService sql,
|
||||
ClientContext client,
|
||||
ILogger<AuthorizationGuard> log,
|
||||
IConfiguration config,
|
||||
IHttpContextAccessor http)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_log = log;
|
||||
_config = config;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// SERVICE KEY CHECK
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate an internal service-to-service call via X-Service-Key header.
|
||||
/// Used by provider containers and background services that cannot carry
|
||||
/// a CIAM session token. Configure via INTERNAL_SERVICE_KEY env var.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) RequireServiceKey()
|
||||
{
|
||||
var expected = _config["INTERNAL_SERVICE_KEY"];
|
||||
if (string.IsNullOrWhiteSpace(expected))
|
||||
{
|
||||
_log.LogWarning("[AuthZ] INTERNAL_SERVICE_KEY not configured — service key check denied");
|
||||
return (false, "Service key not configured");
|
||||
}
|
||||
|
||||
var provided = _http.HttpContext?.Request.Headers["X-Service-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(provided) || provided != expected)
|
||||
{
|
||||
_log.LogWarning("[AuthZ] Invalid or missing X-Service-Key");
|
||||
return (false, "Valid service key required");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// BASIC AUTH CHECKS
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>Require authenticated session with a valid ClientId.</summary>
|
||||
public (bool Ok, string? Error) RequireAuth()
|
||||
{
|
||||
if (!_client.IsAuthenticated)
|
||||
return (false, "Authentication required");
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>Require specific role(s). Case-insensitive.</summary>
|
||||
public (bool Ok, string? Error) RequireRole(params string[] allowedRoles)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return (ok, err);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_client.Role))
|
||||
return (false, "No role assigned");
|
||||
|
||||
if (!allowedRoles.Any(r => string.Equals(_client.Role, r, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Role denied | ClientId={ClientId} Role={Role} Required={Required}",
|
||||
_client.ClientId, _client.Role, string.Join(",", allowedRoles));
|
||||
return (false, "Insufficient permissions");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>Require admin role.</summary>
|
||||
public (bool Ok, string? Error) RequireAdmin()
|
||||
=> RequireRole("admin");
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// INITIATIVE OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify initiative belongs to the authenticated client.
|
||||
/// Returns the initiative JSON on success (avoids double-fetch).
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyInitiativeOwnerAsync(long initiativeId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.Initiative, "get",
|
||||
JsonSerializer.Serialize(new { initiativeId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Initiative not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Initiative not found");
|
||||
|
||||
// Extract clientId from response — check both clean and prefixed shapes
|
||||
var initiative = root.TryGetProperty("initiative", out var initEl) ? initEl : root;
|
||||
var ownerClientId =
|
||||
initiative.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() :
|
||||
initiative.TryGetProperty("iniClientId", out var iniCidProp) ? iniCidProp.GetString() :
|
||||
null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerClientId))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Initiative {Id} has no clientId — ownership check inconclusive, denying",
|
||||
initiativeId);
|
||||
return OwnershipResult.Denied("Initiative ownership could not be verified");
|
||||
}
|
||||
|
||||
if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] IDOR attempt | InitiativeId={InitiativeId} Owner={Owner} Requester={Requester}",
|
||||
initiativeId, ownerClientId, _client.ClientId);
|
||||
return OwnershipResult.Denied("Initiative not found"); // Don't reveal existence
|
||||
}
|
||||
|
||||
// Extract current status for transition validation — check both shapes
|
||||
var status =
|
||||
initiative.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
initiative.TryGetProperty("iniStatus", out var iniStProp) ? iniStProp.GetString() :
|
||||
null;
|
||||
|
||||
return OwnershipResult.Allowed(resp, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Ownership check failed for initiative {Id}", initiativeId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// CHANNEL CAMPAIGN OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify channel campaign belongs to the authenticated client.
|
||||
/// Follows channelCampaign → initiative → client ownership chain.
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyChannelOwnerAsync(long channelCampaignId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
SqlNames.Procs.ChannelCampaign, "get",
|
||||
JsonSerializer.Serialize(new { channelCampaignId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
// Get initiativeId, then check initiative ownership
|
||||
var campaign = root.TryGetProperty("channelCampaign", out var ccEl) ? ccEl : root;
|
||||
var initiativeId =
|
||||
campaign.TryGetProperty("initiativeId", out var initIdProp) ? initIdProp.GetInt64() :
|
||||
campaign.TryGetProperty("chcInitiativeId", out var chcInitProp) ? chcInitProp.GetInt64() :
|
||||
0;
|
||||
|
||||
if (initiativeId <= 0)
|
||||
return OwnershipResult.Denied("Channel campaign not found");
|
||||
|
||||
// Delegate to initiative ownership check
|
||||
return await VerifyInitiativeOwnerAsync(initiativeId, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Channel ownership check failed for {Id}", channelCampaignId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// WIZARD OWNERSHIP
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Verify wizard belongs to the authenticated client.
|
||||
/// </summary>
|
||||
public async Task<OwnershipResult> VerifyWizardOwnerAsync(string wizardId, CancellationToken ct)
|
||||
{
|
||||
var (ok, err) = RequireAuth();
|
||||
if (!ok) return OwnershipResult.Denied(err!);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync(
|
||||
"dbo.spCampaignWizard", "get",
|
||||
JsonSerializer.Serialize(new { wizardId }), ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && !okProp.GetBoolean())
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
|
||||
var wizard = root.TryGetProperty("wizard", out var wzEl) ? wzEl : root;
|
||||
var ownerClientId =
|
||||
wizard.TryGetProperty("clientId", out var cidProp) ? cidProp.GetString() :
|
||||
wizard.TryGetProperty("wizClientId", out var wzCidProp) ? wzCidProp.GetString() :
|
||||
null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerClientId))
|
||||
{
|
||||
_log.LogWarning("[AuthZ] Wizard {Id} has no clientId", wizardId);
|
||||
return OwnershipResult.Denied("Wizard ownership could not be verified");
|
||||
}
|
||||
|
||||
if (!string.Equals(ownerClientId, _client.ClientId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] IDOR attempt | WizardId={WizardId} Owner={Owner} Requester={Requester}",
|
||||
wizardId, ownerClientId, _client.ClientId);
|
||||
return OwnershipResult.Denied("Wizard not found");
|
||||
}
|
||||
|
||||
var status =
|
||||
wizard.TryGetProperty("status", out var stProp) ? stProp.GetString() :
|
||||
wizard.TryGetProperty("wizStatus", out var wzStProp) ? wzStProp.GetString() :
|
||||
null;
|
||||
return OwnershipResult.Allowed(resp, status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[AuthZ] Wizard ownership check failed for {Id}", wizardId);
|
||||
return OwnershipResult.Denied("Authorization check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// STATUS TRANSITION VALIDATION
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a status transition is allowed for client-initiated actions.
|
||||
/// Internal/system transitions (from launch service, provider callbacks) bypass this.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) ValidateClientStatusTransition(
|
||||
string? currentStatus, string requestedStatus, string resourceType = "initiative")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requestedStatus))
|
||||
return (false, "Status is required");
|
||||
|
||||
// Normalize
|
||||
var from = (currentStatus ?? "").ToLowerInvariant();
|
||||
var to = requestedStatus.ToLowerInvariant();
|
||||
|
||||
// Client-allowed transitions (restrictive)
|
||||
var allowed = IsClientTransitionAllowed(from, to);
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[AuthZ] Invalid status transition | {ResourceType} {From} → {To} by ClientId={ClientId}",
|
||||
resourceType, from, to, _client.ClientId);
|
||||
return (false, $"Cannot change {resourceType} from '{from}' to '{to}'");
|
||||
}
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whitelist of client-allowed transitions.
|
||||
/// Everything else requires admin or system action.
|
||||
/// </summary>
|
||||
private static bool IsClientTransitionAllowed(string from, string to)
|
||||
{
|
||||
return (from, to) switch
|
||||
{
|
||||
// Pausing: only active campaigns can be paused
|
||||
("active", "paused") => true,
|
||||
|
||||
// Resuming: only paused campaigns can be resumed
|
||||
("paused", "active") => true,
|
||||
|
||||
// Cancelling: clients can cancel from most pre-completion states
|
||||
("draft", "cancelled") => true,
|
||||
("staged", "cancelled") => true,
|
||||
("pending", "cancelled") => true,
|
||||
("active", "cancelled") => true,
|
||||
("paused", "cancelled") => true,
|
||||
|
||||
// Everything else is denied at the client level
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// BUDGET VALIDATION
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validate budget against channel minimums.
|
||||
/// </summary>
|
||||
public (bool Ok, string? Error) ValidateBudget(
|
||||
decimal totalBudget, string? budgetPeriod, MultiChannelConfig config)
|
||||
{
|
||||
if (totalBudget <= 0)
|
||||
return (false, "Budget must be greater than zero");
|
||||
|
||||
// Convert to monthly for comparison
|
||||
var monthlyBudget = (budgetPeriod?.ToLowerInvariant()) switch
|
||||
{
|
||||
"daily" => totalBudget * 30.4m,
|
||||
"weekly" => totalBudget * 4.33m,
|
||||
_ => totalBudget
|
||||
};
|
||||
|
||||
// Check against lowest channel minimum
|
||||
var minBudget = config.EnabledChannels
|
||||
.Select(c => c.MinMonthlyBudget)
|
||||
.DefaultIfEmpty(150m)
|
||||
.Min();
|
||||
|
||||
if (monthlyBudget < minBudget)
|
||||
return (false, $"Monthly budget must be at least ${minBudget:F0}");
|
||||
|
||||
// Cap at reasonable maximum (safety valve)
|
||||
if (monthlyBudget > 1_000_000m)
|
||||
return (false, "Budget exceeds maximum allowed. Contact support for high-spend campaigns.");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Result type
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class OwnershipResult
|
||||
{
|
||||
public bool IsAllowed { get; init; }
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>The raw JSON response from the ownership lookup (avoids re-fetching).</summary>
|
||||
public string? EntityJson { get; init; }
|
||||
|
||||
/// <summary>Current status of the entity (for transition validation).</summary>
|
||||
public string? CurrentStatus { get; init; }
|
||||
|
||||
public static OwnershipResult Allowed(string? entityJson = null, string? status = null)
|
||||
=> new() { IsAllowed = true, EntityJson = entityJson, CurrentStatus = status };
|
||||
|
||||
public static OwnershipResult Denied(string error)
|
||||
=> new() { IsAllowed = false, Error = error };
|
||||
}
|
||||
@@ -223,7 +223,7 @@ public sealed class ClientAuthMiddleware
|
||||
_logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId);
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
|
||||
var resp = await sql.ExecProcAsync("dbo.spClientSession", "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -239,12 +239,13 @@ 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.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.IsDevBypass = false;
|
||||
clientContext.ClientId = data.TryGetProperty("clientId", out var cid) ? cid.GetString() : null;
|
||||
clientContext.ClientName = data.TryGetProperty("clientName", out var cn) ? cn.GetString() : null;
|
||||
clientContext.ClientCategory = data.TryGetProperty("clientCategory", out var ccat) ? ccat.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.IsDevBypass = false;
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
@@ -1,60 +1,37 @@
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Holds authenticated client information for the current request.
|
||||
/// Populated by ClientAuthMiddleware.
|
||||
/// Holds authenticated identity information for the current request.
|
||||
/// Populated by MultiProviderAuthMiddleware.
|
||||
/// </summary>
|
||||
public sealed class ClientContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Session ID from session-based auth.
|
||||
/// </summary>
|
||||
public string? SessionId { get; set; }
|
||||
public string? SessionId { get; set; }
|
||||
public string? ClientId { get; set; } // OID (JWT) or platform client ID (session)
|
||||
public string? TenantId { get; set; }
|
||||
public string? ClientName { get; set; }
|
||||
public string? ClientCategory { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public bool IsDevBypass { get; set; }
|
||||
public string? AuthProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authenticated client ID (from session, JWT sub claim, or dev header).
|
||||
/// This identifies the client/organization in our platform.
|
||||
/// Raw Entra Object ID (oid claim) — always set for Microsoft tokens.
|
||||
/// Used for identity and activity logging. Distinct from ClientId which may fall
|
||||
/// back to sub for tokens where oid isn't surfaced as a named claim.
|
||||
/// </summary>
|
||||
public string? ClientId { get; set; }
|
||||
public string? EntraOid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional tenant ID for the ad platform (e.g., Google Ads customer ID).
|
||||
/// May be derived from ClientId mapping or passed in request.
|
||||
/// True when the token was issued by the standard Entra (staff) tenant.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
public bool IsStaff { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Display name from token or session (if available).
|
||||
/// </summary>
|
||||
public string? ClientName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User ID from session (if using session auth).
|
||||
/// </summary>
|
||||
public string? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Email from token or session (if available).
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User role from session (admin, user, readonly).
|
||||
/// </summary>
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this request was authenticated via dev bypass (vs real auth).
|
||||
/// </summary>
|
||||
public bool IsDevBypass { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authentication provider used (microsoft, google, etc.)
|
||||
/// </summary>
|
||||
public string? AuthProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True if we have a valid ClientId.
|
||||
/// </summary>
|
||||
/// <summary>True if we have a valid ClientId.</summary>
|
||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
|
||||
|
||||
/// <summary>True if this is an admin session (IsStaff + Role set).</summary>
|
||||
public bool IsAdmin => IsStaff && !string.IsNullOrWhiteSpace(Role);
|
||||
}
|
||||
|
||||
@@ -248,11 +248,26 @@ public sealed class MultiProviderAuthMiddleware
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Entra ID
|
||||
// Standard Entra ID — could be CIAM tenant or Staff tenant (Tech, Admin)
|
||||
// Detect by comparing issuer against configured Staff tenant ID
|
||||
var staffTenantId = _config["Auth:Microsoft:StaffTenantId"];
|
||||
var staffClientId = _config["Auth:Microsoft:StaffClientId"];
|
||||
|
||||
var isStaff = !string.IsNullOrWhiteSpace(staffTenantId) &&
|
||||
jwt.Issuer.Contains(staffTenantId, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isStaff)
|
||||
{
|
||||
tenantId = staffTenantId!;
|
||||
clientId = staffClientId ?? clientId;
|
||||
_logger.LogWarning("[Auth] Staff Entra token detected | tenant={Tenant} | Corr={Corr}", tenantId, corrId);
|
||||
clientContext.IsStaff = true;
|
||||
}
|
||||
|
||||
authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||
metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
validIssuers = new[]
|
||||
{
|
||||
validIssuers = new[]
|
||||
{
|
||||
$"https://login.microsoftonline.com/{tenantId}/v2.0",
|
||||
$"https://sts.windows.net/{tenantId}/"
|
||||
};
|
||||
@@ -342,9 +357,17 @@ public sealed class MultiProviderAuthMiddleware
|
||||
/// </summary>
|
||||
private static void ExtractClaims(ClaimsPrincipal principal, ClientContext clientContext)
|
||||
{
|
||||
// Always extract oid explicitly — used for activity logging and identity.
|
||||
// For standard Entra access tokens oid may be under the full claim URI.
|
||||
var oid = principal.FindFirstValue("oid")
|
||||
?? principal.FindFirstValue("http://schemas.microsoft.com/identity/claims/objectidentifier");
|
||||
|
||||
clientContext.EntraOid = oid;
|
||||
|
||||
// ClientId: prefer oid, fall back to sub
|
||||
clientContext.ClientId =
|
||||
principal.FindFirstValue("oid") ?? // Microsoft object ID
|
||||
principal.FindFirstValue("sub") ?? // Standard subject
|
||||
oid ??
|
||||
principal.FindFirstValue("sub") ??
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
clientContext.Email =
|
||||
@@ -389,7 +412,9 @@ public sealed class MultiProviderAuthMiddleware
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
var sessionProc = "dbo.spClientSession"; // Gateway handles CIAM client sessions only
|
||||
var resp = await sql.ExecProcAsync(sessionProc, "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
@@ -412,8 +437,22 @@ public sealed class MultiProviderAuthMiddleware
|
||||
clientContext.Role = data.TryGetProperty("role", out var role) ? role.GetString() : null;
|
||||
clientContext.IsDevBypass = false;
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
// TenantId: session data first, then X-Tenant-Id header fallback
|
||||
// (In agency model, this is the client's Google Ads customer ID)
|
||||
clientContext.TenantId =
|
||||
data.TryGetProperty("tenantId", out var tenId) ? tenId.GetString() :
|
||||
data.TryGetProperty("googleCustomerId", out var gcid) ? gcid.GetString() :
|
||||
null;
|
||||
|
||||
// Fall back to X-Tenant-Id header if not in session data
|
||||
if (string.IsNullOrWhiteSpace(clientContext.TenantId) &&
|
||||
context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader))
|
||||
{
|
||||
clientContext.TenantId = tenantHeader.FirstOrDefault();
|
||||
}
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} IsAdmin={IsAdmin} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, clientContext.IsAdmin, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user