Skip to content

Instantly share code, notes, and snippets.

@Meir017
Last active February 17, 2026 18:11
Show Gist options
  • Select an option

  • Save Meir017/b62d9bdecb210df312759cf927df4ae7 to your computer and use it in GitHub Desktop.

Select an option

Save Meir017/b62d9bdecb210df312759cf927df4ae7 to your computer and use it in GitHub Desktop.
yarp-tenant-auth-router
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ReverseProxy": {
"Routes": {
"tenant-route": {
"ClusterId": "backend-cluster",
"AuthorizationPolicy": "RequireAuth",
"Match": {
"Path": "/api/{**catch-all}"
}
}
},
"Clusters": {
"backend-cluster": {
"Destinations": {
"destination1": {
"Address": "https://httpbin.org"
}
}
}
}
}
}
#:sdk Microsoft.NET.Sdk.Web
#:package Microsoft.AspNetCore.Authentication.JwtBearer@10.0.3
#:package Yarp.ReverseProxy@2.3.0
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Yarp.ReverseProxy.Transforms;
using Yarp.ReverseProxy.Transforms.Builder;
var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
{
// Local testing: authenticate via X-Test-Tenant-Id header
builder.Services.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.SchemeName, _ => { });
}
else
{
// Production: Azure AD / Entra ID JWT Bearer
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.microsoftonline.com/common/v2.0";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false, // multi-tenant
ValidAudiences = new[] { builder.Configuration["AzureAd:ClientId"]! }
};
});
}
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("RequireAuth", policy => policy.RequireAuthenticatedUser());
});
// Register your tenant service
builder.Services.AddSingleton<ITenantService, TenantService>();
// YARP
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms<TenantTransformProvider>();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy();
app.Run();
// ── Tenant service ──────────────────────────────────────────────────────────────
public interface ITenantService
{
Task<TenantInfo> GetTenantInfoAsync(string tenantId, CancellationToken cancellationToken = default);
}
public record TenantInfo(string DestinationUrl, string TenantName);
public class TenantService : ITenantService
{
public Task<TenantInfo> GetTenantInfoAsync(string tenantId, CancellationToken cancellationToken = default)
{
return tenantId switch
{
"tenant1" => Task.FromResult(new TenantInfo("https://tenant1.api.example.com", "Tenant One")),
"tenant2" => Task.FromResult(new TenantInfo("https://tenant2.api.example.com", "Tenant Two")),
_ => Task.FromResult(new TenantInfo("https://default.api.example.com", "Default Tenant"))
};
}
}
// ── YARP tenant transform ───────────────────────────────────────────────────────
public class TenantTransformProvider(ITenantService tenantService, ILogger<TenantTransformProvider> logger) : ITransformProvider
{
public void ValidateRoute(TransformRouteValidationContext context) { }
public void ValidateCluster(TransformClusterValidationContext context) { }
public void Apply(TransformBuilderContext context)
{
logger.LogInformation("Applying tenant transform for route {RouteId}", context.Route.RouteId);
if (context.Route.AuthorizationPolicy is null)
return;
logger.LogInformation("Route {RouteId} requires authorization, adding tenant transform", context.Route.RouteId);
context.AddRequestTransform(async transformContext =>
{
logger.LogInformation("Executing tenant transform for route {RouteId}", context.Route.RouteId);
var httpContext = transformContext.HttpContext;
var tid = httpContext.User.FindFirstValue("http://schemas.microsoft.com/identity/claims/tenantid")
?? httpContext.User.FindFirstValue("tid");
logger.LogInformation("Extracted tenant ID {TenantId} from user claims", tid);
if (string.IsNullOrEmpty(tid))
return;
logger.LogInformation("Retrieving tenant info for tenant ID {TenantId}", tid);
var tenantInfo = await tenantService.GetTenantInfoAsync(tid, httpContext.RequestAborted);
logger.LogInformation("Retrieved tenant info for tenant ID {TenantId}: {TenantInfo}", tid, tenantInfo);
transformContext.ProxyRequest.Headers.Add("X-Tenant-Id", tid);
transformContext.ProxyRequest.Headers.Add("X-Tenant-Name", tenantInfo.TenantName);
transformContext.ProxyRequest.RequestUri = new Uri(tenantInfo.DestinationUrl + httpContext.Request.Path);
logger.LogInformation("Tenant transform completed for tenant ID {TenantId}", tid);
});
}
}
// ── Dev auth handler ────────────────────────────────────────────────────────────
/// <summary>
/// A fake authentication handler for local development.
/// Pass a tenant ID via the "X-Test-Tenant-Id" header to simulate an authenticated user.
/// </summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "TestScheme";
public const string TenantHeader = "X-Test-Tenant-Id";
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var tenantId = Request.Headers[TenantHeader].FirstOrDefault();
if (string.IsNullOrEmpty(tenantId))
return Task.FromResult(AuthenticateResult.Fail($"Missing {TenantHeader} header"));
var claims = new[]
{
new Claim("tid", tenantId),
new Claim(ClaimTypes.Name, $"testuser@{tenantId}.onmicrosoft.com"),
};
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@host = http://localhost:5264
### Tenant 1 — proxied request
GET {{host}}/api/get
X-Test-Tenant-Id: tenant1
Accept: application/json
### Tenant 2 — proxied request
GET {{host}}/api/get
X-Test-Tenant-Id: tenant2
Accept: application/json
### No tenant header — should return 401
GET {{host}}/api/get
Accept: application/json
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5264",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7010;http://localhost:5264",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment