Sustainsys.Saml2 MVC multitenant implementation in .NET 4.8 IDX10214: Audience validation failed

188 views Asked by At

We recently migrated our applications from BasicAuth to SAML2. So we have a multitenant MVC implementation in .NET 4.8 of Sustaninsys.SAML2 which is working fine on low load. But if many users sign in at the same time, something is getting mixed up and this error is thrown in ACS method when running the AcsCommand:

IDX10214: Audience validation failed. Audiences: 'tenant_a'. Did not match: validationParameters.ValidAudience: 'tenant_b' or validationParameters.ValidAudiences: 'null'.

When going through logs it looks like sessions / cookies or whatever get mixed up. So EntityId and ReturnUrl of user1 for tenant_a is suddenly routed to user2. And user2's EntityId and ReturnUrl for tenant_b is ending up at user1. But the context stays at the original tenant. Which is why error is thrown.

Since we need some custom logic in the Saml2Controller we copied most of it from the SAML2 Github and extended it. So SignIn:

public ActionResult SignIn()
    {
        Options.SPOptions.ReturnUrl = this.GetReturnUrl();
        Options.SPOptions.Saml2PSecurityTokenHandler = null;
        Options.SPOptions.EntityId = new EntityId(GetClientId());

        var result = CommandFactory.GetCommand(CommandFactory.SignInCommandName).Run(
            Request.ToHttpRequestData(),
            Options);

        result.ApplyCookies(Response, true);
        return result.ToActionResult();
    }

And Acs:

public ActionResult Acs()
    {
        // This is where error occures
        result = CommandFactory.GetCommand(CommandFactory.AcsCommandName).Run(
            Request.ToHttpRequestData(),
            Options);

        // lots of custom code

        result.SignInOrOutSessionAuthenticationModule();
        result.ApplyCookies(Response, true);
        return result.ToActionResult();
    }

The web.config for all our tenants look identical since EntityId and ReturnUrl are assigned in the controller:

<sustainsys.saml2 authenticateRequestSigningBehavior="Always">
    <nameIdPolicy allowCreate="true" format="Persistent">
    </nameIdPolicy>
    <identityProviders>
        <add entityId="https://idp.de/auth/realms/zzz" 
            logoutUrl="https://idp.de/auth/realms/zzz/protocol/saml" 
            signOnUrl="https://idp.de/auth/realms/zzz/protocol/saml" 
            allowUnsolicitedAuthnResponse="true" 
            relayStateUsedAsReturnUrl="true" 
            binding="HttpPost">
            <signingCertificate fileName="~/App_Data/secret.cer" />
        </add>
    </identityProviders>
    <federations>
        <add metadataLocation="~/App_Data/" allowUnsolicitedAuthnResponse="true" />
    </federations>
    <serviceCertificates>
        <add storeName="My" storeLocation="LocalMachine" findValue="123456789" x509FindType="FindBySerialNumber" use="Signing" />
    </serviceCertificates>
</sustainsys.saml2>

Does anyone see where the mix up happens? Or is this an issue from the IDP which is Red Hat Keycloak.

UPDATE

In our Saml2Controller we use IOptions from the refrence implementation:

public static IOptions Options
    {
        get { return Sustainsys.Saml2.Mvc.Saml2Controller.Options; }
        set { Sustainsys.Saml2.Mvc.Saml2Controller.Options = value; }
    }
2

There are 2 answers

1
Anders Abel On BEST ANSWER

With a multi tenant setup and the MVC controller library, the best solution is to add one IdentityProvider object per tenant. If you do not know/want to create them all on startup, you can use get SelectIdentityProvider and GetIdentityProvider notifications to supply them from your own store.

Messing with the options per request is not how the library was designed to work.

0
z00mable On

As it turns out, to use a static IOptions object, that is used by Login and Acs for many users is not the best idea. When more than one user logs in at the same time, it will be overwritten. We had to implement a simple cache and create an IOptions object for every login. Be careful - this simple cache might not work for load balancers! Use a distributed one.

    private IOptions LoadOptionsFromConfiguration()
    {
        var spOptions = new SPOptions(SustainsysSaml2Section.Current)
        {
            ReturnUrl = new Uri(this.GetReturnUrl()),
            Saml2PSecurityTokenHandler = null,
            EntityId = new EntityId(GetClientId()),
        };
        var options = new Options(spOptions);
        SustainsysSaml2Section.Current.IdentityProviders.RegisterIdentityProviders(options);
        SustainsysSaml2Section.Current.Federations.RegisterFederations(options);

        return options;
    }
    
    public ActionResult SignIn()
    {
        var options = this.LoadOptionsFromConfiguration();

        var result = CommandFactory.GetCommand(CommandFactory.SignInCommandName).Run(
            Request.ToHttpRequestData(),
            options);

        this.cache.Set(result.SetCookieName, options, DateTimeOffset.Now.AddHours(6));
        result.ApplyCookies(Response, true);
        return result.ToActionResult();
    }

    public ActionResult Acs()
    {
        var requestData = Request.ToHttpRequestData();
        var cookieName = "Saml2." + requestData.RelayState;
        var options = this.cache.Remove(cookieName) as IOptions;

        result = CommandFactory.GetCommand(CommandFactory.AcsCommandName).Run(
            requestData,
            options);

        result.SignInOrOutSessionAuthenticationModule();
        result.ApplyCookies(Response, true);
        return result.ToActionResult();
    }