如何从ASP.NET Core API手动重定向至Azure AD租户专属登录页
我们有一个多租户应用,所有租户的Azure AD信息都存储在数据库中,不需要在应用启动时配置Azure AD认证。需要在完成部分操作后从数据库获取这些信息,为每个租户动态配置对应的Microsoft Azure AD登录页,并且不在appsettings.json中存储任何租户的AD信息。
业务场景
API中有如下接口,当用户未授权时,需要触发Azure AD登录挑战,通过认证后才能获取用户列表:
[HttpGet] public async Task<IActionResult> GetUsers() { // 引导用户使用Microsoft AzureAD登录 // 通过认证后,用户才能获取用户列表 var users = new List<string>{ "Mike, Jhon"}; return Ok(users); }
同时,React前端向该API发起请求时,若租户用户未授权,需要将其重定向至所属租户的专属登录页。
疑问与尝试
这个需求是否可行?我已经做了大量调研但没找到解决方案,若有实现思路或实践经验,请详细说明方法。
我曾尝试以下两种方式,但都未达到预期效果:
- 直接调用Azure AD授权端点:
var response = await new HttpClient().GetAsync(@"https://login.microsoftonline.com/xxx43de-b3fb-efdea0768eb7/oauth2/v2.0/authorize? client_id=xxxxx-efdea0768eb7& response_type=id_token& redirect_uri=https://localhost:7008/signin-oidc& scope=openid& response_mode=form_post& state=12345& nonce=678910");
- 使用ASP.NET Core的Challenge方法:
return Challenge(new AuthenticationProperties { RedirectUri = "/" }, OpenIdConnectDefaults.AuthenticationScheme);
这个需求完全可行,核心是动态构建OpenID Connect认证方案,而非在启动时固定配置。下面是具体实现步骤:
1. 基础配置:禁用启动时的固定Azure AD配置
确保Program.cs中只保留认证服务的基础注册,不要硬编码OpenID Connect配置:
builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; }) .AddCookie(); // 不要在这里添加固定的AddOpenIdConnect配置
2. 动态加载租户AD信息并构建认证方案
在需要触发登录挑战的逻辑中,先从数据库获取当前租户的Azure AD信息(租户ID、客户端ID、重定向URI等),然后动态创建专属的OpenID Connect认证方案:
核心方法:动态注册认证方案
通过IAuthenticationSchemeProvider和IOptionsMonitorCache实现动态方案注册:
private async Task<AuthenticationScheme> GetOrCreateTenantAuthScheme(TenantAdInfo tenantAdInfo) { var schemeName = $"OpenIDConnect-Tenant-{tenantAdInfo.TenantId}"; var schemeProvider = HttpContext.RequestServices.GetRequiredService<IAuthenticationSchemeProvider>(); // 检查方案是否已存在,避免重复创建 var existingScheme = await schemeProvider.GetSchemeAsync(schemeName); if (existingScheme != null) return existingScheme; // 构建OpenID Connect配置选项 var options = new OpenIdConnectOptions(schemeName) { ClientId = tenantAdInfo.ClientId, Authority = $"https://login.microsoftonline.com/{tenantAdInfo.TenantId}/v2.0", RedirectUri = tenantAdInfo.BackendRedirectUri, // 后端回调地址,如/signin-oidc ResponseType = OpenIdConnectResponseType.CodeIdToken, Scope = { "openid", "profile" }, SaveTokens = true, // 自定义Token验证后的逻辑,比如关联租户用户身份 Events = new OpenIdConnectEvents { OnTokenValidated = context => { // 这里可添加租户用户的身份映射逻辑 return Task.CompletedTask; } } }; // 缓存配置选项并注册新认证方案 var optionsCache = HttpContext.RequestServices.GetRequiredService<IOptionsMonitorCache<OpenIdConnectOptions>>(); optionsCache.TryAdd(schemeName, options); var newScheme = new AuthenticationScheme(schemeName, $"Tenant-{tenantAdInfo.TenantId}", typeof(OpenIdConnectHandler)); await schemeProvider.AddSchemeAsync(newScheme); return newScheme; }
3. 在接口中触发动态登录挑战
修改GetUsers接口,先识别当前租户、检查用户认证状态,未授权时触发动态Challenge:
[HttpGet] public async Task<IActionResult> GetUsers() { // 从请求上下文(如域名、请求头)识别当前租户ID var tenantId = ExtractTenantIdFromRequest(); var tenantAdInfo = await _tenantDbRepository.GetAdInfoByTenantId(tenantId); if (!HttpContext.User.Identity.IsAuthenticated) { var authScheme = await GetOrCreateTenantAuthScheme(tenantAdInfo); // 认证完成后重定向回当前接口 var properties = new AuthenticationProperties { RedirectUri = Request.Path }; return Challenge(properties, authScheme.Name); } var users = new List<string>{ "Mike", "Jhon"}; return Ok(users); }
4. 适配前端重定向逻辑
因为是React前端发起请求,直接返回302重定向会导致前端无法正确处理,需要调整为:
- API检测到未授权时,返回401 Unauthorized,并在响应头中携带租户专属的登录URL
- 前端收到401后,读取登录URL并跳转到该地址,认证完成后回调回前端页面,再重新发起API请求
修改接口的未授权返回逻辑:
if (!HttpContext.User.Identity.IsAuthenticated) { // 构建Azure AD授权URL var loginUrl = $"https://login.microsoftonline.com/{tenantAdInfo.TenantId}/oauth2/v2.0/authorize?" + $"client_id={tenantAdInfo.ClientId}" + $"&response_type=code" + $"&redirect_uri={Uri.EscapeDataString(tenantAdInfo.FrontendRedirectUri)}" + $"&scope=openid profile api://{tenantAdInfo.ClientId}/access_as_user" + $"&state={Uri.EscapeDataString(Request.Path)}" + // 保存原请求路径,认证后跳转 $"&nonce={Guid.NewGuid().ToString()}"; Response.Headers.Add("X-Login-Url", loginUrl); return Unauthorized(); }
5. 后端回调端点处理
如果使用后端signin-oidc回调,需要从请求中提取租户信息,动态加载对应认证方案完成处理:
[HttpPost("/signin-oidc")] public async Task<IActionResult> SignInOidc() { // 从state参数解析租户ID和原请求路径 var state = Request.Form["state"].ToString(); var (tenantId, originalPath) = ParseStateData(state); var tenantAdInfo = await _tenantDbRepository.GetAdInfoByTenantId(tenantId); var authScheme = await GetOrCreateTenantAuthScheme(tenantAdInfo); var authResult = await HttpContext.AuthenticateAsync(authScheme.Name); if (authResult.Succeeded) { await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, authResult.Principal); return Redirect(originalPath); } return BadRequest("认证失败"); }
之前尝试失败的原因
- 直接调用HttpClient的方式:这是后端发起的请求,不是引导用户浏览器跳转,用户看不到登录页面,无法完成交互认证。
- 固定Challenge方法:没有提前配置对应租户的OpenID Connect方案,默认方案缺少租户专属信息,导致跳转到错误的登录页。
内容的提问来源于stack exchange,提问作者Samir Hemzeyev

