Add project files.

This commit is contained in:
Grae Jones
2026-02-03 15:04:37 -08:00
parent a4838b594d
commit 8e7e03702e
65 changed files with 6227 additions and 0 deletions

View 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; }
}

View 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");
}
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
@Gateway_HostAddress = http://localhost:5255
GET {{Gateway_HostAddress}}/weatherforecast/
Accept: application/json
###

View 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; } = "";
}

View 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; }
}

View 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; }
}

View 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; }
}
}

View 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; }
}

View 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; }
}

View 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
View 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();

View 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
}
}
}

View 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);
}
}

View 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>();
}
}

View 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;
}
}
}

View 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);
}

View 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;
}
}
}

View 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"] ?? ""
};
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Auth": {
"AllowDevBypass": true
}
}

18
Gateway/appsettings.json Normal file
View 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"
}
}
}

View 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": ""
}
}
}