diff --git a/samples/AspNetCoreReactSample/Controllers/AccountController.cs b/samples/AspNetCoreReactSample/Controllers/AccountController.cs index be5fb720..564d2229 100644 --- a/samples/AspNetCoreReactSample/Controllers/AccountController.cs +++ b/samples/AspNetCoreReactSample/Controllers/AccountController.cs @@ -48,9 +48,20 @@ public IActionResult Login(string? scheme) [AllowAnonymous] [HttpGet("/account/logout")] - public IActionResult Logout() + public async Task Logout(string? redirectUrl) { - return SignOut(); + if (string.IsNullOrWhiteSpace(redirectUrl)) + { + redirectUrl = "/"; + } + + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + await HttpContext.SignOutAsync(properties); + var authScheme = User.Claims.FirstOrDefault(x => string.Equals(x.Type, "auth_scheme"))?.Value; + if (!string.IsNullOrWhiteSpace(authScheme)) + { + await HttpContext.SignOutAsync(authScheme, properties); + } } } diff --git a/samples/AspNetCoreReactSample/Program.cs b/samples/AspNetCoreReactSample/Program.cs index 835475a4..90579f97 100644 --- a/samples/AspNetCoreReactSample/Program.cs +++ b/samples/AspNetCoreReactSample/Program.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using GSS.Authentication.CAS; using GSS.Authentication.CAS.AspNetCore; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect; @@ -16,45 +15,7 @@ options.FallbackPolicy = options.DefaultPolicy; }); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.Events.OnSigningOut = async context => - { - var authService = context.HttpContext.RequestServices.GetRequiredService(); - var result = await authService.AuthenticateAsync(context.HttpContext, null); - string? authScheme = null; - if (result.Properties != null || result.Properties!.Items.TryGetValue(".AuthScheme", out authScheme) && - string.IsNullOrWhiteSpace(authScheme)) - { - if (string.Equals(authScheme, CasDefaults.AuthenticationType)) - { - options.CookieManager.DeleteCookie(context.HttpContext, options.Cookie.Name!, - context.CookieOptions); - // redirecting to the identity provider to sign out - await context.HttpContext.SignOutAsync(authScheme); - return; - } - - if (string.Equals(authScheme, OpenIdConnectDefaults.AuthenticationScheme) && - builder.Configuration.GetValue("OIDC:SaveTokens", false)) - { - options.CookieManager.DeleteCookie(context.HttpContext, options.Cookie.Name!, - context.CookieOptions); - // redirecting to the identity provider to sign out - await context.HttpContext.SignOutAsync(authScheme); - return; - } - } - - await context.Options.Events.RedirectToLogout(new RedirectContext( - context.HttpContext, - context.Scheme, - context.Options, - context.Properties, - "/" - )); - }; - }) + .AddCookie() .AddCAS(options => { options.CasServerUrlBase = builder.Configuration["CAS:ServerUrlBase"]!; @@ -65,6 +26,7 @@ await context.Options.Events.RedirectToLogout(new RedirectContext options.Scope.Add(s)); } - + options.Events.OnTokenValidated = context => + { + if (context.Principal?.Identity is ClaimsIdentity claimIdentity) + { + claimIdentity.AddClaim(new Claim("auth_scheme", OpenIdConnectDefaults.AuthenticationScheme)); + } + return Task.CompletedTask; + }; options.TokenValidationParameters.NameClaimType = builder.Configuration.GetValue("OIDC:NameClaimType", "name"); }); diff --git a/samples/AspNetCoreSample/Pages/Account/Logout.cshtml.cs b/samples/AspNetCoreSample/Pages/Account/Logout.cshtml.cs index 88ac8ad2..6f78d20a 100644 --- a/samples/AspNetCoreSample/Pages/Account/Logout.cshtml.cs +++ b/samples/AspNetCoreSample/Pages/Account/Logout.cshtml.cs @@ -1,12 +1,23 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc.RazorPages; namespace AspNetCoreSample.Pages.Account; public class LogoutModel : PageModel { - public IActionResult OnGet() + public async Task OnGet(string? redirectUrl) { - return SignOut(); + if (string.IsNullOrWhiteSpace(redirectUrl)) + { + redirectUrl = "/"; + } + + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + await HttpContext.SignOutAsync(properties); + var authScheme = User.Claims.FirstOrDefault(x => string.Equals(x.Type, "auth_scheme"))?.Value; + if (!string.IsNullOrWhiteSpace(authScheme)) + { + await HttpContext.SignOutAsync(authScheme, properties); + } } } \ No newline at end of file diff --git a/samples/AspNetCoreSample/Program.cs b/samples/AspNetCoreSample/Program.cs index c6258907..12ac9f3f 100644 --- a/samples/AspNetCoreSample/Program.cs +++ b/samples/AspNetCoreSample/Program.cs @@ -2,7 +2,6 @@ using System.Security.Cryptography.X509Certificates; using GSS.Authentication.CAS; using GSS.Authentication.CAS.AspNetCore; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect; @@ -34,53 +33,7 @@ options.FallbackPolicy = options.DefaultPolicy; }); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.Events.OnSigningOut = async context => - { - var authService = context.HttpContext.RequestServices.GetRequiredService(); - var result = await authService.AuthenticateAsync(context.HttpContext, null); - string? authScheme = null; - if (result.Properties != null || result.Properties!.Items.TryGetValue(".AuthScheme", out authScheme) && - string.IsNullOrWhiteSpace(authScheme)) - { - if (string.Equals(authScheme, CasDefaults.AuthenticationType)) - { - options.CookieManager.DeleteCookie(context.HttpContext, options.Cookie.Name!, - context.CookieOptions); - // redirecting to the identity provider to sign out - await context.HttpContext.SignOutAsync(authScheme); - return; - } - - if (string.Equals(authScheme, OpenIdConnectDefaults.AuthenticationScheme) && - builder.Configuration.GetValue("OIDC:SaveTokens", false)) - { - options.CookieManager.DeleteCookie(context.HttpContext, options.Cookie.Name!, - context.CookieOptions); - // redirecting to the identity provider to sign out - await context.HttpContext.SignOutAsync(authScheme); - return; - } - } - - var saml2SessionIndex = context.HttpContext.User.FindFirst(Saml2ClaimTypes.SessionIndex); - if (saml2SessionIndex != null) - { - // redirecting to the identity provider to sign out - await context.HttpContext.SignOutAsync(Saml2Defaults.Scheme); - return; - } - - await context.Options.Events.RedirectToLogout(new RedirectContext( - context.HttpContext, - context.Scheme, - context.Options, - context.Properties, - "/" - )); - }; - }) + .AddCookie() .AddCAS(options => { options.CasServerUrlBase = builder.Configuration["CAS:ServerUrlBase"]!; @@ -92,6 +45,7 @@ await context.Options.Events.RedirectToLogout(new RedirectContext options.Scope.Add(s)); } - options.TokenValidationParameters.NameClaimType = builder.Configuration.GetValue("OIDC:NameClaimType", "name"); + options.Events.OnTokenValidated = context => + { + if (context.Principal?.Identity is ClaimsIdentity claimIdentity) + { + claimIdentity.AddClaim(new Claim("auth_scheme", OpenIdConnectDefaults.AuthenticationScheme)); + } + return Task.CompletedTask; + }; options.Events.OnRemoteFailure = context => { var failure = context.Failure; @@ -182,6 +144,13 @@ await context.Options.Events.RedirectToLogout(new RedirectContext + { + if (result.Principal?.Identity is ClaimsIdentity claimIdentity) + { + claimIdentity.AddClaim(new Claim("auth_scheme", Saml2Defaults.Scheme)); + } + }; options.Notifications.MetadataCreated = (metadata, _) => { var ssoDescriptor = metadata.RoleDescriptors.OfType().First(); diff --git a/samples/BlazorSample/Pages/Account/Logout.cshtml.cs b/samples/BlazorSample/Pages/Account/Logout.cshtml.cs index 4c2081a0..f29ecdbf 100644 --- a/samples/BlazorSample/Pages/Account/Logout.cshtml.cs +++ b/samples/BlazorSample/Pages/Account/Logout.cshtml.cs @@ -1,12 +1,23 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc.RazorPages; namespace BlazorSample.Pages.Account; public class Logout : PageModel { - public IActionResult OnGet() + public async Task OnGet(string? redirectUrl) { - return SignOut(); + if (string.IsNullOrWhiteSpace(redirectUrl)) + { + redirectUrl = "/"; + } + + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + await HttpContext.SignOutAsync(properties); + var authScheme = User.Claims.FirstOrDefault(x => string.Equals(x.Type, "auth_scheme"))?.Value; + if (!string.IsNullOrWhiteSpace(authScheme)) + { + await HttpContext.SignOutAsync(authScheme, properties); + } } } \ No newline at end of file diff --git a/samples/BlazorSample/Program.cs b/samples/BlazorSample/Program.cs index 3ef0eb34..cea8a449 100644 --- a/samples/BlazorSample/Program.cs +++ b/samples/BlazorSample/Program.cs @@ -2,7 +2,6 @@ using BlazorSample.Components; using GSS.Authentication.CAS; using GSS.Authentication.CAS.AspNetCore; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect; @@ -19,45 +18,7 @@ options.FallbackPolicy = options.DefaultPolicy; }); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.Events.OnSigningOut = async context => - { - var authService = context.HttpContext.RequestServices.GetRequiredService(); - var result = await authService.AuthenticateAsync(context.HttpContext, null); - string? authScheme = null; - if (result.Properties != null || result.Properties!.Items.TryGetValue(".AuthScheme", out authScheme) && - string.IsNullOrWhiteSpace(authScheme)) - { - if (string.Equals(authScheme, CasDefaults.AuthenticationType)) - { - options.CookieManager.DeleteCookie(context.HttpContext, options.Cookie.Name!, - context.CookieOptions); - // redirecting to the identity provider to sign out - await context.HttpContext.SignOutAsync(authScheme); - return; - } - - if (string.Equals(authScheme, OpenIdConnectDefaults.AuthenticationScheme) && - builder.Configuration.GetValue("OIDC:SaveTokens", false)) - { - options.CookieManager.DeleteCookie(context.HttpContext, options.Cookie.Name!, - context.CookieOptions); - // redirecting to the identity provider to sign out - await context.HttpContext.SignOutAsync(authScheme); - return; - } - } - - await context.Options.Events.RedirectToLogout(new RedirectContext( - context.HttpContext, - context.Scheme, - context.Options, - context.Properties, - "/" - )); - }; - }) + .AddCookie() .AddCAS(options => { options.CasServerUrlBase = builder.Configuration["CAS:ServerUrlBase"]!; @@ -68,6 +29,7 @@ await context.Options.Events.RedirectToLogout(new RedirectContext options.Scope.Add(s)); } - + options.Events.OnTokenValidated = context => + { + if (context.Principal?.Identity is ClaimsIdentity claimIdentity) + { + claimIdentity.AddClaim(new Claim("auth_scheme", OpenIdConnectDefaults.AuthenticationScheme)); + } + return Task.CompletedTask; + }; options.TokenValidationParameters.NameClaimType = builder.Configuration.GetValue("OIDC:NameClaimType", "name"); }); diff --git a/samples/OwinSample/Controllers/AccountController.cs b/samples/OwinSample/Controllers/AccountController.cs index ddd169a9..85a7af1d 100644 --- a/samples/OwinSample/Controllers/AccountController.cs +++ b/samples/OwinSample/Controllers/AccountController.cs @@ -1,6 +1,7 @@ using System.Web; using System.Web.Mvc; using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Cookies; namespace OwinSample.Controllers { @@ -22,9 +23,20 @@ public ActionResult Login(string scheme) // GET /Account/Logout [HttpGet] - public void Logout() + public void Logout(string redirectUrl) { - Request.GetOwinContext().Authentication.SignOut(); + if (string.IsNullOrWhiteSpace(redirectUrl)) + { + redirectUrl = "/"; + } + var owinContext = Request.GetOwinContext(); + var properties = new AuthenticationProperties { RedirectUri = redirectUrl }; + owinContext.Authentication.SignOut(properties, CookieAuthenticationDefaults.AuthenticationType); + var authScheme = owinContext.Authentication.User.FindFirst("auth_scheme")?.Value; + if (!string.IsNullOrWhiteSpace(authScheme)) + { + owinContext.Authentication.SignOut(properties, authScheme); + } } } } \ No newline at end of file diff --git a/samples/OwinSample/Startup.cs b/samples/OwinSample/Startup.cs index e75019cb..4c98df0a 100644 --- a/samples/OwinSample/Startup.cs +++ b/samples/OwinSample/Startup.cs @@ -2,10 +2,10 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using System.Web; using System.Web.Helpers; using System.Web.Mvc; using System.Web.Routing; +using GSS.Authentication.CAS; using GSS.Authentication.CAS.Owin; using GSS.Authentication.CAS.Security; using GSS.Authentication.CAS.Validation; @@ -86,33 +86,7 @@ public void Configuration(IAppBuilder app) LogoutPath = CookieAuthenticationDefaults.LogoutPath, // https://github.com/aspnet/AspNetKatana/wiki/System.Web-response-cookie-integration-issues CookieManager = new SystemWebCookieManager(), - SessionStore = singleLogout ? resolver.GetRequiredService() : null, - Provider = new CookieAuthenticationProvider - { - OnResponseSignOut = context => - { - var redirectContext = new CookieApplyRedirectContext - ( - context.OwinContext, - context.Options, - "/" - ); - if (configuration.GetValue("CAS:SingleSignOut", false)) - { - context.Options.CookieManager.DeleteCookie(context.OwinContext, context.Options.CookieName, - context.CookieOptions); - // Single Sign-Out - var urlHelper = new UrlHelper(HttpContext.Current.Request.RequestContext); - var serviceUrl = urlHelper.Action("Index", "Home", null, context.Request.Scheme); - var redirectUri = new UriBuilder(configuration["CAS:ServerUrlBase"]!); - redirectUri.Path += "/logout"; - redirectUri.Query = $"service={Uri.EscapeDataString(serviceUrl)}"; - redirectContext.RedirectUri = redirectUri.Uri.AbsoluteUri; - } - - context.Options.Provider.ApplyRedirect(redirectContext); - } - } + SessionStore = singleLogout ? resolver.GetRequiredService() : null }); app.UseCasAuthentication(options => @@ -143,6 +117,7 @@ public void Configuration(IAppBuilder app) if (assertion == null) return Task.CompletedTask; // Map claims from assertion + context.Identity.AddClaim(new Claim("auth_scheme", CasDefaults.AuthenticationType)); context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, assertion.PrincipalName)); if (assertion.Attributes.TryGetValue("display_name", out var displayName) && !string.IsNullOrWhiteSpace(displayName)) @@ -174,6 +149,7 @@ public void Configuration(IAppBuilder app) ClientSecret = configuration["OIDC:ClientSecret"], Authority = configuration["OIDC:Authority"], RequireHttpsMetadata = !env.Equals("Development", StringComparison.OrdinalIgnoreCase), + // required for single logout SaveTokens = configuration.GetValue("OIDC:SaveTokens", false), ResponseType = OpenIdConnectResponseType.Code, // https://github.com/aspnet/AspNetKatana/issues/348 @@ -184,31 +160,46 @@ public void Configuration(IAppBuilder app) CookieManager = new SystemWebCookieManager(), Notifications = new OpenIdConnectAuthenticationNotifications { - RedirectToIdentityProvider = notification => + SecurityTokenValidated = notification => { - // generate the redirect_uri parameter automatically - if (string.IsNullOrWhiteSpace(notification.Options.RedirectUri)) - { - notification.ProtocolMessage.RedirectUri = - notification.Request.Scheme + Uri.SchemeDelimiter + - notification.Request.Host + notification.Request.PathBase + - notification.Options.CallbackPath; - } - + notification.AuthenticationTicket.Identity.AddClaim(new Claim("auth_scheme", + OpenIdConnectAuthenticationDefaults.AuthenticationType)); return Task.CompletedTask; }, - AuthorizationCodeReceived = notification => + RedirectToIdentityProvider = async notification => { - // generate the redirect_uri parameter automatically - if (string.IsNullOrWhiteSpace(notification.Options.RedirectUri)) + // fix redirect_uri + if (notification.ProtocolMessage.RequestType == OpenIdConnectRequestType.Authentication && + string.IsNullOrWhiteSpace(notification.Options.RedirectUri)) { - notification.TokenEndpointRequest.RedirectUri = + notification.ProtocolMessage.RedirectUri = notification.Request.Scheme + Uri.SchemeDelimiter + notification.Request.Host + notification.Request.PathBase + notification.Options.CallbackPath; } - return Task.CompletedTask; + if (notification.ProtocolMessage.RequestType == OpenIdConnectRequestType.Logout) + { + // fix post_logout_redirect_uri + if (!Uri.IsWellFormedUriString(notification.ProtocolMessage.PostLogoutRedirectUri, + UriKind.Absolute)) + { + notification.ProtocolMessage.PostLogoutRedirectUri = notification.Request.Scheme + + Uri.SchemeDelimiter + + notification.Request.Host + notification.Request.PathBase + + notification.ProtocolMessage.PostLogoutRedirectUri; + } + + // fix id_token_hint + if (string.IsNullOrWhiteSpace(notification.ProtocolMessage.IdTokenHint)) + { + var result = + await notification.OwinContext.Authentication.AuthenticateAsync( + CookieAuthenticationDefaults.AuthenticationType); + var idToken = result.Properties.Dictionary[OpenIdConnectParameterNames.IdToken]; + notification.ProtocolMessage.IdTokenHint = idToken; + } + } } } }); @@ -218,14 +209,25 @@ public void Configuration(IAppBuilder app) private static Saml2AuthenticationOptions CreateSaml2Options(IConfiguration configuration) { - var spOptions = new SPOptions { EntityId = new EntityId(configuration["SAML2:SP:EntityId"]), AuthenticateRequestSigningBehavior = SigningBehavior.Never }; - spOptions.TokenValidationParametersTemplate.NameClaimType = ClaimTypes.NameIdentifier; + var spOptions = new SPOptions + { + EntityId = new EntityId(configuration["SAML2:SP:EntityId"]), + AuthenticateRequestSigningBehavior = SigningBehavior.Never, + TokenValidationParametersTemplate = { NameClaimType = ClaimTypes.NameIdentifier } + }; var options = new Saml2AuthenticationOptions(false) { SPOptions = spOptions }; var idp = new IdentityProvider(new EntityId(configuration["SAML2:IdP:EntityId"]), spOptions) { MetadataLocation = configuration["SAML2:IdP:MetadataLocation"] }; options.IdentityProviders.Add(idp); + options.Notifications.AcsCommandResultCreated = (result, _) => + { + if (result.Principal.Identity is ClaimsIdentity identity) + { + identity.AddClaim(new Claim("auth_scheme", "Saml2")); + } + }; options.Notifications.MetadataCreated = (metadata, _) => { var ssoDescriptor = metadata.RoleDescriptors.OfType().First(); diff --git a/samples/OwinSample/web.config b/samples/OwinSample/web.config index 6775d96c..941474a7 100644 --- a/samples/OwinSample/web.config +++ b/samples/OwinSample/web.config @@ -35,14 +35,14 @@ - - + + - - + + @@ -59,20 +59,20 @@ - - + + - - + + - - + + @@ -89,8 +89,8 @@ - - + + @@ -131,8 +131,8 @@ - - + + @@ -147,6 +147,18 @@ + + + + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b3a0848e..b159db76 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -11,7 +11,7 @@ snupkg true enable - 5.4.0 + 5.5.0 diff --git a/src/GSS.Authentication.CAS.Owin/CasAuthenticationHandler.cs b/src/GSS.Authentication.CAS.Owin/CasAuthenticationHandler.cs index 42dbaeaa..c1c954a6 100644 --- a/src/GSS.Authentication.CAS.Owin/CasAuthenticationHandler.cs +++ b/src/GSS.Authentication.CAS.Owin/CasAuthenticationHandler.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Owin.Infrastructure; using Microsoft.Owin.Logging; using Microsoft.Owin.Security; @@ -25,6 +26,11 @@ public CasAuthenticationHandler(ILogger logger) /// True if the request was handled, false if the next middleware should be invoked. public override async Task InvokeAsync() { + if (Options.SignedOutCallbackPath.HasValue && Options.SignedOutCallbackPath == Request.Path) + { + return await HandleSignOutCallbackAsync(); + } + if (Options.CallbackPath.HasValue && Options.CallbackPath == Request.Path) { return await InvokeReturnPathAsync().ConfigureAwait(false); @@ -33,6 +39,17 @@ public override async Task InvokeAsync() return false; } + private Task HandleSignOutCallbackAsync() + { + var query = Request.Query; + var state = query[State]; + var properties = Options.StateDataFormat.Unprotect(state); + Response.Redirect(!string.IsNullOrEmpty(properties?.RedirectUri) + ? properties!.RedirectUri + : Options.SignedOutRedirectUri); + return Task.FromResult(true); + } + private async Task InvokeReturnPathAsync() { AuthenticationTicket? ticket = null; @@ -41,7 +58,7 @@ private async Task InvokeReturnPathAsync() try { ticket = await AuthenticateAsync().ConfigureAwait(false); - if (ticket?.Identity == null || !ticket.Identity.IsAuthenticated) + if (ticket?.Identity is not { IsAuthenticated: true }) { exception = new InvalidOperationException("Invalid return state, unable to redirect."); properties = ticket?.Properties; @@ -89,7 +106,7 @@ private async Task InvokeReturnPathAsync() await Options.Provider.RedirectToAuthorizationEndpoint(context).ConfigureAwait(false); - if (context.SignInAsAuthenticationType != null && context.Identity != null) + if (context is { SignInAsAuthenticationType: not null, Identity: not null }) { var signInIdentity = context.Identity; if (!string.Equals(signInIdentity.AuthenticationType, context.SignInAsAuthenticationType, @@ -102,7 +119,7 @@ private async Task InvokeReturnPathAsync() Context.Authentication.SignIn(context.Properties, signInIdentity); } - if (!context.IsRequestCompleted && context.RedirectUri != null) + if (context is { IsRequestCompleted: false, RedirectUri: not null }) { if (context.Identity == null) { @@ -150,7 +167,7 @@ protected override async Task AuthenticateCoreAsync() throw new InvalidOperationException("Missing ticket parameter from query"); } - var service = BuildReturnTo(state); + var service = QueryHelpers.AddQueryString(BuildRedirectUri(properties.RedirectUri ?? "/"), State, state); var principal = await Options.ServiceTicketValidator.ValidateAsync(ticket, service, Request.CallCancelled) .ConfigureAwait(false); @@ -176,6 +193,43 @@ protected override async Task AuthenticateCoreAsync() return new AuthenticationTicket(context.Identity, context.Properties); } + /// + /// Handles SignOut + /// + /// + protected override async Task ApplyResponseGrantAsync() + { + var signOut = Helper.LookupSignOut(Options.AuthenticationType, Options.AuthenticationMode); + if (signOut != null) + { + AuthenticationTicket? ticket = null; + var redirectContext = new CasRedirectContext(Context, ticket) + { + RedirectUri = signOut.Properties.RedirectUri + }; + await Options.Provider.RedirectToIdentityProviderForSignOut(redirectContext).ConfigureAwait(false); + if (redirectContext.Handled) + { + return; + } + + var properties = new AuthenticationProperties { RedirectUri = Options.SignedOutRedirectUri }; + if (!string.IsNullOrWhiteSpace(signOut.Properties.RedirectUri)) + { + properties.RedirectUri = signOut.Properties.RedirectUri; + } + + var returnTo = QueryHelpers.AddQueryString( + BuildRedirectUriIfRelative(Options.SignedOutCallbackPath.Value), State, + Options.StateDataFormat.Protect(properties)); + var logoutUrl = new UriBuilder(Options.CasServerUrlBase); + logoutUrl.Path += Constants.Paths.Logout; + var redirectUri = + QueryHelpers.AddQueryString(logoutUrl.Uri.AbsoluteUri, Constants.Parameters.Service, returnTo); + Response.Redirect(redirectUri); + } + } + /// /// Handles SignIn /// @@ -202,7 +256,8 @@ protected override Task ApplyResponseChallengeAsync() // Anti-CSRF GenerateCorrelationId(Options.CookieManager, state); - var returnTo = BuildReturnTo(Options.StateDataFormat.Protect(state)); + var returnTo = QueryHelpers.AddQueryString(BuildRedirectUri(Options.CallbackPath.Value), State, + Options.StateDataFormat.Protect(state)); var authorizationEndpoint = $"{Options.CasServerUrlBase}/login?service={Uri.EscapeDataString(returnTo)}"; @@ -213,15 +268,27 @@ protected override Task ApplyResponseChallengeAsync() return Task.CompletedTask; } - private string BuildReturnTo(string? state) + private string BuildRedirectUri(string path) { var baseUrl = Options.ServiceUrlBase?.IsAbsoluteUri == true ? Options.ServiceUrlBase.AbsoluteUri.TrimEnd('/') : $"{Request.Scheme}://{Request.Host}{RequestPathBase}"; - return - state == null || string.IsNullOrWhiteSpace(state) - ? $"{baseUrl}{Options.CallbackPath}" - : $"{baseUrl}{Options.CallbackPath}?state={Uri.EscapeDataString(state)}"; + return $"{baseUrl}{path}"; + } + + /// + /// Build a redirect path if the given path is a relative path. + /// + private string BuildRedirectUriIfRelative(string uriString) + { + if (string.IsNullOrWhiteSpace(uriString)) + { + return uriString; + } + + return Uri.TryCreate(uriString, UriKind.Absolute, out _) + ? uriString + : BuildRedirectUri(uriString); } } } \ No newline at end of file diff --git a/src/GSS.Authentication.CAS.Owin/CasAuthenticationOptions.cs b/src/GSS.Authentication.CAS.Owin/CasAuthenticationOptions.cs index 92730e37..f05e80d6 100644 --- a/src/GSS.Authentication.CAS.Owin/CasAuthenticationOptions.cs +++ b/src/GSS.Authentication.CAS.Owin/CasAuthenticationOptions.cs @@ -18,6 +18,7 @@ public CasAuthenticationOptions() : base(CasDefaults.AuthenticationType) { Caption = CasDefaults.AuthenticationType; CallbackPath = new PathString("/signin-cas"); + SignedOutCallbackPath = new PathString("/signout-callback-cas"); AuthenticationMode = AuthenticationMode.Passive; BackchannelTimeout = TimeSpan.FromSeconds(60); CookieManager = new CookieManager(); @@ -81,6 +82,19 @@ public string Caption /// Default will automatic generated by OWIN request /// public Uri? ServiceUrlBase { get; set; } + + /// + /// The request path within the application's base path where the user agent will be returned after sign out from the CAS server. + /// See service from https://apereo.github.io/cas/6.6.x/protocol/CAS-Protocol-Specification.html#231-parameters + /// + public PathString SignedOutCallbackPath { get; set; } + + /// + /// The uri where the user agent will be redirected to after application is signed out from the identity provider. + /// The redirect will happen after the SignedOutCallbackPath is invoked. + /// + /// This URI can be out of the application's domain. By default it points to the root. + public string SignedOutRedirectUri { get; set; } = "/"; /// /// Gets or sets the used to validate service ticket. diff --git a/src/GSS.Authentication.CAS.Owin/Events/CasAuthenticationProvider.cs b/src/GSS.Authentication.CAS.Owin/Events/CasAuthenticationProvider.cs index dce9145b..6d7e50c8 100644 --- a/src/GSS.Authentication.CAS.Owin/Events/CasAuthenticationProvider.cs +++ b/src/GSS.Authentication.CAS.Owin/Events/CasAuthenticationProvider.cs @@ -23,12 +23,22 @@ public class CasAuthenticationProvider : ICasAuthenticationProvider context.Response.Redirect(context.RedirectUri); return Task.CompletedTask; }; + + /// + /// Invoked before redirecting to the identity provider to sign out. + /// + public Func OnRedirectToIdentityProviderForSignOut { get; set; } = _ => Task.CompletedTask; public Func OnRemoteFailure { get; set; } = _ => Task.CompletedTask; public virtual Task CreatingTicket(CasCreatingTicketContext context) => OnCreatingTicket(context); public virtual Task RedirectToAuthorizationEndpoint(CasRedirectToAuthorizationEndpointContext context) => OnRedirectToAuthorizationEndpoint(context); + + /// + /// Invoked before redirecting to the identity provider to sign out. + /// + public virtual Task RedirectToIdentityProviderForSignOut(CasRedirectContext context) => OnRedirectToIdentityProviderForSignOut(context); public Task RemoteFailure(CasRemoteFailureContext context) => OnRemoteFailure(context); } diff --git a/src/GSS.Authentication.CAS.Owin/Events/CasRedirectContext.cs b/src/GSS.Authentication.CAS.Owin/Events/CasRedirectContext.cs new file mode 100644 index 00000000..f5432e8b --- /dev/null +++ b/src/GSS.Authentication.CAS.Owin/Events/CasRedirectContext.cs @@ -0,0 +1,23 @@ +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.Provider; + +// ReSharper disable once CheckNamespace +namespace GSS.Authentication.CAS.Owin; + +public class CasRedirectContext : ReturnEndpointContext +{ + public CasRedirectContext(IOwinContext context, AuthenticationTicket? ticket) : base(context, ticket) + { + } + + /// + /// If true, will skip any default logic for this redirect. + /// + public bool Handled { get; private set; } + + /// + /// Skips any default logic for this redirect. + /// + public void HandleResponse() => Handled = true; +} \ No newline at end of file diff --git a/src/GSS.Authentication.CAS.Owin/Events/ICasAuthenticationProvider.cs b/src/GSS.Authentication.CAS.Owin/Events/ICasAuthenticationProvider.cs index 9c8ebe3d..7d7d5ce0 100644 --- a/src/GSS.Authentication.CAS.Owin/Events/ICasAuthenticationProvider.cs +++ b/src/GSS.Authentication.CAS.Owin/Events/ICasAuthenticationProvider.cs @@ -22,6 +22,11 @@ public interface ICasAuthenticationProvider /// Contains redirect URI and of the challenge. /// Task RedirectToAuthorizationEndpoint(CasRedirectToAuthorizationEndpointContext context); + + /// + /// Invoked before redirecting to the identity provider to sign out. + /// + Task RedirectToIdentityProviderForSignOut(CasRedirectContext context); /// /// Invoked when there is a remote failure. diff --git a/src/GSS.Authentication.CAS.Owin/GSS.Authentication.CAS.Owin.csproj b/src/GSS.Authentication.CAS.Owin/GSS.Authentication.CAS.Owin.csproj index e0684283..c2e9f570 100644 --- a/src/GSS.Authentication.CAS.Owin/GSS.Authentication.CAS.Owin.csproj +++ b/src/GSS.Authentication.CAS.Owin/GSS.Authentication.CAS.Owin.csproj @@ -7,6 +7,7 @@ +