Last active
February 17, 2026 18:11
-
-
Save Meir017/b62d9bdecb210df312759cf927df4ae7 to your computer and use it in GitHub Desktop.
yarp-tenant-auth-router
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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" | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #: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)); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "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