diff --git a/src/Microsoft.AspNetCore.Authentication.WsFederation/Events/RemoteSignoutContext.cs b/src/Microsoft.AspNetCore.Authentication.WsFederation/Events/RemoteSignoutContext.cs new file mode 100644 index 000000000..8aec24a64 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.WsFederation/Events/RemoteSignoutContext.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.Protocols.WsFederation; + +namespace Microsoft.AspNetCore.Authentication.WsFederation +{ + /// + /// An event context for RemoteSignOut. + /// + public class RemoteSignOutContext : RemoteAuthenticationContext + { + /// + /// + /// + /// + /// + /// + /// + public RemoteSignOutContext(HttpContext context, AuthenticationScheme scheme, WsFederationOptions options, WsFederationMessage message) + : base(context, scheme, options, new AuthenticationProperties()) + => ProtocolMessage = message; + + /// + /// The signout message. + /// + public WsFederationMessage ProtocolMessage { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.WsFederation/Events/WsFederationEvents.cs b/src/Microsoft.AspNetCore.Authentication.WsFederation/Events/WsFederationEvents.cs index 3dd1c90e9..55c3936f9 100644 --- a/src/Microsoft.AspNetCore.Authentication.WsFederation/Events/WsFederationEvents.cs +++ b/src/Microsoft.AspNetCore.Authentication.WsFederation/Events/WsFederationEvents.cs @@ -26,6 +26,11 @@ public class WsFederationEvents : RemoteAuthenticationEvents /// public Func OnRedirectToIdentityProvider { get; set; } = context => Task.CompletedTask; + /// + /// Invoked when a wsignoutcleanup request is received at the RemoteSignOutPath endpoint. + /// + public Func OnRemoteSignOut { get; set; } = context => Task.CompletedTask; + /// /// Invoked with the security token that has been extracted from the protocol message. /// @@ -51,6 +56,11 @@ public class WsFederationEvents : RemoteAuthenticationEvents /// public virtual Task RedirectToIdentityProvider(RedirectContext context) => OnRedirectToIdentityProvider(context); + /// + /// Invoked when a wsignoutcleanup request is received at the RemoteSignOutPath endpoint. + /// + public virtual Task RemoteSignOut(RemoteSignOutContext context) => OnRemoteSignOut(context); + /// /// Invoked with the security token that has been extracted from the protocol message. /// diff --git a/src/Microsoft.AspNetCore.Authentication.WsFederation/LoggingExtensions.cs b/src/Microsoft.AspNetCore.Authentication.WsFederation/LoggingExtensions.cs index 0f2f9339d..e28b7e15b 100644 --- a/src/Microsoft.AspNetCore.Authentication.WsFederation/LoggingExtensions.cs +++ b/src/Microsoft.AspNetCore.Authentication.WsFederation/LoggingExtensions.cs @@ -11,6 +11,9 @@ internal static class LoggingExtensions private static Action _signInWithoutToken; private static Action _exceptionProcessingMessage; private static Action _malformedRedirectUri; + private static Action _remoteSignOutHandledResponse; + private static Action _remoteSignOutSkipped; + private static Action _remoteSignOut; static LoggingExtensions() { @@ -30,6 +33,18 @@ static LoggingExtensions() eventId: 4, logLevel: LogLevel.Warning, formatString: "The sign-out redirect URI '{0}' is malformed."); + _remoteSignOutHandledResponse = LoggerMessage.Define( + eventId: 5, + logLevel: LogLevel.Debug, + formatString: "RemoteSignOutContext.HandledResponse"); + _remoteSignOutSkipped = LoggerMessage.Define( + eventId: 6, + logLevel: LogLevel.Debug, + formatString: "RemoteSignOutContext.Skipped"); + _remoteSignOut = LoggerMessage.Define( + eventId: 7, + logLevel: LogLevel.Information, + formatString: "Remote signout request processed."); } public static void SignInWithoutWresult(this ILogger logger) @@ -51,5 +66,20 @@ public static void MalformedRedirectUri(this ILogger logger, string uri) { _malformedRedirectUri(logger, uri, null); } + + public static void RemoteSignOutHandledResponse(this ILogger logger) + { + _remoteSignOutHandledResponse(logger, null); + } + + public static void RemoteSignOutSkipped(this ILogger logger) + { + _remoteSignOutSkipped(logger, null); + } + + public static void RemoteSignOut(this ILogger logger) + { + _remoteSignOut(logger, null); + } } } diff --git a/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationHandler.cs b/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationHandler.cs index b9c470908..c5848849a 100644 --- a/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationHandler.cs +++ b/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationHandler.cs @@ -51,6 +51,20 @@ public WsFederationHandler(IOptionsMonitor options, ILogger /// A new instance of the events instance. protected override Task CreateEventsAsync() => Task.FromResult(new WsFederationEvents()); + /// + /// Overridden to handle remote signout requests + /// + /// + public override Task HandleRequestAsync() + { + if (Options.RemoteSignOutPath.HasValue && Options.RemoteSignOutPath == Request.Path) + { + return HandleRemoteSignOutAsync(); + } + + return base.HandleRequestAsync(); + } + /// /// Handles Challenge /// @@ -359,6 +373,49 @@ public async virtual Task SignOutAsync(AuthenticationProperties properties) } } + /// + /// Handles requests to the RemoteSignOutPath and signs out the user. + /// + /// + protected virtual async Task HandleRemoteSignOutAsync() + { + WsFederationMessage message = null; + + if (string.Equals(Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) + { + message = new WsFederationMessage(Request.Query.Select(pair => new KeyValuePair(pair.Key, pair.Value))); + } + + var remoteSignOutContext = new RemoteSignOutContext(Context, Scheme, Options, message); + await Events.RemoteSignOut(remoteSignOutContext); + + if (remoteSignOutContext.Result != null) + { + if (remoteSignOutContext.Result.Handled) + { + Logger.RemoteSignOutHandledResponse(); + return true; + } + if (remoteSignOutContext.Result.Skipped) + { + Logger.RemoteSignOutSkipped(); + return false; + } + } + + if (message == null + || !string.Equals(message.Wa, WsFederationConstants.WsFederationActions.SignOutCleanup, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + Logger.RemoteSignOut(); + + // We've received a remote sign-out request + await Context.SignOutAsync(Options.SignOutScheme); + return true; + } + /// /// Build a redirect path if the given path is a relative path. /// diff --git a/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationOptions.cs b/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationOptions.cs index 41b40e89a..97b0354e3 100644 --- a/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationOptions.cs +++ b/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationOptions.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.WsFederation; using Microsoft.IdentityModel.Tokens; @@ -32,6 +33,7 @@ public class WsFederationOptions : RemoteAuthenticationOptions public WsFederationOptions() { CallbackPath = "/signin-wsfed"; + RemoteSignOutPath = "/signout-wsfed"; Events = new WsFederationEvents(); } @@ -159,5 +161,16 @@ public TokenValidationParameters TokenValidationParameters /// However, that flow is susceptible to XSRF and other attacks so it is disabled here by default. /// public bool AllowUnsolicitedLogins { get; set; } + + /// + /// Requests received on this path will cause the handler to invoke SignOut using the SignInScheme. + /// + public PathString RemoteSignOutPath { get; set; } + + /// + /// The Authentication Scheme to use with SignOutAsync from RemoteSignOutPath. SignInScheme will be used if this + /// is not set. + /// + public string SignOutScheme { get; set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationPostConfigureOptions.cs b/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationPostConfigureOptions.cs index 0eda9e4cb..62647d4fc 100644 --- a/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationPostConfigureOptions.cs +++ b/src/Microsoft.AspNetCore.Authentication.WsFederation/WsFederationPostConfigureOptions.cs @@ -35,7 +35,12 @@ public WsFederationPostConfigureOptions(IDataProtectionProvider dataProtection) public void PostConfigure(string name, WsFederationOptions options) { options.DataProtectionProvider = options.DataProtectionProvider ?? _dp; - + + if (string.IsNullOrEmpty(options.SignOutScheme)) + { + options.SignOutScheme = options.SignInScheme; + } + if (options.StateDataFormat == null) { var dataProtector = options.DataProtectionProvider.CreateProtector( diff --git a/test/Microsoft.AspNetCore.Authentication.WsFederation.Test/WsFederationTest.cs b/test/Microsoft.AspNetCore.Authentication.WsFederation.Test/WsFederationTest.cs index bd60fdbd3..5453ec0b3 100644 --- a/test/Microsoft.AspNetCore.Authentication.WsFederation.Test/WsFederationTest.cs +++ b/test/Microsoft.AspNetCore.Authentication.WsFederation.Test/WsFederationTest.cs @@ -112,6 +112,20 @@ public async Task InvalidTokenIsRejected() Assert.Equal("AuthenticationFailed", await response.Content.ReadAsStringAsync()); } + [Fact] + public async Task RemoteSignoutRequestTriggersSignout() + { + var httpClient = CreateClient(); + + var response = await httpClient.GetAsync("/signout-wsfed?wa=wsignoutcleanup1.0"); + response.EnsureSuccessStatusCode(); + + var cookie = response.Headers.GetValues(HeaderNames.SetCookie).Single(); + Assert.Equal(".AspNetCore.Cookies=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax", cookie); + Assert.Equal("OnRemoteSignOut", response.Headers.GetValues("EventHeader").Single()); + Assert.Equal("", await response.Content.ReadAsStringAsync()); + } + [Fact] public async Task EventsResolvedFromDI() { @@ -251,6 +265,11 @@ private HttpClient CreateClient(bool allowUnsolicited = false) context.HttpContext.Request.Path = new PathString("/AuthenticationFailed"); context.SkipHandler(); return Task.FromResult(0); + }, + OnRemoteSignOut = context => + { + context.Response.Headers["EventHeader"] = "OnRemoteSignOut"; + return Task.FromResult(0); } }; });