简体   繁体   中英

IdentityServer4 as external provider, how to avoid logout prompt?

I am working with two identity providers, both implemented using IdentityServer4 in ASP.NET MVC Core 2.2. One of them is used as an external provider by the other. Let's call them "primary" and "external". The primary provider is referenced directly by the web application. The external provider is an optional login method provided by the primary provider.

The web application uses the oidc-client-js library to implement authentication. The logout operation in the web app calls UserManager.signoutRedirect . This works fine when the primary identity provider is used (no logout confirmation prompt is shown). However, when the external provider is used, the user is prompted to sign out from the external provider.

The sequence of requests when logging out are:

  • GET http://{primary}/connect/endsession?id_token_hint=...&post_logout_redirect_uri=http://{webapp}
  • GET http://{primary}/Account/Logout?logoutId=...
  • GET http://{external}/connect/endsession?state=...&post_logout_redirect_uri=http://{primary}/signout-callback-{idp}&x-client-SKU=ID_NETSTANDARD2_0&x-client-ver=5.3.0.0
  • GET http://{external}/Account/Logout?logoutId=...

This last request above shows the logout confirmation screen from the external provider.

The code for the /Account/Logout page on the primary provider is almost identical to the sample code in the documentation :

[HttpGet]
public async Task<IActionResult> Logout(string logoutId)
{
    var vm = await BuildLogoutViewModelAsync(logoutId);

    if (!vm.ShowLogoutPrompt)
    {
        // If the request is authenticated don't show the prompt,
        // just log the user out by calling the POST handler directly.
        return Logout(vm);
    }

    return View(vm);
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Logout(LogoutInputModel model)
{
    var vm = await BuildLoggedOutViewModelAsync(model.LogoutId);

    if (User?.Identity.IsAuthenticated)
    {
        // delete local authentication cookie
        await _signInManager.SignOutAsync();

        // raise the logout event
        await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName()));
    }

    // check if we need to trigger sign-out at an upstream identity provider
    if (vm.TriggerExternalSignout)
    {
        // build a return URL so the upstream provider will redirect back
        // to us after the user has logged out. this allows us to then
        // complete our single sign-out processing.
        var url = Url.Action("Logout", new { logoutId = vm.LogoutId });

        // this triggers a redirect to the external provider for sign-out
        var ap = new AuthenticationProperties { RedirectUri = url };
        return SignOut(ap, vm.ExternalAuthenticationScheme);
    }

    return View("LoggedOut", vm);
}

The BuildLogoutViewModelAsync method calls GetLogoutContextAsync to check if the logout is authenticated, like so:

public async Task<LogoutViewModel> BuildLogoutViewModelAsync(string logoutId)
{
    var vm = new LogoutViewModel
        {
            LogoutId = logoutId,
            ShowLogoutPrompt = true
        };

    var context = await _interaction.GetLogoutContextAsync(logoutId);
    if (context?.ShowSignoutPrompt == false)
    {
        // It's safe to automatically sign-out
        vm.ShowLogoutPrompt = false;
    }

    return vm;
}

The BuildLoggedOutViewModelAsync method basically just checks for an external identity provider and sets the TriggerExternalSignout property if one was used.

I hate to make this a wall of code, but I'll include the ConfigureServices code used to configure the primary identity server because it is probably relevant:

var authenticationBuilder = services.AddAuthentication();
authenticationBuilder.AddOpenIdConnect(openIdConfig.Scheme, "external", ConfigureOptions);

void ConfigureOptions(OpenIdConnectOptions opts)
{
    opts.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
    opts.SignOutScheme = IdentityServerConstants.SignoutScheme;
    opts.Authority = openIdConfig.ProviderAuthority;
    opts.ClientId = openIdConfig.ClientId;
    opts.ClientSecret = openIdConfig.ClientSecret;
    opts.ResponseType = "code id_token";
    opts.RequireHttpsMetadata = false;
    opts.CallbackPath = $"/signin-{openIdConfig.Scheme}";
    opts.SignedOutCallbackPath = $"/signout-callback-{openIdConfig.Scheme}";
    opts.RemoteSignOutPath = $"/signout-{openIdConfig.Scheme}";

    opts.Scope.Clear();
    opts.Scope.Add("openid");
    opts.Scope.Add("profile");
    opts.Scope.Add("email");
    opts.Scope.Add("phone");
    opts.Scope.Add("roles");

    opts.SaveTokens = true;
    opts.GetClaimsFromUserInfoEndpoint = true;

    var mapAdditionalClaims = new[] { JwtClaimTypes.Role, ... };
    foreach (string additionalClaim in mapAdditionalClaims)
    {
        opts.ClaimActions.MapJsonKey(additionalClaim, additionalClaim);
    }

    opts.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = JwtClaimTypes.Name,
            RoleClaimType = JwtClaimTypes.Role
        };
}

