Sustainsys.Saml2用app.Map()实现多租户时ACS端点响应接收异常
问题描述
现有多租户应用支持每个租户使用独立IdP完成身份认证,当前代码可正常跳转至对应IdP认证页,但认证完成后ACS端点无法接收到IdP返回的认证响应。
核心配置在Startup的Configuration方法中,通过Owin的Map方法按租户划分路由分支,每个分支独立配置对应IdP的Saml2认证逻辑:
[assembly: OwinStartup(typeof(SSOSamlDemoASPNET.App_Start.Startup))] namespace SSOSamlDemoASPNET.App_Start { public class Startup { public void Configuration(IAppBuilder app) { app.Map("/client/okta", (appx) => { ConfigureAuthentication(appx, "/client/okta/Saml2", ...); }); app.Map("/client/azuread", (appx) => { ConfigureAuthentication(appx, "/client/azuread/Saml2", ...); }); } private static void ConfigureAuthentication(IAppBuilder app, string modulePath, string audience, string issuer, string metadataUrl) { app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType); app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationType = CookieAuthenticationDefaults.AuthenticationType, CookieName = "LoggedUser", CookiePath = "/", CookieManager = new SystemWebCookieManager(), }); app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); ConfigureSaml(app, modulePath, audience, issuer, metadataUrl); } private static void ConfigureSaml(IAppBuilder app, string modulePath, string audience, string issuer, string metadataUrl) { var saml2options = new Saml2AuthenticationOptions(false); var spOptions = new SPOptions { EntityId = new EntityId(audience), ModulePath = modulePath, PublicOrigin = new Uri("https://localhost:44340/"), }; spOptions.Logger = new ConsoleLoggerAdapter(); saml2options.SPOptions = spOptions; saml2options.IdentityProviders.Add(new IdentityProvider(new EntityId(issuer), spOptions) { AllowUnsolicitedAuthnResponse = true, MetadataLocation = metadataUrl, LoadMetadata = true, Binding = Saml2BindingType.HttpPost, }); app.UseSaml2Authentication(saml2options); } } }
单个IdP的认证发起逻辑如下:
authProperties.Dictionary["idp"] = "https://sts.windows.net/xxx/"; authProperties.RedirectUri = "https://localhost:44340/client/azuread/ExternalLoginCallback"; HttpContext.Current.Request.GetOwinContext().Authentication.Challenge(authProperties, "Saml2");
根因定位
排查Sustainsys.Saml2库源码中Saml2AuthenticationHandler的实现后确认,问题出在路径匹配逻辑未适配Owin Map的路径处理规则:Owin执行Map分支时,会把匹配到的路径前缀从Request.Path移动到Request.PathBase属性中,但Saml2中间件的路径判断逻辑只读取Request.Path做前缀匹配,完全忽略PathBase,导致ACS请求无法命中处理逻辑。
实际出问题时的参数值:
- 代码中配置的
Options.SPOptions.ModulePath = /client/azuread/Saml2 - 请求进入Map分支后,
/client/azuread前缀被移到PathBase,剩余Request.Path = /Saml2/Acs
路径匹配时拿/Saml2/Acs去匹配前缀/client/azuread/Saml2,自然匹配失败,ACS处理逻辑不会执行。
对应源码逻辑片段如下:
public override async Task<bool> InvokeAsync() { var Saml2Path = new PathString(Options.SPOptions.ModulePath); if (Request.Path.StartsWithSegments(Saml2Path, out PathString remainingPath)) { if (remainingPath == new PathString("/" + CommandFactory.AcsCommandName)) { var ticket = (MultipleIdentityAuthenticationTicket)await AuthenticateAsync(); if (ticket.Identities.Any()) { Context.Authentication.SignIn(ticket.Properties, ticket.Identities.ToArray()); } else { Response.Redirect(ticket.Properties.RedirectUri); } return true; } } }
解决方案
方案1:调整ModulePath为分支内相对路径(无侵入,推荐)
这是最简单的适配方式,完全遵循Owin Map的路径规则,不需要修改库代码或添加自定义拦截逻辑:
在Map分支内部配置Saml2时,ModulePath只需要传分支内的相对路径,不要带Map的前缀路径。修改配置如下:
app.Map("/client/okta", (appx) => { // 分支内ModulePath配置为/Saml2,不带/client/okta前缀 ConfigureAuthentication(appx, "/Saml2", ...); }); app.Map("/client/azuread", (appx) => { // 分支内ModulePath配置为/Saml2,不带/client/azuread前缀 ConfigureAuthentication(appx, "/Saml2", ...); });
修改后Saml2中间件在分支内匹配的路径前缀就是/Saml2,和Map切分后的Request.Path完全匹配,ACS请求可以正常命中处理逻辑。库会自动结合PublicOrigin和PathBase生成完整的ACS地址返回给IdP,不需要手动拼接全路径。
额外优化建议:把CookieAuthentication中间件的注册移到Map外层的根管道上,不要在每个租户分支重复注册,避免多分支Cookie加密隔离导致登录状态异常。
方案2:通过Notifications自定义路径修正逻辑
如果因为业务约束不能调整ModulePath配置,可以通过Saml2Options提供的Notifications扩展点,在请求进入Saml2中间件前手动修正路径参数,让内置匹配逻辑正常工作:
private static void ConfigureSaml(IAppBuilder app, string modulePath, string audience, string issuer, string metadataUrl) { var saml2options = new Saml2AuthenticationOptions(false); // 省略原有SPOptions和IdP配置逻辑... saml2options.Notifications = new Saml2Notifications { MessageReceived = context => { var request = context.OwinContext.Request; var fullRequestPath = request.PathBase + request.Path; var expectedAcsFullPath = new PathString(modulePath + "/Acs"); if (fullRequestPath == expectedAcsFullPath) { // 重置Path为全路径,清空PathBase,让内置路径匹配逻辑命中 request.Path = fullRequestPath; request.PathBase = PathString.Empty; } return Task.CompletedTask; } }; app.UseSaml2Authentication(saml2options); }
内容的提问来源于stack exchange,提问作者Pavel Sem

