diff --git a/samples/OpenIdConnect.AzureAdSample/Startup.cs b/samples/OpenIdConnect.AzureAdSample/Startup.cs index 05c073b46a..7e32ddf8d9 100644 --- a/samples/OpenIdConnect.AzureAdSample/Startup.cs +++ b/samples/OpenIdConnect.AzureAdSample/Startup.cs @@ -81,6 +81,7 @@ namespace OpenIdConnect.AzureAdSample ClientSecret = clientSecret, // for code flow Authority = authority, ResponseType = OpenIdConnectResponseType.CodeIdToken, + PostLogoutRedirectUri = "/usersignout", // GetClaimsFromUserInfoEndpoint = true, Events = new OpenIdConnectEvents() { diff --git a/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/LoggingExtensions.cs b/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/LoggingExtensions.cs index d0f3e12d90..7f2519f5fd 100644 --- a/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/LoggingExtensions.cs +++ b/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/LoggingExtensions.cs @@ -16,6 +16,7 @@ namespace Microsoft.Extensions.Logging private static Action _redeemingCodeForTokens; private static Action _enteringOpenIdAuthenticationHandlerHandleRemoteAuthenticateAsync; private static Action _enteringOpenIdAuthenticationHandlerHandleUnauthorizedAsync; + private static Action _enteringOpenIdAuthenticationHandlerHandleSignOutAsync; private static Action _messageReceived; private static Action _messageReceivedContextHandledResponse; private static Action _messageReceivedContextSkipped; @@ -47,6 +48,7 @@ namespace Microsoft.Extensions.Logging private static Action _invalidSecurityTokenType; private static Action _unableToValidateIdToken; private static Action _postAuthenticationLocalRedirect; + private static Action _postSignOutRedirect; private static Action _remoteSignOutHandledResponse; private static Action _remoteSignOutSkipped; private static Action _remoteSignOut; @@ -72,6 +74,10 @@ namespace Microsoft.Extensions.Logging eventId: 4, logLevel: LogLevel.Trace, formatString: "Entering {OpenIdConnectHandlerType}'s HandleUnauthorizedAsync."); + _enteringOpenIdAuthenticationHandlerHandleSignOutAsync = LoggerMessage.Define( + eventId: 14, + logLevel: LogLevel.Trace, + formatString: "Entering {OpenIdConnectHandlerType}'s HandleSignOutAsync."); _postAuthenticationLocalRedirect = LoggerMessage.Define( eventId: 5, logLevel: LogLevel.Trace, @@ -180,6 +186,10 @@ namespace Microsoft.Extensions.Logging eventId: 32, logLevel: LogLevel.Debug, formatString: "TokenResponseReceived.Skipped"); + _postSignOutRedirect = LoggerMessage.Define( + eventId: 33, + logLevel: LogLevel.Trace, + formatString: "Using properties.RedirectUri for redirect post authentication: '{RedirectUri}'."); _userInformationReceived = LoggerMessage.Define( eventId: 35, logLevel: LogLevel.Trace, @@ -430,6 +440,11 @@ namespace Microsoft.Extensions.Logging _enteringOpenIdAuthenticationHandlerHandleUnauthorizedAsync(logger, openIdConnectHandlerTypeName, null); } + public static void EnteringOpenIdAuthenticationHandlerHandleSignOutAsync(this ILogger logger, string openIdConnectHandlerTypeName) + { + _enteringOpenIdAuthenticationHandlerHandleSignOutAsync(logger, openIdConnectHandlerTypeName, null); + } + public static void UserInformationReceived(this ILogger logger, string user) { _userInformationReceived(logger, user, null); @@ -440,6 +455,11 @@ namespace Microsoft.Extensions.Logging _postAuthenticationLocalRedirect(logger, redirectUri, null); } + public static void PostSignOutRedirect(this ILogger logger, string redirectUri) + { + _postSignOutRedirect(logger, redirectUri, null); + } + public static void RemoteSignOutHandledResponse(this ILogger logger) { _remoteSignOutHandledResponse(logger, null); diff --git a/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectHandler.cs b/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectHandler.cs index df7caf3317..066803e99c 100644 --- a/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectHandler.cs +++ b/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectHandler.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Authentication; using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using Microsoft.Net.Http.Headers; @@ -68,7 +69,14 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect { return await HandleRemoteSignOutAsync(); } - return await base.HandleRequestAsync(); + else if (Options.SignedOutCallbackPath.HasValue && Options.SignedOutCallbackPath == Request.Path) + { + return await HandleSignOutCallbackAsync(); + } + else + { + return await base.HandleRequestAsync(); + } } protected virtual async Task HandleRemoteSignOutAsync() @@ -79,12 +87,14 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect { message = new OpenIdConnectMessage(Request.Query.Select(pair => new KeyValuePair(pair.Key, pair.Value))); } + // assumption: if the ContentType is "application/x-www-form-urlencoded" it should be safe to read as it is small. else if (string.Equals(Request.Method, "POST", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrEmpty(Request.ContentType) // May have media/type; charset=utf-8, allow partial match. && Request.ContentType.StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase) - && Request.Body.CanRead) { + && Request.Body.CanRead) + { var form = await Request.ReadFormAsync(); message = new OpenIdConnectMessage(form.Select(pair => new KeyValuePair(pair.Key, pair.Value))); } @@ -139,108 +149,134 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect } /// - /// Handles Signout + /// Sign out the Relying Party from the OpenID provider /// - /// - protected override async Task HandleSignOutAsync(SignOutContext signout) + /// A task executing the sign out procedure + protected override async Task HandleSignOutAsync(SignOutContext context) { - if (signout != null) + if (context == null) { - if (_configuration == null && Options.ConfigurationManager != null) + return; + } + + Logger.EnteringOpenIdAuthenticationHandlerHandleSignOutAsync(GetType().FullName); + + if (_configuration == null && Options.ConfigurationManager != null) + { + _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); + } + + var message = new OpenIdConnectMessage() + { + IssuerAddress = _configuration == null ? string.Empty : (_configuration.EndSessionEndpoint ?? string.Empty), + + // Redirect back of SigneOutCallbackPath first before user agent is redirected to actual post logout redirect uri + PostLogoutRedirectUri = BuildRedirectUriIfRelative(Options.SignedOutCallbackPath) + }; + + // Get the post redirect URI. + var properties = new AuthenticationProperties(context.Properties); + if (string.IsNullOrEmpty(properties.RedirectUri)) + { + properties.RedirectUri = BuildRedirectUriIfRelative(Options.PostLogoutRedirectUri); + if (string.IsNullOrWhiteSpace(properties.RedirectUri)) { - _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted); - } - - var message = new OpenIdConnectMessage() - { - IssuerAddress = _configuration == null ? string.Empty : (_configuration.EndSessionEndpoint ?? string.Empty), - }; - - // Set End_Session_Endpoint in order: - // 1. properties.Redirect - // 2. Options.PostLogoutRedirectUri - var properties = new AuthenticationProperties(signout.Properties); - var logoutRedirectUri = properties.RedirectUri; - if (!string.IsNullOrEmpty(logoutRedirectUri)) - { - // Relative to PathBase - if (logoutRedirectUri.StartsWith("/", StringComparison.Ordinal)) - { - logoutRedirectUri = BuildRedirectUri(logoutRedirectUri); - } - message.PostLogoutRedirectUri = logoutRedirectUri; - } - else if (!string.IsNullOrEmpty(Options.PostLogoutRedirectUri)) - { - logoutRedirectUri = Options.PostLogoutRedirectUri; - // Relative to PathBase - if (logoutRedirectUri.StartsWith("/", StringComparison.Ordinal)) - { - logoutRedirectUri = BuildRedirectUri(logoutRedirectUri); - } - message.PostLogoutRedirectUri = logoutRedirectUri; - } - - // Attach the identity token to the logout request when possible. - message.IdTokenHint = await Context.Authentication.GetTokenAsync(Options.SignOutScheme, OpenIdConnectParameterNames.IdToken); - - var redirectContext = new RedirectContext(Context, Options, properties) - { - ProtocolMessage = message - }; - - await Options.Events.RedirectToIdentityProviderForSignOut(redirectContext); - if (redirectContext.HandledResponse) - { - Logger.RedirectToIdentityProviderForSignOutHandledResponse(); - return; - } - else if (redirectContext.Skipped) - { - Logger.RedirectToIdentityProviderForSignOutSkipped(); - return; - } - - message = redirectContext.ProtocolMessage; - - if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.RedirectGet) - { - var redirectUri = message.CreateLogoutRequestUrl(); - if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute)) - { - Logger.InvalidLogoutQueryStringRedirectUrl(redirectUri); - } - - Response.Redirect(redirectUri); - } - else if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.FormPost) - { - var inputs = new StringBuilder(); - foreach (var parameter in message.Parameters) - { - var name = HtmlEncoder.Encode(parameter.Key); - var value = HtmlEncoder.Encode(parameter.Value); - - var input = string.Format(CultureInfo.InvariantCulture, InputTagFormat, name, value); - inputs.AppendLine(input); - } - - var issuer = HtmlEncoder.Encode(message.IssuerAddress); - - var content = string.Format(CultureInfo.InvariantCulture, HtmlFormFormat, issuer, inputs); - var buffer = Encoding.UTF8.GetBytes(content); - - Response.ContentLength = buffer.Length; - Response.ContentType = "text/html;charset=UTF-8"; - - // Emit Cache-Control=no-cache to prevent client caching. - Response.Headers[HeaderNames.CacheControl] = "no-cache"; - Response.Headers[HeaderNames.Pragma] = "no-cache"; - Response.Headers[HeaderNames.Expires] = "-1"; - - await Response.Body.WriteAsync(buffer, 0, buffer.Length); + properties.RedirectUri = CurrentUri; } } + Logger.PostSignOutRedirect(properties.RedirectUri); + + // Attach the identity token to the logout request when possible. + message.IdTokenHint = await Context.Authentication.GetTokenAsync(Options.SignOutScheme, OpenIdConnectParameterNames.IdToken); + + var redirectContext = new RedirectContext(Context, Options, properties) + { + ProtocolMessage = message + }; + + await Options.Events.RedirectToIdentityProviderForSignOut(redirectContext); + if (redirectContext.HandledResponse) + { + Logger.RedirectToIdentityProviderForSignOutHandledResponse(); + return; + } + else if (redirectContext.Skipped) + { + Logger.RedirectToIdentityProviderForSignOutSkipped(); + return; + } + + message = redirectContext.ProtocolMessage; + + if (!string.IsNullOrEmpty(message.State)) + { + properties.Items[OpenIdConnectDefaults.UserstatePropertiesKey] = message.State; + } + + message.State = Options.StateDataFormat.Protect(properties); + + if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.RedirectGet) + { + var redirectUri = message.CreateLogoutRequestUrl(); + if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute)) + { + Logger.InvalidLogoutQueryStringRedirectUrl(redirectUri); + } + + Response.Redirect(redirectUri); + } + else if (Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.FormPost) + { + var inputs = new StringBuilder(); + foreach (var parameter in message.Parameters) + { + var name = HtmlEncoder.Encode(parameter.Key); + var value = HtmlEncoder.Encode(parameter.Value); + + var input = string.Format(CultureInfo.InvariantCulture, InputTagFormat, name, value); + inputs.AppendLine(input); + } + + var issuer = HtmlEncoder.Encode(message.IssuerAddress); + + var content = string.Format(CultureInfo.InvariantCulture, HtmlFormFormat, issuer, inputs); + var buffer = Encoding.UTF8.GetBytes(content); + + Response.ContentLength = buffer.Length; + Response.ContentType = "text/html;charset=UTF-8"; + + // Emit Cache-Control=no-cache to prevent client caching. + Response.Headers[HeaderNames.CacheControl] = "no-cache"; + Response.Headers[HeaderNames.Pragma] = "no-cache"; + Response.Headers[HeaderNames.Expires] = "-1"; + + await Response.Body.WriteAsync(buffer, 0, buffer.Length); + } + else + { + throw new NotImplementedException($"An unsupported authentication method has been configured: {Options.AuthenticationMethod}"); + } + } + + /// + /// Response to the callback from OpenId provider after session ended. + /// + /// A task executing the callback procedure + protected virtual Task HandleSignOutCallbackAsync() + { + StringValues protectedState; + if (Request.Query.TryGetValue("State", out protectedState)) + { + var properties = Options.StateDataFormat.Unprotect(protectedState); + if (!string.IsNullOrEmpty(properties.RedirectUri)) + { + Response.Redirect(properties.RedirectUri); + return Task.FromResult(true); + } + } + + Response.Redirect("/"); + return Task.FromResult(true); } /// @@ -1111,5 +1147,23 @@ namespace Microsoft.AspNetCore.Authentication.OpenIdConnect return ticket; } + + /// + /// Build a redirect path if the given path is a relative path. + /// + private string BuildRedirectUriIfRelative(string uri) + { + if (string.IsNullOrEmpty(uri)) + { + return uri; + } + + if (!uri.StartsWith("/", StringComparison.Ordinal)) + { + return uri; + } + + return BuildRedirectUri(uri); + } } } diff --git a/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs b/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs index 181444b055..c3b18b2951 100644 --- a/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs +++ b/src/Microsoft.AspNetCore.Authentication.OpenIdConnect/OpenIdConnectOptions.cs @@ -50,7 +50,9 @@ namespace Microsoft.AspNetCore.Builder AutomaticChallenge = true; DisplayName = OpenIdConnectDefaults.Caption; CallbackPath = new PathString("/signin-oidc"); + SignedOutCallbackPath = new PathString("/signout-callback-oidc"); RemoteSignOutPath = new PathString("/signout-oidc"); + Events = new OpenIdConnectEvents(); Scope.Add("openid"); Scope.Add("profile"); @@ -120,9 +122,15 @@ namespace Microsoft.AspNetCore.Builder }; /// - /// Gets or sets the 'post_logout_redirect_uri' + /// The request path within the application's base path where the user agent will be returned after sign out from the identity provider. /// - /// This is sent to the OP as the redirect for the user-agent. + public PathString SignedOutCallbackPath { get; set; } + + /// + /// The uri where the user agent will be returned to after application is signed out from the identity provider. + /// The redirect will happen after the is invoked. + /// + /// This URI is optional and it can be out of the application's domain. public string PostLogoutRedirectUri { get; set; } /// diff --git a/src/Microsoft.AspNetCore.Authentication/RemoteAuthenticationHandler.cs b/src/Microsoft.AspNetCore.Authentication/RemoteAuthenticationHandler.cs index 4dfcc3662c..e34ee5fb55 100644 --- a/src/Microsoft.AspNetCore.Authentication/RemoteAuthenticationHandler.cs +++ b/src/Microsoft.AspNetCore.Authentication/RemoteAuthenticationHandler.cs @@ -26,6 +26,7 @@ namespace Microsoft.AspNetCore.Authentication { return await HandleRemoteCallbackAsync(); } + return false; }