My understanding is that the id_token_hint parameter passed to the first /connect/endsession endpoint will "authenticate" the logout request, which allows us to bypass the prompt based on the ShowSignoutPrompt property returned by GetLogoutContextAsync . However, this does not happen when the user is redirected to the external provider. The call to SignOut generates the second /connect/endsession URL with a state parameter, but no id_token_hint .

The logout code in the external provider is basically the same as the code shown above. When it calls GetLogoutContextAsync , that method does not see the request as authenticated, so the ShowSignoutPrompt property is true.

Any idea how to authenticate the request to the external provider?

The final block of code, you hate, but luckily added, contains one significant row:

opts.SaveTokens = true;

That allows you later to restore the id_token you got from the external provider.
Then you can use it as a "second level hint".

if (vm.TriggerExternalSignout)
{
    var url = Url.Action("Logout", new { logoutId = vm.LogoutId });
    var props = new AuthenticationProperties {RedirectUri = url};
    props.SetParameter("id_token_hint", HttpContext.GetTokenAsync("id_token"));
    return SignOut(props, vm.ExternalAuthenticationScheme);
}

I have come up with a solution, though it seems to contradict what is done in the samples.

The problem seems to be caused by two lines of code that were both from the IdentityServer samples that we used as a basis for our IDP implementations. The problem code is in the "primary" IDP.

The first line is in ConfigureServices in Startup.cs:

var authenticationBuilder = services.AddAuthentication();
authenticationBuilder.AddOpenIdConnect(openIdConfig.Scheme, "external", ConfigureOptions);

void ConfigureOptions(OpenIdConnectOptions opts)
{
    opts.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
    opts.SignOutScheme = IdentityServerConstants.SignoutScheme; // this is a problem

The second place is in ExternalController.cs, in the Callback method. Here we diverged from the samples, using IdentityServerConstants.ExternalCookieAuthenticationScheme instead of IdentityConstants.ExternalScheme :

// Read external identity from the temporary cookie
var result = await this.HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);

// ...

// delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(
    IdentityServerConstants.ExternalCookieAuthenticationScheme); // this is a problem

What happens at logout is: since the SignOutScheme is overridden, it is looking for a cookie that doesn't exist. Simply removing that doesn't fix it because the call to SignOutAsync has deleted the cookie that contains the information required for the identity code to authenticate the scheme. Since it can't authenticate the scheme, it does not include the id_token_hint in the request to the "external" IDP.

I've been able to fix this by removing the code that overrides SignOutScheme in Startup.cs, and moving the code that deletes the ExternalCookieAuthenticationScheme cookie to the Logout endpoint in AccountController.cs:

// check if we need to trigger sign-out at an upstream identity provider
if (vm.TriggerExternalSignout)
{
    // delete temporary cookie used during external authentication
    await this.HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);

    // build a return URL so the upstream provider will redirect back...

This way the "temporary" external cookie is left around until it is needed, but is deleted when the user logs out.

I'm not sure if this is the "correct" solution, but it does seem to work correctly in all cases that I've tested. I'm not really sure why we deviated from the sample in ExternalController.cs, either, but I suspect it is because we have two standalone IDP rather than a site with a single standalone IDP. Also, the sample appears to be using implicit flow while we are using hybrid flow.

I had the exact same problem as OP and was able to correct it by explicitly stating that the ID Token is to be added on to the logout request as per this Github Issue

https://github.com/IdentityServer/IdentityServer4/issues/3510

options.SaveTokens = true; // required for single sign out
options.Events = new OpenIdConnectEvents // required for single sign out
  {
    OnRedirectToIdentityProviderForSignOut = async (context) => context.ProtocolMessage.IdTokenHint = await context.HttpContext.GetTokenAsync("id_token")
  };

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM