Matthew
Matthew

Reputation: 726

Identity Server 3 + ASP.NET Core 2.0 MVC app - Federated single sign-out not including a redirect to ADFS before ending session

My web app is client to an Identity Server 3 STS, which is federated with ADFS for the external IdP. Sign-in works great. Sign-out from the STS is fine. But I have never been able to get IdSrv3 to redirect to ADFS for sign-out prior to ending the IdSrv3 session and ultimately redirecting to the app.

If I understand correctly, I should be able to have ADFS post back to the RP (IdSrv3) after signing out, at which point IdSrv3

Read the docs: https://identityserver.github.io/Documentation/docsv2/advanced/federated-post-logout-redirect.html

As well as much of the anthology of the GitHub issues surrounding this topic of federated single sign-out.

Tracing through IdSrv3 I never see an attempt to redirect to ADFS for sign-out, so I assume I'm missing configuration here.

Once complexity is that I'm running IdSrv3 however my client apps are ASP.NET Core 2.0 so many of the samples don't cleanly reconcile with the latest Microsoft identity client middleware.

On the IdSrv3, these are (I believe) the relevant configuration components:

Configuration of Additional Identity Providers:

        var wsFed = new WsFederationAuthenticationOptions
        {
            Wtrealm = ConfigurationManager.AppSettings["Wtrealm"],
            MetadataAddress = metaDataAddress,
            AuthenticationType = "ADFS",
            Caption = "ACME ADFS",
            SignInAsAuthenticationType = signInAsType
        };

The IdSrv3 middleware:

coreApp.UseIdentityServer(
                    new IdentityServerOptions
                    {

                        SiteName = "eFactoryPro Identity Server",
                        SigningCertificate = Cert.Load(),
                        Factory = factory,
                        RequireSsl = true,

                        AuthenticationOptions = new AuthenticationOptions
                        {
                            IdentityProviders = ConfigureAdditionalIdentityProviders,
                            EnablePostSignOutAutoRedirect = true,
                            EnableSignOutPrompt = false,
                            EnableAutoCallbackForFederatedSignout = true
                        },
                        LoggingOptions = new LoggingOptions
                        {
                            EnableHttpLogging = true,
                            EnableKatanaLogging = true,
                            //EnableWebApiDiagnostics = true,
                            //WebApiDiagnosticsIsVerbose = true
                        }
                    });
                coreApp.Map("/signoutcallback", cleanup =>
                {
                    cleanup.Run(async ctx =>
                    {
                        var state = ctx.Request.Cookies["state"];
                        await ctx.Environment.RenderLoggedOutViewAsync(state);
                    });
                });
            });

Now for the Client side, an ASP.NET Core 2.0 MVC application:

Update: See accepted answer - the redirect to IdP for sign-out should have been handled on the IdSrv3 side with respect to redirecting to the external IdP (ADFS)

       public static void ConfigureAuth(this IServiceCollection services,
      ITicketStore distributedStore,
      Options.AuthenticationOptions authOptions)
    {

        services.AddDataProtection();

        services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
                options.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            }).AddCookie(options =>
            {
                options.ExpireTimeSpan = TimeSpan.FromHours(8);
                options.SlidingExpiration = true;
                options.SessionStore = distributedStore;
            })
            .AddOpenIdConnect(options =>
            {
                options.Authority = authOptions.Authority;
                options.ClientId = authOptions.ClientId;
                options.ClientSecret = authOptions.ClientSecret;

                options.ResponseType = "code id_token";

                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Scope.Add("roles");
                options.Scope.Add("email");
                options.Scope.Add("offline_access");

                options.RequireHttpsMetadata = false;

                options.GetClaimsFromUserInfoEndpoint = true;

                options.SaveTokens = true;

                options.Events = new OpenIdConnectEvents()
                {
                    OnRedirectToIdentityProviderForSignOut = n =>
                    {

                        var idTokenHint = n.ProtocolMessage.IdTokenHint;

                        if (!string.IsNullOrEmpty(idTokenHint))
                        {
                            var sessionId = n.HttpContext?.Session?.Id;
                            var signOutRedirectUrl = n.ProtocolMessage.BuildRedirectUrl();

                            if (sessionId != null)
                            {                                    
                                n.HttpContext.Response.Cookies.Append("state", sessionId);
                            }

                            n.HttpContext?.Session?.Clear();

                            n.Response.Redirect(signOutRedirectUrl);
                        }

                        return Task.FromResult(0);
                    }
                };
            });

    }

From the documentation I should be passing the "sign out message id" into that 'state' cookie. However, this extension method doesn't work in ASP.NET Core 2.0 as we don't really have access to OwinContext anymore.

var signOutMessageId = n.OwinContext.Environment.GetSignOutMessageId();

I've even tried instantiating a new OwinContext(n.HttpContext) to get at the environment dictionary - however, the value that the "GetSignOutMessageId()" obtains has a key of "id" which I can't find in the Owin variables.

It seems this cookie is really just necessary to persist state through all of the redirects so that after the PostLogoutUri of my client application is hit, which is currently set to "https://myapp/signout-callback-oidc", the message id can be used to finish cleaning up the session.

I'm also confused as to what role the "EnableAutoCallbackForFederatedSignout = true" setting plays on the IdSrv3 configuration.

From this description and looking at the code it would apear that this just saves me from having to set the "WReply" parameters on the ADFS signout: https://github.com/IdentityServer/IdentityServer3/issues/2613 I would expect that ADFS would redirect to: "https://myIdSrv3/core/signoutcallback" automatically if this settings was 'true'.

If anyone has any guidance to share it is much appreciated.

Upvotes: 1

Views: 824

Answers (1)

Matthew
Matthew

Reputation: 726

It turns out I was conflating some of the concepts in IdSrv3 that describe Federated Single Sign-Out initiated by the External Idp as opposed to my use case - sign-out initiated by the IdSrv3 client app, cascading "up" to the external IdP.

The root cause of this problem was in my UserService implementation. There I had overriden the "AuthenticateExternalAsync()" method, but did not specify the external identity provider in the AuthenticateResult object.

Here is the corrected implementation:

        public override Task AuthenticateExternalAsync(ExternalAuthenticationContext context)
        {

         ...

                context.AuthenticateResult = new AuthenticateResult(
                    user.Id, 
                    user.UserName, 
                    new List<Claim>(), 
                    context.ExternalIdentity.Provider);

            return Task.FromResult(0);
        }

Once the External Idp was specified in my AuthenticateResult, I was able to handle the WsFederationAuthenticationNotifications.RedirectToIdentityProvider event.

For the sake of completeness, here is my code to handle federated sign-out (client intiatited) from ADFS vis WsFed. It is more or less straight from the IdSrv3 documentation:

 Notifications = new WsFederationAuthenticationNotifications()
            {
                RedirectToIdentityProvider = n =>
                {

                    if (n.ProtocolMessage.IsSignOutMessage)
                    {
                        var signOutMessageId = n.OwinContext.Environment.GetSignOutMessageId();
                        if (signOutMessageId != null)
                        {
                            n.OwinContext.Response.Cookies.Append("state", signOutMessageId);
                        }

                        var cleanUpUri =
                            $@"{n.Request.Scheme}://{n.Request.Host}{n.Request.PathBase}/external-signout-cleanup";

                        n.ProtocolMessage.Wreply = cleanUpUri;
                    }

                    return Task.FromResult(0);
                }
            }

And finally, my /external-signout-cleanup implementation:

                coreApp.Map("/external-signout-cleanup", cleanup =>
                {
                    cleanup.Run(async ctx =>
                    {
                        var state = ctx.Request.Cookies["state"];
                        await ctx.Environment.RenderLoggedOutViewAsync(state);
                    });
                });

Upvotes: 2

Related Questions