Add project files.
This commit is contained in:
382
Gateway/Controllers/AuthController.cs
Normal file
382
Gateway/Controllers/AuthController.cs
Normal file
@@ -0,0 +1,382 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication endpoints for session management.
|
||||
/// Sessions are created after Entra External ID authentication.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ILogger<AuthController> _log;
|
||||
|
||||
public AuthController(SqlService sql, ClientContext client, ILogger<AuthController> log)
|
||||
{
|
||||
_sql = sql;
|
||||
_client = client;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exchange Entra JWT for a session token.
|
||||
/// Call this after successful Entra login to get a session for API calls.
|
||||
/// The JWT must be validated by middleware before this endpoint.
|
||||
/// </summary>
|
||||
[HttpPost("session")]
|
||||
public async Task<IActionResult> CreateSession([FromBody] CreateSessionRequest? request, CancellationToken ct)
|
||||
{
|
||||
_log.LogWarning("[Session] CreateSession called");
|
||||
|
||||
// ClientContext is populated by middleware after JWT validation
|
||||
if (!_client.IsAuthenticated)
|
||||
{
|
||||
_log.LogWarning("[Session] Not authenticated - ClientId is null/empty");
|
||||
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
|
||||
}
|
||||
|
||||
_log.LogWarning("[Session] Authenticated: ClientId={ClientId}, Email={Email}",
|
||||
_client.ClientId, _client.Email);
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = _client.AuthProvider ?? "EntraExternalId",
|
||||
subject = _client.ClientId,
|
||||
email = _client.Email,
|
||||
displayName = _client.ClientName,
|
||||
clientId = request?.PreferredClientId,
|
||||
ipAddress = GetClientIp(),
|
||||
userAgent = Request.Headers.UserAgent.FirstOrDefault(),
|
||||
sessionDurationHours = request?.SessionDurationHours ?? 24
|
||||
});
|
||||
|
||||
_log.LogWarning("[Session] Calling spSession with: {Rqst}", rqst);
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "createFromIdentity", rqst, ct: ct);
|
||||
|
||||
_log.LogWarning("[Session] spSession response: {Resp}", resp ?? "(null)");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
return StatusCode(500, new { ok = false, error = "Session service unavailable" });
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
{
|
||||
_log.LogWarning("[Session] Success - returning session");
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Session creation failed";
|
||||
_log.LogWarning("[Session] Proc returned error: {Error}", error);
|
||||
return BadRequest(new { ok = false, error });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Session] EXCEPTION in CreateSession: {Message}", ex.Message);
|
||||
return StatusCode(500, new { ok = false, error = "Session service error", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a new client/organization for the authenticated user.
|
||||
/// JWT must be validated by middleware before this endpoint.
|
||||
/// Called from the registration portal after CIAM sign-in.
|
||||
/// </summary>
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request, CancellationToken ct)
|
||||
{
|
||||
_log.LogWarning("[Register] Register called");
|
||||
|
||||
if (!_client.IsAuthenticated)
|
||||
{
|
||||
_log.LogWarning("[Register] Not authenticated");
|
||||
return Unauthorized(new { ok = false, error = "Valid Entra authentication required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request?.CompanyName))
|
||||
{
|
||||
return BadRequest(new { ok = false, error = "companyName is required" });
|
||||
}
|
||||
|
||||
_log.LogWarning("[Register] Authenticated: Subject={Subject}, Email={Email}, Company={Company}",
|
||||
_client.ClientId, _client.Email, request.CompanyName);
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
provider = _client.AuthProvider ?? "EntraExternalId",
|
||||
subject = _client.ClientId,
|
||||
email = _client.Email,
|
||||
displayName = _client.ClientName,
|
||||
companyName = request.CompanyName,
|
||||
industry = request.Industry,
|
||||
website = request.Website
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spOnboarding", "register", rqst, ct: ct);
|
||||
|
||||
_log.LogWarning("[Register] spOnboarding response: {Resp}", resp ?? "(null)");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
return StatusCode(500, new { ok = false, error = "Onboarding service unavailable" });
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
{
|
||||
_log.LogWarning("[Register] Success");
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Registration failed";
|
||||
_log.LogWarning("[Register] Error: {Error}", error);
|
||||
return BadRequest(new { ok = false, error });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Register] EXCEPTION: {Message}", ex.Message);
|
||||
return StatusCode(500, new { ok = false, error = "Registration service error", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sign off (invalidate session)
|
||||
/// </summary>
|
||||
[HttpPost("signoff")]
|
||||
public async Task<IActionResult> SignOff(CancellationToken ct)
|
||||
{
|
||||
var token = ExtractSessionToken();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return BadRequest(new { ok = false, error = "No session token provided" });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
|
||||
try
|
||||
{
|
||||
await _sql.ExecProcAsync("dbo.spSession", "signoff", rqst, ct: ct);
|
||||
return Ok(new { ok = true, message = "Signed out successfully" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "SignOff error");
|
||||
return StatusCode(500, new { ok = false, error = "Sign off failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh session (extend expiration)
|
||||
/// </summary>
|
||||
[HttpPost("refresh")]
|
||||
public async Task<IActionResult> Refresh([FromBody] RefreshRequest? request, CancellationToken ct)
|
||||
{
|
||||
var token = ExtractSessionToken();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return BadRequest(new { ok = false, error = "No session token provided" });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
sessionToken = token,
|
||||
sessionDurationHours = request?.SessionDurationHours ?? 24
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "refresh", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
return StatusCode(500, new { ok = false, error = "Session service unavailable" });
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
{
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Refresh failed";
|
||||
return Unauthorized(new { ok = false, error });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Refresh error");
|
||||
return StatusCode(500, new { ok = false, error = "Session refresh failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current session info
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
public async Task<IActionResult> Me(CancellationToken ct)
|
||||
{
|
||||
var token = ExtractSessionToken();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return Unauthorized(new { ok = false, error = "No session token provided" });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
return StatusCode(500, new { ok = false, error = "Session service unavailable" });
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
{
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Invalid session";
|
||||
return Unauthorized(new { ok = false, error });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "Me error");
|
||||
return StatusCode(500, new { ok = false, error = "Session validation failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switch to a different client context (for multi-client users)
|
||||
/// </summary>
|
||||
[HttpPost("switch-client")]
|
||||
public async Task<IActionResult> SwitchClient([FromBody] SwitchClientRequest request, CancellationToken ct)
|
||||
{
|
||||
var token = ExtractSessionToken();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return Unauthorized(new { ok = false, error = "No session token provided" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ClientId))
|
||||
{
|
||||
return BadRequest(new { ok = false, error = "clientId is required" });
|
||||
}
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
sessionToken = token,
|
||||
clientId = request.ClientId
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var resp = await _sql.ExecProcAsync("dbo.spSession", "switchClient", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
return StatusCode(500, new { ok = false, error = "Session service unavailable" });
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
{
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
else
|
||||
{
|
||||
var error = root.TryGetProperty("error", out var errProp) ? errProp.GetString() : "Switch failed";
|
||||
return BadRequest(new { ok = false, error });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "SwitchClient error");
|
||||
return StatusCode(500, new { ok = false, error = "Client switch failed" });
|
||||
}
|
||||
}
|
||||
|
||||
private string? ExtractSessionToken()
|
||||
{
|
||||
// Check X-Session-Token header first
|
||||
var token = Request.Headers["X-Session-Token"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
return token;
|
||||
|
||||
// Check Authorization header (for session tokens, not JWTs)
|
||||
var auth = Request.Headers.Authorization.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Session ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return auth.Substring(8).Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? GetClientIp()
|
||||
{
|
||||
var forwarded = Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(forwarded))
|
||||
return forwarded.Split(',')[0].Trim();
|
||||
|
||||
return HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CreateSessionRequest
|
||||
{
|
||||
public string? PreferredClientId { get; set; }
|
||||
public int? SessionDurationHours { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RefreshRequest
|
||||
{
|
||||
public int? SessionDurationHours { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SwitchClientRequest
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RegisterRequest
|
||||
{
|
||||
public string? CompanyName { get; set; }
|
||||
public string? Industry { get; set; }
|
||||
public string? Website { get; set; }
|
||||
}
|
||||
25
Gateway/Controllers/ExecutionController.cs
Normal file
25
Gateway/Controllers/ExecutionController.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Gateway.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/execution")]
|
||||
public sealed class ExecutionController : ControllerBase
|
||||
{
|
||||
private readonly ExecutionService _svc;
|
||||
public ExecutionController(ExecutionService svc) => _svc = svc;
|
||||
|
||||
[HttpPost("request")]
|
||||
public async Task<IActionResult> Execute([FromBody] JsonElement body)
|
||||
{
|
||||
if (body.ValueKind == JsonValueKind.Undefined || body.ValueKind == JsonValueKind.Null)
|
||||
return BadRequest(new { ok = false, error = "Missing request body" });
|
||||
|
||||
var resp = await _svc.ExecuteAsync(body, HttpContext.RequestAborted);
|
||||
|
||||
// resp is JsonElement / JsonDocument / string json — you decide.
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
}
|
||||
28
Gateway/Controllers/TestController.cs
Normal file
28
Gateway/Controllers/TestController.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Gateway.Data;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Gateway.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/test")]
|
||||
public class TestController : ControllerBase
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
|
||||
public TestController(SqlService sql)
|
||||
{
|
||||
_sql = sql;
|
||||
}
|
||||
|
||||
[HttpGet("ping")]
|
||||
public async Task<IActionResult> Ping(CancellationToken ct)
|
||||
{
|
||||
// Use a real clientId that exists in tbClient (or you'll hit your FK check)
|
||||
var rqst = """
|
||||
{ "clientId":"00000000-0000-0000-0000-000000000001" }
|
||||
""";
|
||||
|
||||
var resp = await _sql.ExecProcAsync("dbo.spTemplate", "ping", rqst, ct: ct);
|
||||
return Content(resp, "application/json");
|
||||
}
|
||||
}
|
||||
14
Gateway/Data/SqlNames.cs
Normal file
14
Gateway/Data/SqlNames.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Gateway.Data;
|
||||
|
||||
public static class SqlNames
|
||||
{
|
||||
public static class Procs
|
||||
{
|
||||
public const string Client = "dbo.spClient";
|
||||
public const string User = "dbo.spUser";
|
||||
public const string UserClientRole = "dbo.spUserClientRole";
|
||||
public const string AdAccount = "dbo.spAdAccount";
|
||||
public const string AdCampaign = "dbo.spAdCampaign";
|
||||
public const string Invoice = "dbo.spInvoice";
|
||||
}
|
||||
}
|
||||
92
Gateway/Data/SqlServer.cs
Normal file
92
Gateway/Data/SqlServer.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Gateway.Data;
|
||||
|
||||
public class SqlService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<SqlService> _logger;
|
||||
|
||||
public SqlService(IConfiguration config, ILogger<SqlService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string GetConnectionString()
|
||||
{
|
||||
// expects env var: ConnectionStrings__Sql
|
||||
var cs = _config.GetConnectionString("Sql");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
throw new InvalidOperationException("Missing connection string: ConnectionStrings:Sql (env var ConnectionStrings__Sql).");
|
||||
return cs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a stored procedure using the standard signature:
|
||||
/// @action varchar(..),
|
||||
/// @rqst nvarchar(max),
|
||||
/// @resp nvarchar(max) OUTPUT
|
||||
/// Returns the output JSON string (resp).
|
||||
/// </summary>
|
||||
public async Task<string> ExecProcAsync(
|
||||
string procName,
|
||||
string action,
|
||||
string rqstJson,
|
||||
int commandTimeoutSeconds = 60,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(procName))
|
||||
throw new ArgumentException("procName is required.", nameof(procName));
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
throw new ArgumentException("action is required.", nameof(action));
|
||||
if (string.IsNullOrWhiteSpace(rqstJson))
|
||||
rqstJson = "{}";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
await using var conn = new SqlConnection(GetConnectionString());
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = new SqlCommand(procName, conn)
|
||||
{
|
||||
CommandType = CommandType.StoredProcedure,
|
||||
CommandTimeout = commandTimeoutSeconds
|
||||
};
|
||||
|
||||
cmd.Parameters.Add(new SqlParameter("@action", SqlDbType.VarChar, 50) { Value = action });
|
||||
cmd.Parameters.Add(new SqlParameter("@rqst", SqlDbType.NVarChar, -1) { Value = rqstJson });
|
||||
|
||||
var pResp = new SqlParameter("@resp", SqlDbType.NVarChar, -1)
|
||||
{
|
||||
Direction = ParameterDirection.Output
|
||||
};
|
||||
cmd.Parameters.Add(pResp);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
|
||||
var resp = pResp.Value as string ?? "";
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogInformation("SQL ok: {Proc} action={Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
|
||||
|
||||
return resp;
|
||||
}
|
||||
catch (SqlException ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogError(ex, "SQL error: {Proc} action={Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
_logger.LogError(ex, "App error calling SQL: {Proc} action={Action} ms={Ms}", procName, action, sw.ElapsedMilliseconds);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
Gateway/Gateway.csproj
Normal file
28
Gateway/Gateway.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
<!-- Container Settings -->
|
||||
<EnableSdkContainerDebugging>True</EnableSdkContainerDebugging>
|
||||
<ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:8.0</ContainerBaseImage>
|
||||
<ContainerRepository>gateway</ContainerRepository>
|
||||
<UserSecretsId>1fbe288a-4287-4931-8b37-9711665c35bb</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.4.0" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.4.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.4.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ContainerPort Include="8080" Type="tcp" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
6
Gateway/Gateway.http
Normal file
6
Gateway/Gateway.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@Gateway_HostAddress = http://localhost:5255
|
||||
|
||||
GET {{Gateway_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
11
Gateway/Models/CampaignDto.cs
Normal file
11
Gateway/Models/CampaignDto.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Gateway.Models;
|
||||
|
||||
public class CampaignDto
|
||||
{
|
||||
public string Network { get; set; } = "";
|
||||
public string ExternalAccountId { get; set; } = "";
|
||||
public string CampaignId { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public string ChannelType { get; set; } = "";
|
||||
}
|
||||
17
Gateway/Models/CreateCampaignRequest.cs
Normal file
17
Gateway/Models/CreateCampaignRequest.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Gateway.Models;
|
||||
|
||||
public class CreateCampaignRequest
|
||||
{
|
||||
// Campaign name
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
// For Google: budget uses micros (1,000,000 micros = 1 currency unit)
|
||||
// e.g. $50/day => 50_000_000 micros
|
||||
public long DailyBudgetMicros { get; set; }
|
||||
|
||||
// Optional: for future (Search/Display/PMax)
|
||||
public string ChannelType { get; set; } = "Search";
|
||||
|
||||
// Optional: where your UI can store draft settings
|
||||
public Dictionary<string, string>? Meta { get; set; }
|
||||
}
|
||||
10
Gateway/Models/CreateCampaignResult.cs
Normal file
10
Gateway/Models/CreateCampaignResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Gateway.Models;
|
||||
|
||||
public class CreateCampaignResult
|
||||
{
|
||||
public string Network { get; set; } = "";
|
||||
public string ExternalAccountId { get; set; } = "";
|
||||
public bool Ok { get; set; }
|
||||
public string? CampaignId { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
22
Gateway/Models/ExecutionRequest.cs
Normal file
22
Gateway/Models/ExecutionRequest.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Models
|
||||
{
|
||||
public sealed class ExecutionRequest
|
||||
{
|
||||
/// <summary>Ad platform provider: google, meta, msads, etc.</summary>
|
||||
public string Provider { get; set; } = "google";
|
||||
|
||||
/// <summary>Sub-module/microservice: system, campaigns, reporting, accounts, etc.</summary>
|
||||
public string Service { get; set; } = "system";
|
||||
|
||||
/// <summary>Specific operation/action: ping, create, list, get, update, delete, etc.</summary>
|
||||
public string Action { get; set; } = "ping";
|
||||
|
||||
/// <summary>Tenant/Customer ID for account context</summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>Raw JSON payload for the operation</summary>
|
||||
public JsonElement Payload { get; set; }
|
||||
}
|
||||
}
|
||||
14
Gateway/Models/ExecutionResponse.cs
Normal file
14
Gateway/Models/ExecutionResponse.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Gateway.Models;
|
||||
|
||||
public sealed class ExecutionResponse
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public int? LogId { get; set; }
|
||||
public string Provider { get; set; } = "";
|
||||
public string Service { get; set; } = "";
|
||||
public string RequestId { get; set; } = "";
|
||||
public int ProviderStatus { get; set; }
|
||||
public long ElapsedMs { get; set; }
|
||||
public object? Result { get; set; }
|
||||
public object? Error { get; set; }
|
||||
}
|
||||
9
Gateway/Models/ProviderRequest.cs
Normal file
9
Gateway/Models/ProviderRequest.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Gateway.Models;
|
||||
|
||||
public sealed class ProviderRequest
|
||||
{
|
||||
public string Operation { get; set; } = string.Empty;
|
||||
public string? TenantId { get; set; }
|
||||
public string? RequestId { get; set; }
|
||||
public Dictionary<string, object>? Payload { get; set; }
|
||||
}
|
||||
16
Gateway/Models/ProviderResponse.cs
Normal file
16
Gateway/Models/ProviderResponse.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Gateway.Models;
|
||||
|
||||
public sealed class ProviderResponse
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public string? RequestId { get; set; }
|
||||
public object? Data { get; set; }
|
||||
public ProviderError? Error { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ProviderError
|
||||
{
|
||||
public string Code { get; set; } = "ERROR";
|
||||
public string Message { get; set; } = "Unknown error";
|
||||
public object? Detail { get; set; }
|
||||
}
|
||||
80
Gateway/Program.cs
Normal file
80
Gateway/Program.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.ProviderClients;
|
||||
using Gateway.Security;
|
||||
using Gateway.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --------------------
|
||||
// Container-friendly HTTP binding
|
||||
// --------------------
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
|
||||
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
|
||||
|
||||
// --------------------
|
||||
// Services
|
||||
// --------------------
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// Data & business services
|
||||
builder.Services.AddScoped<SqlService>();
|
||||
builder.Services.AddScoped<ExecutionService>();
|
||||
|
||||
// Authentication context (scoped - one per request)
|
||||
builder.Services.AddScoped<ClientContext>();
|
||||
|
||||
// Provider clients
|
||||
builder.Services.AddHttpClient<GoogleProviderClient>(client =>
|
||||
{
|
||||
var baseUrl = builder.Configuration["Provider:Google:BaseUrl"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL")
|
||||
?? "";
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
client.BaseAddress = new Uri(baseUrl.EndsWith("/") ? baseUrl : baseUrl + "/");
|
||||
});
|
||||
|
||||
// HTTP client factory for ExecutionService
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// --------------------
|
||||
// Middleware pipeline
|
||||
// --------------------
|
||||
|
||||
// Swagger (enabled for all environments in containers)
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// Health check endpoint (before auth & logging)
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "Gateway",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
// Root endpoint
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
service = "Gateway API",
|
||||
version = "1.0.0",
|
||||
status = "Application Gateway running"
|
||||
}));
|
||||
|
||||
// Access logging middleware (captures all requests)
|
||||
// Placed BEFORE auth so we log even failed auth attempts
|
||||
app.UseAccessLogging();
|
||||
|
||||
// Client authentication middleware (multi-provider)
|
||||
// - Validates JWTs from Microsoft, Google, etc.
|
||||
// - Accepts X-Dev-ClientId header (development)
|
||||
app.UseMiddleware<MultiProviderAuthMiddleware>();
|
||||
|
||||
// Standard middleware
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
52
Gateway/Properties/launchSettings.json
Normal file
52
Gateway/Properties/launchSettings.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:5255"
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://localhost:7252;http://localhost:5255"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Container (.NET SDK)": {
|
||||
"commandName": "SdkContainer",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_HTTPS_PORTS": "8081",
|
||||
"ASPNETCORE_HTTP_PORTS": "8080"
|
||||
},
|
||||
"publishAllPorts": true,
|
||||
"useSSL": true
|
||||
}
|
||||
},
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:52388",
|
||||
"sslPort": 44375
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Gateway/ProviderClients/GoogleProviderClient.cs
Normal file
60
Gateway/ProviderClients/GoogleProviderClient.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Gateway.Models;
|
||||
|
||||
namespace Gateway.ProviderClients;
|
||||
|
||||
public sealed class GoogleProviderClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<GoogleProviderClient> _logger;
|
||||
|
||||
public GoogleProviderClient(HttpClient http, IConfiguration config, ILogger<GoogleProviderClient> logger)
|
||||
{
|
||||
_http = http;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string GetInternalKey()
|
||||
=> _config["Provider:Google:InternalKey"]
|
||||
?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY")
|
||||
?? "";
|
||||
|
||||
public async Task<(int status, ProviderResponse body)> ExecuteAsync(ProviderRequest req, CancellationToken ct)
|
||||
{
|
||||
var key = GetInternalKey();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return (500, new ProviderResponse { Ok = false, RequestId = req.RequestId, Error = new ProviderError { Code = "CONFIG", Message = "Missing Google internal key" } });
|
||||
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, "internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
msg.Content = JsonContent.Create(req, options: new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
var res = await _http.SendAsync(msg, ct);
|
||||
|
||||
ProviderResponse? body = null;
|
||||
try
|
||||
{
|
||||
body = await res.Content.ReadFromJsonAsync<ProviderResponse>(cancellationToken: ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse provider response as JSON");
|
||||
}
|
||||
|
||||
body ??= new ProviderResponse
|
||||
{
|
||||
Ok = res.IsSuccessStatusCode,
|
||||
RequestId = req.RequestId,
|
||||
Error = res.IsSuccessStatusCode ? null : new ProviderError { Code = "PROVIDER", Message = "Non-JSON error from provider" }
|
||||
};
|
||||
|
||||
// If provider returned a 4xx/5xx but body says Ok=true, normalize
|
||||
if (!res.IsSuccessStatusCode && body.Ok)
|
||||
body.Ok = false;
|
||||
|
||||
return ((int)res.StatusCode, body);
|
||||
}
|
||||
}
|
||||
169
Gateway/Security/AccessLogMiddleware.cs
Normal file
169
Gateway/Security/AccessLogMiddleware.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using Gateway.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Logs all HTTP requests to tbAccessLog for security monitoring and debugging.
|
||||
/// Should be registered early in the pipeline (after routing, before auth).
|
||||
/// Logs asynchronously to avoid impacting response time.
|
||||
/// </summary>
|
||||
public sealed class AccessLogMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<AccessLogMiddleware> _logger;
|
||||
|
||||
// Paths to skip logging (health checks, static files, etc.)
|
||||
private static readonly HashSet<string> _skipPaths = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/health",
|
||||
"/favicon.ico"
|
||||
};
|
||||
|
||||
public AccessLogMiddleware(RequestDelegate next, ILogger<AccessLogMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
|
||||
{
|
||||
var path = context.Request.Path.Value ?? "/";
|
||||
|
||||
// Skip logging for noisy endpoints
|
||||
if (ShouldSkip(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
string? errorCode = null;
|
||||
string? errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorCode = "unhandled-exception";
|
||||
errorMessage = ex.Message;
|
||||
throw; // Re-throw to let error handling middleware deal with it
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
// Capture error info from response headers if set by auth middleware
|
||||
if (context.Response.Headers.TryGetValue("X-Auth-Fail", out var authFail))
|
||||
{
|
||||
errorCode = authFail.FirstOrDefault();
|
||||
}
|
||||
|
||||
// Fire-and-forget logging (don't await)
|
||||
_ = LogAccessAsync(sql, context, clientContext, stopwatch.ElapsedMilliseconds, errorCode, errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldSkip(string path)
|
||||
{
|
||||
if (_skipPaths.Contains(path))
|
||||
return true;
|
||||
|
||||
// Skip swagger
|
||||
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task LogAccessAsync(
|
||||
SqlService sql,
|
||||
HttpContext context,
|
||||
ClientContext clientContext,
|
||||
long durationMs,
|
||||
string? errorCode,
|
||||
string? errorMessage)
|
||||
{
|
||||
try
|
||||
{
|
||||
var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
|
||||
?? context.Response.Headers["X-Correlation-Id"].FirstOrDefault();
|
||||
|
||||
var authPath = context.Response.Headers["X-Auth-Path"].FirstOrDefault();
|
||||
|
||||
var rqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
correlationId,
|
||||
method = context.Request.Method,
|
||||
path = context.Request.Path.Value,
|
||||
queryString = context.Request.QueryString.HasValue
|
||||
? SanitizeQueryString(context.Request.QueryString.Value)
|
||||
: null,
|
||||
authPath,
|
||||
userId = clientContext.UserId,
|
||||
clientId = clientContext.ClientId,
|
||||
sessionId = clientContext.SessionId,
|
||||
statusCode = context.Response.StatusCode,
|
||||
durationMs,
|
||||
ipAddress = GetClientIp(context),
|
||||
userAgent = context.Request.Headers.UserAgent.FirstOrDefault(),
|
||||
errorCode,
|
||||
errorMessage
|
||||
});
|
||||
|
||||
await sql.ExecProcAsync("dbo.spAccessLog", "log", rqst);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't let logging failures affect the response
|
||||
_logger.LogError(ex, "Failed to write access log");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetClientIp(HttpContext context)
|
||||
{
|
||||
// Check X-Forwarded-For first (for requests behind load balancer/proxy)
|
||||
var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(forwarded))
|
||||
{
|
||||
return forwarded.Split(',')[0].Trim();
|
||||
}
|
||||
|
||||
return context.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
private static string? SanitizeQueryString(string? queryString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(queryString))
|
||||
return null;
|
||||
|
||||
// Remove sensitive params (add more as needed)
|
||||
var sensitiveParams = new[] { "token", "key", "secret", "password", "apikey" };
|
||||
|
||||
foreach (var param in sensitiveParams)
|
||||
{
|
||||
// Simple regex-free approach: just note that sensitive data may be present
|
||||
if (queryString.Contains(param, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "[REDACTED]";
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
return queryString.Length > 1000 ? queryString[..1000] : queryString;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension method for cleaner registration in Program.cs
|
||||
/// </summary>
|
||||
public static class AccessLogMiddlewareExtensions
|
||||
{
|
||||
public static IApplicationBuilder UseAccessLogging(this IApplicationBuilder builder)
|
||||
{
|
||||
return builder.UseMiddleware<AccessLogMiddleware>();
|
||||
}
|
||||
}
|
||||
415
Gateway/Security/ClientAuthMiddleware.cs
Normal file
415
Gateway/Security/ClientAuthMiddleware.cs
Normal file
@@ -0,0 +1,415 @@
|
||||
using Gateway.Data;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Tight auth contract:
|
||||
/// 1) POST /api/auth/session -> MUST be Entra JWT (Authorization: Bearer <entraJwt>)
|
||||
/// 2) All other /api/* -> MUST be valid session token (X-Session-Token OR Authorization: Bearer <sessionToken>)
|
||||
/// 3) Dev bypass -> optional (Development or Auth:AllowDevBypass=true)
|
||||
///
|
||||
/// Populates ClientContext for downstream services.
|
||||
/// Emits Warning logs so Azure Log Stream shows request-level auth flow.
|
||||
/// Adds debug headers (X-Correlation-Id, X-Auth-Path, X-Auth-Fail).
|
||||
/// </summary>
|
||||
public sealed class ClientAuthMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<ClientAuthMiddleware> _logger;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
// Exact paths that do not require authentication
|
||||
private static readonly HashSet<string> _anonymousExact = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/",
|
||||
"/health"
|
||||
};
|
||||
|
||||
// Prefix paths that do not require authentication
|
||||
private static readonly string[] _anonymousPrefixes =
|
||||
{
|
||||
"/swagger",
|
||||
"/api/test"
|
||||
};
|
||||
|
||||
// Cache OpenID config manager (avoid fetching metadata every request)
|
||||
private static ConfigurationManager<OpenIdConnectConfiguration>? _oidcConfigManager;
|
||||
private static readonly object _oidcLock = new();
|
||||
|
||||
public ClientAuthMiddleware(RequestDelegate next, ILogger<ClientAuthMiddleware> logger, IConfiguration config)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
|
||||
{
|
||||
var pathRaw = context.Request.Path.Value ?? "";
|
||||
var path = pathRaw.ToLowerInvariant();
|
||||
|
||||
var corrId = EnsureCorrelationId(context);
|
||||
|
||||
// Always visible in ACA log stream
|
||||
_logger.LogWarning("[Auth] HIT {Method} {Path} | Corr={Corr}", context.Request.Method, pathRaw, corrId);
|
||||
|
||||
// Anonymous paths
|
||||
if (IsAnonymousPath(path))
|
||||
{
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "anonymous", authFail: null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1) SESSION EXCHANGE: MUST be Entra JWT
|
||||
// ---------------------------------------------------------------------
|
||||
if (path.StartsWith("/api/auth/session", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (await TryJwtAuthAsync(context, clientContext, corrId))
|
||||
{
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "jwt(session-exchange)", authFail: null);
|
||||
_logger.LogWarning("[Auth] Session exchange authorized via JWT | Email={Email} | Corr={Corr}",
|
||||
clientContext.Email, corrId);
|
||||
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "jwt(session-exchange)", authFail: "jwt-required");
|
||||
_logger.LogWarning("[Auth] Session exchange denied: valid Entra JWT required | Corr={Corr}", corrId);
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Valid Entra authentication required",
|
||||
correlationId = corrId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 2) ALL OTHER /api/auth/*: MUST be session (or dev bypass)
|
||||
// (signoff, refresh, me, switch-client, etc.)
|
||||
// ---------------------------------------------------------------------
|
||||
if (path.StartsWith("/api/auth", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (TryDevBypass(context, clientContext, corrId))
|
||||
{
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "dev-bypass(auth)", authFail: null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await TrySessionAuthAsync(context, clientContext, sql, corrId))
|
||||
{
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "session(auth)", authFail: null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "session(auth)", authFail: "session-required");
|
||||
_logger.LogWarning("[Auth] /api/auth denied: valid session required | Corr={Corr}", corrId);
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Valid session required",
|
||||
correlationId = corrId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 3) ALL OTHER REQUESTS (typically /api/*): MUST be session (or dev bypass)
|
||||
// NO JWT FALLBACK. Keeps Bearer=<sessionToken> unambiguous.
|
||||
// ---------------------------------------------------------------------
|
||||
if (TryDevBypass(context, clientContext, corrId))
|
||||
{
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "dev-bypass", authFail: null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await TrySessionAuthAsync(context, clientContext, sql, corrId))
|
||||
{
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "session", authFail: null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SetAuthDebugHeaders(context, corrId, authPath: "session", authFail: "session-required");
|
||||
_logger.LogWarning("[Auth] UNAUTHORIZED: valid session required | {Path} | Corr={Corr}", pathRaw, corrId);
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Valid session required",
|
||||
correlationId = corrId
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsAnonymousPath(string pathLower)
|
||||
{
|
||||
if (_anonymousExact.Contains(pathLower))
|
||||
return true;
|
||||
|
||||
return _anonymousPrefixes.Any(p => pathLower.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string EnsureCorrelationId(HttpContext context)
|
||||
{
|
||||
const string header = "X-Correlation-Id";
|
||||
|
||||
if (!context.Request.Headers.TryGetValue(header, out var existing) ||
|
||||
string.IsNullOrWhiteSpace(existing.FirstOrDefault()))
|
||||
{
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
context.Request.Headers[header] = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
return existing.First()!;
|
||||
}
|
||||
|
||||
private static void SetAuthDebugHeaders(HttpContext context, string corrId, string authPath, string? authFail)
|
||||
{
|
||||
context.Response.Headers["X-Correlation-Id"] = corrId;
|
||||
context.Response.Headers["X-Auth-Path"] = authPath;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(authFail))
|
||||
context.Response.Headers["X-Auth-Fail"] = authFail;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session token authentication: Validate against our session database.
|
||||
/// Accepts X-Session-Token header OR Authorization: Bearer <sessionToken>.
|
||||
/// </summary>
|
||||
private async Task<bool> TrySessionAuthAsync(HttpContext context, ClientContext clientContext, SqlService sql, string corrId)
|
||||
{
|
||||
string? token = null;
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Session-Token", out var sessionHeader))
|
||||
token = sessionHeader.FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token) &&
|
||||
context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
var headerValue = authHeader.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(headerValue) &&
|
||||
headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
token = headerValue["Bearer ".Length..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Session auth skipped (no token) | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_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);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Session validation failed: empty response | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
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;
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
|
||||
_logger.LogWarning("[Auth] Session validation failed: ok=false | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Auth] Session validation error | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Development bypass: Accept X-Dev-ClientId header.
|
||||
/// Only works when ASPNETCORE_ENVIRONMENT=Development or Auth:AllowDevBypass=true.
|
||||
/// </summary>
|
||||
private bool TryDevBypass(HttpContext context, ClientContext clientContext, string corrId)
|
||||
{
|
||||
var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
||||
var allowBypass = _config.GetValue<bool>("Auth:AllowDevBypass");
|
||||
|
||||
if (!string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase) && !allowBypass)
|
||||
return false;
|
||||
|
||||
if (!context.Request.Headers.TryGetValue("X-Dev-ClientId", out var devClientId))
|
||||
return false;
|
||||
|
||||
var clientId = devClientId.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
return false;
|
||||
|
||||
clientContext.ClientId = clientId;
|
||||
clientContext.IsDevBypass = true;
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Dev-TenantId", out var devTenantId))
|
||||
clientContext.TenantId = devTenantId.FirstOrDefault();
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Dev-ClientName", out var devName))
|
||||
clientContext.ClientName = devName.FirstOrDefault();
|
||||
|
||||
_logger.LogWarning("[Auth] Dev bypass OK | ClientId={ClientId} | Corr={Corr}", clientId, corrId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JWT authentication: Validate Entra ID Bearer token.
|
||||
/// Used ONLY for /api/auth/session.
|
||||
/// </summary>
|
||||
private async Task<bool> TryJwtAuthAsync(HttpContext context, ClientContext clientContext, string corrId)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
_logger.LogWarning("[Auth] JWT skipped (no Authorization) | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var headerValue = authHeader.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(headerValue) ||
|
||||
!headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("[Auth] JWT skipped (Authorization not Bearer) | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var token = headerValue["Bearer ".Length..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_logger.LogWarning("[Auth] JWT skipped (empty bearer token) | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var tenantId = _config["Auth:EntraId:TenantId"] ?? _config["ENTRA_TENANT_ID"];
|
||||
var clientId = _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"];
|
||||
var instance = _config["Auth:EntraId:Instance"] ?? "https://login.microsoftonline.com/";
|
||||
var audienceOverride = _config["Auth:EntraId:Audience"]; // optional (e.g. api://xxx or App ID URI)
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
_logger.LogWarning("[Auth] JWT disabled (missing TenantId/ClientId) | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Diagnostics (safe enough for logs; do NOT log full token)
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
if (handler.CanReadToken(token))
|
||||
{
|
||||
var jwt = handler.ReadJwtToken(token);
|
||||
_logger.LogWarning("[Auth] JWT presented | iss={Iss} aud={Aud} sub={Sub} | Corr={Corr}",
|
||||
jwt.Issuer, jwt.Audiences.FirstOrDefault(), jwt.Subject, corrId);
|
||||
}
|
||||
|
||||
var authority = $"{instance.TrimEnd('/')}/{tenantId}/v2.0";
|
||||
var metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
|
||||
var mgr = GetOrCreateConfigManager(metadataAddress);
|
||||
var openIdConfig = await mgr.GetConfigurationAsync(context.RequestAborted);
|
||||
|
||||
var validAudiences = new List<string> { clientId, $"api://{clientId}" };
|
||||
if (!string.IsNullOrWhiteSpace(audienceOverride))
|
||||
validAudiences.Add(audienceOverride);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { $"{instance.TrimEnd('/')}/{tenantId}/v2.0" },
|
||||
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = validAudiences,
|
||||
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var principal = handler.ValidateToken(token, validationParams, out _);
|
||||
|
||||
clientContext.ClientId =
|
||||
principal.FindFirstValue("oid") ??
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier) ??
|
||||
principal.FindFirstValue("sub");
|
||||
|
||||
clientContext.Email =
|
||||
principal.FindFirstValue("preferred_username") ??
|
||||
principal.FindFirstValue(ClaimTypes.Email) ??
|
||||
principal.FindFirstValue("upn");
|
||||
|
||||
clientContext.ClientName =
|
||||
principal.FindFirstValue("name") ??
|
||||
principal.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
clientContext.IsDevBypass = false;
|
||||
|
||||
_logger.LogWarning("[Auth] JWT validated OK | oid={Oid} email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
catch (SecurityTokenException ex)
|
||||
{
|
||||
_logger.LogWarning("[Auth] JWT validation FAILED: {Msg} | Corr={Corr}", ex.Message, corrId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Auth] JWT validation ERROR | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ConfigurationManager<OpenIdConnectConfiguration> GetOrCreateConfigManager(string metadataAddress)
|
||||
{
|
||||
lock (_oidcLock)
|
||||
{
|
||||
_oidcConfigManager ??= new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress,
|
||||
new OpenIdConnectConfigurationRetriever());
|
||||
|
||||
return _oidcConfigManager;
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Gateway/Security/ClientContext.cs
Normal file
60
Gateway/Security/ClientContext.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Holds authenticated client information for the current request.
|
||||
/// Populated by ClientAuthMiddleware.
|
||||
/// </summary>
|
||||
public sealed class ClientContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Session ID from session-based auth.
|
||||
/// </summary>
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The authenticated client ID (from session, JWT sub claim, or dev header).
|
||||
/// This identifies the client/organization in our platform.
|
||||
/// </summary>
|
||||
public string? ClientId { 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.
|
||||
/// </summary>
|
||||
public string? TenantId { 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>
|
||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(ClientId);
|
||||
}
|
||||
512
Gateway/Security/MultiProviderAuthMiddleware.cs
Normal file
512
Gateway/Security/MultiProviderAuthMiddleware.cs
Normal file
@@ -0,0 +1,512 @@
|
||||
using Gateway.Data;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-provider authentication middleware.
|
||||
/// Supports: Microsoft Entra ID, Google, and extensible for others.
|
||||
///
|
||||
/// For /api/auth/session: Validates JWT from any configured provider
|
||||
/// For all other /api/*: Validates session token
|
||||
/// </summary>
|
||||
public sealed class MultiProviderAuthMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<MultiProviderAuthMiddleware> _logger;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
// Paths that don't require auth
|
||||
private static readonly HashSet<string> _anonymousExact = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/",
|
||||
"/health"
|
||||
};
|
||||
|
||||
private static readonly string[] _anonymousPrefixes = { "/swagger", "/api/test" };
|
||||
|
||||
// OIDC config managers (cached per provider)
|
||||
private static readonly Dictionary<string, ConfigurationManager<OpenIdConnectConfiguration>> _oidcManagers = new();
|
||||
private static readonly object _oidcLock = new();
|
||||
|
||||
public MultiProviderAuthMiddleware(RequestDelegate next, ILogger<MultiProviderAuthMiddleware> logger, IConfiguration config)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ClientContext clientContext, SqlService sql)
|
||||
{
|
||||
var pathRaw = context.Request.Path.Value ?? "";
|
||||
var path = pathRaw.ToLowerInvariant();
|
||||
var corrId = EnsureCorrelationId(context);
|
||||
|
||||
_logger.LogWarning("[Auth] HIT {Method} {Path} | Corr={Corr}", context.Request.Method, pathRaw, corrId);
|
||||
|
||||
// Anonymous paths
|
||||
if (IsAnonymousPath(path))
|
||||
{
|
||||
SetAuthHeaders(context, corrId, "anonymous", null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// SESSION EXCHANGE: Accept JWT from any configured provider
|
||||
// ---------------------------------------------------------------------
|
||||
if (path.StartsWith("/api/auth/session", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var (jwtValid, provider) = await TryMultiProviderJwtAsync(context, clientContext, corrId);
|
||||
|
||||
if (jwtValid)
|
||||
{
|
||||
SetAuthHeaders(context, corrId, $"jwt({provider})", null);
|
||||
_logger.LogWarning("[Auth] Session exchange authorized via {Provider} JWT | Email={Email} | Corr={Corr}",
|
||||
provider, clientContext.Email, corrId);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SetAuthHeaders(context, corrId, "jwt", "jwt-required");
|
||||
_logger.LogWarning("[Auth] Session exchange denied: valid JWT required | Corr={Corr}", corrId);
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Valid authentication required from a supported provider",
|
||||
correlationId = corrId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// ALL OTHER /api/* PATHS: Require session token (or dev bypass)
|
||||
// ---------------------------------------------------------------------
|
||||
if (TryDevBypass(context, clientContext, corrId))
|
||||
{
|
||||
SetAuthHeaders(context, corrId, "dev-bypass", null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await TrySessionAuthAsync(context, clientContext, sql, corrId))
|
||||
{
|
||||
SetAuthHeaders(context, corrId, "session", null);
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
SetAuthHeaders(context, corrId, "session", "session-required");
|
||||
_logger.LogWarning("[Auth] UNAUTHORIZED: valid session required | {Path} | Corr={Corr}", pathRaw, corrId);
|
||||
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Valid session required",
|
||||
correlationId = corrId
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to validate JWT from multiple providers.
|
||||
/// Returns (success, providerName).
|
||||
/// </summary>
|
||||
private async Task<(bool Success, string? Provider)> TryMultiProviderJwtAsync(
|
||||
HttpContext context,
|
||||
ClientContext clientContext,
|
||||
string corrId)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
_logger.LogWarning("[Auth] No Authorization header | Corr={Corr}", corrId);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var headerValue = authHeader.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(headerValue) || !headerValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Invalid Authorization header format | Corr={Corr}", corrId);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
var token = headerValue["Bearer ".Length..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Empty bearer token | Corr={Corr}", corrId);
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
// Check for provider hint from frontend
|
||||
var providerHint = context.Request.Headers["X-Auth-Provider"].FirstOrDefault()?.ToLowerInvariant();
|
||||
|
||||
// Read token to get issuer (for auto-detection)
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
JwtSecurityToken? jwt = null;
|
||||
|
||||
if (handler.CanReadToken(token))
|
||||
{
|
||||
jwt = handler.ReadJwtToken(token);
|
||||
_logger.LogWarning("[Auth] JWT presented | iss={Iss} aud={Aud} | Corr={Corr}",
|
||||
jwt.Issuer, jwt.Audiences.FirstOrDefault(), corrId);
|
||||
}
|
||||
|
||||
// Try providers in order (hint first, then auto-detect)
|
||||
var providersToTry = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(providerHint))
|
||||
providersToTry.Add(providerHint);
|
||||
|
||||
// Auto-detect based on issuer
|
||||
if (jwt != null)
|
||||
{
|
||||
if (jwt.Issuer.Contains("login.microsoftonline.com") || jwt.Issuer.Contains("sts.windows.net") || jwt.Issuer.Contains("ciamlogin.com"))
|
||||
providersToTry.Add("microsoft");
|
||||
else if (jwt.Issuer.Contains("accounts.google.com"))
|
||||
providersToTry.Add("google");
|
||||
}
|
||||
|
||||
// Fallback: try all configured providers
|
||||
if (IsProviderConfigured("microsoft") && !providersToTry.Contains("microsoft"))
|
||||
providersToTry.Add("microsoft");
|
||||
if (IsProviderConfigured("google") && !providersToTry.Contains("google"))
|
||||
providersToTry.Add("google");
|
||||
|
||||
foreach (var provider in providersToTry.Distinct())
|
||||
{
|
||||
var success = provider switch
|
||||
{
|
||||
"microsoft" => await TryValidateMicrosoftJwtAsync(token, clientContext, corrId),
|
||||
"google" => await TryValidateGoogleJwtAsync(token, clientContext, corrId),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (success)
|
||||
{
|
||||
clientContext.AuthProvider = provider;
|
||||
return (true, provider);
|
||||
}
|
||||
}
|
||||
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate Microsoft Entra ID JWT
|
||||
/// </summary>
|
||||
private async Task<bool> TryValidateMicrosoftJwtAsync(string token, ClientContext clientContext, string corrId)
|
||||
{
|
||||
var tenantId = _config["Auth:Microsoft:TenantId"] ?? _config["Auth:EntraId:TenantId"] ?? _config["ENTRA_TENANT_ID"];
|
||||
var clientId = _config["Auth:Microsoft:ClientId"] ?? _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"];
|
||||
var ciamDomain = _config["Auth:Microsoft:CiamDomain"] ?? _config["Auth:EntraId:CiamDomain"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Microsoft provider not configured | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Peek at the token issuer to determine if this is a CIAM token
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwt = handler.ReadJwtToken(token);
|
||||
var isCiam = jwt.Issuer.Contains("ciamlogin.com", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Build authority + valid issuers based on token type
|
||||
string authority;
|
||||
string metadataAddress;
|
||||
string[] validIssuers;
|
||||
|
||||
if (isCiam)
|
||||
{
|
||||
// CIAM (External ID): derive domain from issuer or config
|
||||
var domain = ciamDomain;
|
||||
if (string.IsNullOrWhiteSpace(domain))
|
||||
{
|
||||
// Extract domain from issuer, e.g. "https://USIMClients.ciamlogin.com/{tenant}/v2.0"
|
||||
var issuerUri = new Uri(jwt.Issuer);
|
||||
domain = issuerUri.Host;
|
||||
}
|
||||
|
||||
authority = $"https://{domain}/{tenantId}/v2.0";
|
||||
metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
validIssuers = new[]
|
||||
{
|
||||
$"https://{domain}/{tenantId}/v2.0",
|
||||
$"https://{domain}/{tenantId}"
|
||||
};
|
||||
|
||||
_logger.LogWarning("[Auth] CIAM token detected | domain={Domain} | Corr={Corr}", domain, corrId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Standard Entra ID
|
||||
authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
|
||||
metadataAddress = $"{authority}/.well-known/openid-configuration";
|
||||
validIssuers = new[]
|
||||
{
|
||||
$"https://login.microsoftonline.com/{tenantId}/v2.0",
|
||||
$"https://sts.windows.net/{tenantId}/"
|
||||
};
|
||||
}
|
||||
|
||||
var mgr = GetOrCreateOidcManager(isCiam ? "microsoft-ciam" : "microsoft", metadataAddress);
|
||||
var openIdConfig = await mgr.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = validIssuers,
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = new[] { clientId, $"api://{clientId}" },
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var principal = tokenHandler.ValidateToken(token, validationParams, out _);
|
||||
|
||||
ExtractClaims(principal, clientContext);
|
||||
|
||||
_logger.LogWarning("[Auth] Microsoft JWT validated ({Mode}) | sub={Sub} email={Email} | Corr={Corr}",
|
||||
isCiam ? "CIAM" : "Entra", clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[Auth] Microsoft JWT validation failed: {Msg} | Corr={Corr}", ex.Message, corrId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate Google ID token
|
||||
/// </summary>
|
||||
private async Task<bool> TryValidateGoogleJwtAsync(string token, ClientContext clientContext, string corrId)
|
||||
{
|
||||
var clientId = _config["Auth:Google:ClientId"] ?? _config["GOOGLE_CLIENT_ID"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Google provider not configured | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var metadataAddress = "https://accounts.google.com/.well-known/openid-configuration";
|
||||
|
||||
var mgr = GetOrCreateOidcManager("google", metadataAddress);
|
||||
var openIdConfig = await mgr.GetConfigurationAsync(CancellationToken.None);
|
||||
|
||||
var validationParams = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = new[] { "https://accounts.google.com", "accounts.google.com" },
|
||||
ValidateAudience = true,
|
||||
ValidAudiences = new[] { clientId },
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKeys = openIdConfig.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(token, validationParams, out _);
|
||||
|
||||
ExtractClaims(principal, clientContext);
|
||||
|
||||
_logger.LogWarning("[Auth] Google JWT validated | sub={Sub} email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("[Auth] Google JWT validation failed: {Msg} | Corr={Corr}", ex.Message, corrId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract standard claims into ClientContext
|
||||
/// </summary>
|
||||
private static void ExtractClaims(ClaimsPrincipal principal, ClientContext clientContext)
|
||||
{
|
||||
clientContext.ClientId =
|
||||
principal.FindFirstValue("oid") ?? // Microsoft object ID
|
||||
principal.FindFirstValue("sub") ?? // Standard subject
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
clientContext.Email =
|
||||
principal.FindFirstValue("email") ??
|
||||
principal.FindFirstValue("preferred_username") ??
|
||||
principal.FindFirstValue(ClaimTypes.Email);
|
||||
|
||||
clientContext.ClientName =
|
||||
principal.FindFirstValue("name") ??
|
||||
principal.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
clientContext.IsDevBypass = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session token validation (unchanged from original)
|
||||
/// </summary>
|
||||
private async Task<bool> TrySessionAuthAsync(HttpContext context, ClientContext clientContext, SqlService sql, string corrId)
|
||||
{
|
||||
string? token = null;
|
||||
|
||||
// Check X-Session-Token header first
|
||||
if (context.Request.Headers.TryGetValue("X-Session-Token", out var sessionHeader))
|
||||
token = sessionHeader.FirstOrDefault();
|
||||
|
||||
// Fall back to Authorization: Bearer (session token, not JWT)
|
||||
if (string.IsNullOrWhiteSpace(token) && context.Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
{
|
||||
var auth = authHeader.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(auth) && auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
token = auth["Bearer ".Length..].Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
_logger.LogWarning("[Auth] No session token provided | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogWarning("[Auth] Session validation starting | Corr={Corr}", corrId);
|
||||
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { sessionToken = token });
|
||||
var resp = await sql.ExecProcAsync("dbo.spSession", "validate", rqst, ct: context.RequestAborted);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
_logger.LogWarning("[Auth] Session validation failed: empty response | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
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;
|
||||
|
||||
_logger.LogWarning("[Auth] Session validated OK | ClientId={ClientId} Email={Email} | Corr={Corr}",
|
||||
clientContext.ClientId, clientContext.Email, corrId);
|
||||
|
||||
return clientContext.IsAuthenticated;
|
||||
}
|
||||
|
||||
_logger.LogWarning("[Auth] Session validation failed: ok=false | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Auth] Session validation error | Corr={Corr}", corrId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Development bypass
|
||||
/// </summary>
|
||||
private bool TryDevBypass(HttpContext context, ClientContext clientContext, string corrId)
|
||||
{
|
||||
var env = _config["ASPNETCORE_ENVIRONMENT"] ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
||||
var allowBypass = _config.GetValue<bool>("Auth:AllowDevBypass");
|
||||
|
||||
if (!string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase) && !allowBypass)
|
||||
return false;
|
||||
|
||||
if (!context.Request.Headers.TryGetValue("X-Dev-ClientId", out var devClientId))
|
||||
return false;
|
||||
|
||||
var clientId = devClientId.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(clientId))
|
||||
return false;
|
||||
|
||||
clientContext.ClientId = clientId;
|
||||
clientContext.IsDevBypass = true;
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Dev-TenantId", out var devTenantId))
|
||||
clientContext.TenantId = devTenantId.FirstOrDefault();
|
||||
|
||||
_logger.LogWarning("[Auth] Dev bypass OK | ClientId={ClientId} | Corr={Corr}", clientId, corrId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsProviderConfigured(string provider)
|
||||
{
|
||||
return provider switch
|
||||
{
|
||||
"microsoft" => !string.IsNullOrWhiteSpace(
|
||||
_config["Auth:Microsoft:ClientId"] ?? _config["Auth:EntraId:ClientId"] ?? _config["ENTRA_CLIENT_ID"]),
|
||||
"google" => !string.IsNullOrWhiteSpace(
|
||||
_config["Auth:Google:ClientId"] ?? _config["GOOGLE_CLIENT_ID"]),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsAnonymousPath(string pathLower)
|
||||
{
|
||||
if (_anonymousExact.Contains(pathLower))
|
||||
return true;
|
||||
return _anonymousPrefixes.Any(p => pathLower.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string EnsureCorrelationId(HttpContext context)
|
||||
{
|
||||
const string header = "X-Correlation-Id";
|
||||
if (!context.Request.Headers.TryGetValue(header, out var existing) || string.IsNullOrWhiteSpace(existing.FirstOrDefault()))
|
||||
{
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
context.Request.Headers[header] = id;
|
||||
return id;
|
||||
}
|
||||
return existing.First()!;
|
||||
}
|
||||
|
||||
private static void SetAuthHeaders(HttpContext context, string corrId, string authPath, string? authFail)
|
||||
{
|
||||
context.Response.Headers["X-Correlation-Id"] = corrId;
|
||||
context.Response.Headers["X-Auth-Path"] = authPath;
|
||||
if (!string.IsNullOrWhiteSpace(authFail))
|
||||
context.Response.Headers["X-Auth-Fail"] = authFail;
|
||||
}
|
||||
|
||||
private static ConfigurationManager<OpenIdConnectConfiguration> GetOrCreateOidcManager(string provider, string metadataAddress)
|
||||
{
|
||||
lock (_oidcLock)
|
||||
{
|
||||
if (!_oidcManagers.TryGetValue(provider, out var mgr))
|
||||
{
|
||||
mgr = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
metadataAddress,
|
||||
new OpenIdConnectConfigurationRetriever());
|
||||
_oidcManagers[provider] = mgr;
|
||||
}
|
||||
return mgr;
|
||||
}
|
||||
}
|
||||
}
|
||||
375
Gateway/Services/ExecutionService.cs
Normal file
375
Gateway/Services/ExecutionService.cs
Normal file
@@ -0,0 +1,375 @@
|
||||
using Gateway.Data;
|
||||
using Gateway.Security;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Gateway.Services;
|
||||
|
||||
public sealed class ExecutionService
|
||||
{
|
||||
private readonly SqlService _sql;
|
||||
private readonly IHttpClientFactory _http;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly ClientContext _client;
|
||||
private readonly ILogger<ExecutionService> _logger;
|
||||
|
||||
// Operations that don't require a linked account (health checks, etc.)
|
||||
private static readonly HashSet<string> AccountOptionalOperations = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Ping", "TestPing", "ListAccessibleCustomers"
|
||||
};
|
||||
|
||||
public ExecutionService(
|
||||
SqlService sql,
|
||||
IHttpClientFactory http,
|
||||
IConfiguration cfg,
|
||||
ClientContext client,
|
||||
ILogger<ExecutionService> logger)
|
||||
{
|
||||
_sql = sql;
|
||||
_http = http;
|
||||
_cfg = cfg;
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> ExecuteAsync(JsonElement reqJson, CancellationToken ct)
|
||||
{
|
||||
var requestId = Guid.NewGuid().ToString("N");
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
|
||||
// Extract clientId from authenticated context
|
||||
var clientId = _client.ClientId;
|
||||
|
||||
// Extract routing info: provider, service, action
|
||||
var provider = reqJson.TryGetProperty("provider", out var pv) ? pv.GetString() ?? "google" : "google";
|
||||
var service = reqJson.TryGetProperty("service", out var sv) ? sv.GetString() ?? "system" : "system";
|
||||
var action = reqJson.TryGetProperty("action", out var av) ? av.GetString() ?? "ping" : "ping";
|
||||
|
||||
// Legacy support: if "operation" is provided, use it as action
|
||||
string? operation = action;
|
||||
if (reqJson.TryGetProperty("operation", out var opProp) && opProp.ValueKind == JsonValueKind.String)
|
||||
operation = opProp.GetString();
|
||||
|
||||
// TenantId priority: 1) request body, 2) ClientContext, 3) null
|
||||
string? tenantId = null;
|
||||
if (reqJson.TryGetProperty("tenantId", out var tid) && tid.ValueKind == JsonValueKind.String)
|
||||
tenantId = tid.GetString();
|
||||
tenantId ??= _client.TenantId;
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Starting | RequestId={RequestId} ClientId={ClientId} TenantId={TenantId} Provider={Provider} Service={Service} Action={Action} DevBypass={DevBypass}",
|
||||
requestId, clientId, tenantId, provider, service, action, _client.IsDevBypass);
|
||||
|
||||
// ================================================================
|
||||
// AGENCY MODEL: Validate account and get loginCustomerId
|
||||
// ================================================================
|
||||
string? loginCustomerId = null;
|
||||
string? validatedClientName = null;
|
||||
|
||||
// Only validate if operation requires a linked account
|
||||
bool requiresAccount = !string.IsNullOrEmpty(operation) &&
|
||||
!AccountOptionalOperations.Contains(operation) &&
|
||||
!string.IsNullOrEmpty(tenantId);
|
||||
|
||||
if (requiresAccount)
|
||||
{
|
||||
var validation = await ValidateGoogleAccountAsync(tenantId!, ct);
|
||||
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[Execution] Account validation failed | RequestId={RequestId} TenantId={TenantId} Error={Error}",
|
||||
requestId, tenantId, validation.Error);
|
||||
|
||||
// Return error response without calling provider
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = false,
|
||||
status = 400,
|
||||
elapsedMs = 0,
|
||||
requestId,
|
||||
clientId,
|
||||
error = new
|
||||
{
|
||||
code = validation.ErrorCode ?? "VALIDATION_ERROR",
|
||||
message = validation.Error
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loginCustomerId = validation.LoginCustomerId;
|
||||
validatedClientName = validation.ClientName;
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Account validated | RequestId={RequestId} TenantId={TenantId} LoginCustomerId={LoginCustomerId} Client={ClientName}",
|
||||
requestId, tenantId, loginCustomerId, validatedClientName);
|
||||
}
|
||||
|
||||
// Log start (now includes clientId and routing info)
|
||||
int? logId = null;
|
||||
var startRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
action = "start",
|
||||
requestId,
|
||||
clientId,
|
||||
tenantId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
loginCustomerId,
|
||||
sessionId = _client.SessionId,
|
||||
userId = _client.UserId,
|
||||
isDevBypass = _client.IsDevBypass,
|
||||
req = reqJson
|
||||
});
|
||||
|
||||
var startResp = await _sql.ExecProcAsync("dbo.spAdpApiLog", "start", startRqst, ct: ct);
|
||||
using (var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(startResp) ? "{}" : startResp))
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("logId", out var e) && e.ValueKind == JsonValueKind.Number)
|
||||
logId = e.GetInt32();
|
||||
}
|
||||
|
||||
// Inject/override fields in request before forwarding to provider
|
||||
var enrichedRequest = EnrichRequest(reqJson, requestId, tenantId, loginCustomerId);
|
||||
|
||||
// Forward to provider (URL based on provider type)
|
||||
var sw = Stopwatch.StartNew();
|
||||
int providerStatus;
|
||||
string providerResp;
|
||||
try
|
||||
{
|
||||
var providerUrl = GetProviderUrl(provider);
|
||||
var key = GetProviderKey(provider);
|
||||
|
||||
var httpClient = _http.CreateClient();
|
||||
using var msg = new HttpRequestMessage(HttpMethod.Post, $"{providerUrl}/internal/execute");
|
||||
msg.Headers.Add("X-Internal-Key", key);
|
||||
msg.Headers.Add("X-Request-Id", requestId);
|
||||
msg.Content = new StringContent(enrichedRequest, System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var resp = await httpClient.SendAsync(msg, ct);
|
||||
providerStatus = (int)resp.StatusCode;
|
||||
providerResp = await resp.Content.ReadAsStringAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[Execution] Provider call failed | RequestId={RequestId}", requestId);
|
||||
providerStatus = 500;
|
||||
providerResp = JsonSerializer.Serialize(new { ok = false, requestId, error = ex.Message });
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"[Execution] Completed | RequestId={RequestId} ClientId={ClientId} Status={Status} ElapsedMs={ElapsedMs}",
|
||||
requestId, clientId, providerStatus, sw.ElapsedMilliseconds);
|
||||
|
||||
// Log finish (includes clientId and routing info for correlation)
|
||||
var finishRqst = JsonSerializer.Serialize(new
|
||||
{
|
||||
action = "finish",
|
||||
logId,
|
||||
requestId,
|
||||
clientId,
|
||||
provider,
|
||||
service,
|
||||
operation = action,
|
||||
providerStatus,
|
||||
elapsedMs = sw.ElapsedMilliseconds,
|
||||
resp = JsonDocument.Parse(providerResp).RootElement
|
||||
});
|
||||
|
||||
_ = await _sql.ExecProcAsync("dbo.spAdpApiLog", "finish", finishRqst, ct: ct);
|
||||
|
||||
// Wrap response with metadata
|
||||
var wrappedResponse = WrapResponse(providerResp, providerStatus, sw.ElapsedMilliseconds, requestId, clientId);
|
||||
return wrappedResponse;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a Google Ads customer ID is linked in the database.
|
||||
/// Returns loginCustomerId if account is found.
|
||||
/// </summary>
|
||||
private async Task<AccountValidation> ValidateGoogleAccountAsync(string customerId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rqst = JsonSerializer.Serialize(new { customerId });
|
||||
var resp = await _sql.ExecProcAsync("dbo.spGoogleAccount", "validate", rqst, ct: ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resp))
|
||||
{
|
||||
return AccountValidation.Invalid("VALIDATION_ERROR", "Account validation failed");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(resp);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// Check if validation succeeded
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
{
|
||||
// Check account status
|
||||
var accountStatus = root.TryGetProperty("accountStatus", out var accStat)
|
||||
? accStat.GetString() : null;
|
||||
var clientStatus = root.TryGetProperty("clientStatus", out var cltStat)
|
||||
? cltStat.GetString() : null;
|
||||
|
||||
if (accountStatus != "Active")
|
||||
{
|
||||
return AccountValidation.Invalid("ACCOUNT_INACTIVE",
|
||||
$"Google Ads account is {accountStatus?.ToLower() ?? "not active"}");
|
||||
}
|
||||
|
||||
if (clientStatus != "Active")
|
||||
{
|
||||
return AccountValidation.Invalid("CLIENT_INACTIVE",
|
||||
$"Client is {clientStatus?.ToLower() ?? "not active"}");
|
||||
}
|
||||
|
||||
// Extract loginCustomerId (manager account)
|
||||
var loginCustomerId = root.TryGetProperty("loginCustomerId", out var loginProp) &&
|
||||
loginProp.ValueKind == JsonValueKind.String
|
||||
? loginProp.GetString()
|
||||
: null;
|
||||
|
||||
var clientName = root.TryGetProperty("clientName", out var nameProp) &&
|
||||
nameProp.ValueKind == JsonValueKind.String
|
||||
? nameProp.GetString()
|
||||
: null;
|
||||
|
||||
return AccountValidation.Valid(loginCustomerId, clientName);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Validation failed - account not found
|
||||
var errorCode = root.TryGetProperty("errorCode", out var ecProp)
|
||||
? ecProp.GetString() : "ACCOUNT_NOT_FOUND";
|
||||
var error = root.TryGetProperty("error", out var errProp)
|
||||
? errProp.GetString() : "Account not found";
|
||||
|
||||
return AccountValidation.Invalid(errorCode, error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Account validation error for customerId={CustomerId}", customerId);
|
||||
return AccountValidation.Invalid("VALIDATION_ERROR", "Account validation failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enrich the request with requestId, tenantId, and loginCustomerId before forwarding to provider.
|
||||
/// </summary>
|
||||
private static string EnrichRequest(JsonElement original, string requestId, string? tenantId, string? loginCustomerId)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(original.GetRawText());
|
||||
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(doc.RootElement.GetRawText())
|
||||
?? new Dictionary<string, JsonElement>();
|
||||
|
||||
// Add/override requestId
|
||||
dict["requestId"] = JsonDocument.Parse($"\"{requestId}\"").RootElement;
|
||||
|
||||
// Add tenantId if we have one
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
dict["tenantId"] = JsonDocument.Parse($"\"{tenantId}\"").RootElement;
|
||||
}
|
||||
|
||||
// Add loginCustomerId (manager account) if we have one
|
||||
if (!string.IsNullOrWhiteSpace(loginCustomerId))
|
||||
{
|
||||
dict["loginCustomerId"] = JsonDocument.Parse($"\"{loginCustomerId}\"").RootElement;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrap provider response with Gateway metadata.
|
||||
/// </summary>
|
||||
private static string WrapResponse(string providerResp, int status, long elapsedMs, string requestId, string? clientId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var providerJson = JsonDocument.Parse(providerResp).RootElement;
|
||||
var wrapped = new
|
||||
{
|
||||
ok = status >= 200 && status < 300,
|
||||
status,
|
||||
elapsedMs,
|
||||
requestId,
|
||||
clientId,
|
||||
result = providerJson
|
||||
};
|
||||
return JsonSerializer.Serialize(wrapped, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If provider response isn't valid JSON, wrap as string
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
ok = false,
|
||||
status,
|
||||
elapsedMs,
|
||||
requestId,
|
||||
clientId,
|
||||
result = providerResp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of account validation.
|
||||
/// </summary>
|
||||
private sealed class AccountValidation
|
||||
{
|
||||
public bool IsValid { get; init; }
|
||||
public string? ErrorCode { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string? LoginCustomerId { get; init; }
|
||||
public string? ClientName { get; init; }
|
||||
|
||||
public static AccountValidation Valid(string? loginCustomerId, string? clientName) => new()
|
||||
{
|
||||
IsValid = true,
|
||||
LoginCustomerId = loginCustomerId,
|
||||
ClientName = clientName
|
||||
};
|
||||
|
||||
public static AccountValidation Invalid(string? errorCode, string? error) => new()
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorCode = errorCode,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get provider URL based on provider type.
|
||||
/// </summary>
|
||||
private string GetProviderUrl(string provider)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"meta" => _cfg["META_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
"msads" => _cfg["MSADS_PROVIDER_URL"]?.TrimEnd('/') ?? "",
|
||||
_ => _cfg["GOOGLE_PROVIDER_URL"]?.TrimEnd('/') ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get internal API key for provider.
|
||||
/// </summary>
|
||||
private string GetProviderKey(string provider)
|
||||
{
|
||||
return provider.ToLowerInvariant() switch
|
||||
{
|
||||
"google" => _cfg["GOOGLE_INTERNAL_KEY"] ?? "",
|
||||
"meta" => _cfg["META_INTERNAL_KEY"] ?? "",
|
||||
"msads" => _cfg["MSADS_INTERNAL_KEY"] ?? "",
|
||||
_ => _cfg["GOOGLE_INTERNAL_KEY"] ?? ""
|
||||
};
|
||||
}
|
||||
}
|
||||
12
Gateway/appsettings.Development.json
Normal file
12
Gateway/appsettings.Development.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
|
||||
"Auth": {
|
||||
"AllowDevBypass": true
|
||||
}
|
||||
}
|
||||
18
Gateway/appsettings.json
Normal file
18
Gateway/appsettings.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"Auth": {
|
||||
"AllowDevBypass": false,
|
||||
"EntraId": {
|
||||
"Instance": "https://login.microsoftonline.com/",
|
||||
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
|
||||
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Gateway/appsettings.multiprovider.json
Normal file
22
Gateway/appsettings.multiprovider.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"Auth": {
|
||||
"AllowDevBypass": false,
|
||||
|
||||
"Microsoft": {
|
||||
"TenantId": "891f98f1-ed34-42a1-9b6c-28b0554d92c2",
|
||||
"ClientId": "154c9111-14a0-4c0f-8132-7bc68254a74e"
|
||||
},
|
||||
|
||||
"Google": {
|
||||
"ClientId": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user