diff --git a/CAS.sln b/CAS.sln
index c7810bbd..40137a1e 100644
--- a/CAS.sln
+++ b/CAS.sln
@@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OwinSample", "samples\OwinS
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorSample", "samples\BlazorSample\BlazorSample.csproj", "{35775C99-782F-4502-B31D-B13E966D0A90}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNetCoreMvcSample", "samples\AspNetCoreMvcSample\AspNetCoreMvcSample.csproj", "{F98E8D5E-DB3C-4716-A440-392457A5784B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -105,6 +107,10 @@ Global
{35775C99-782F-4502-B31D-B13E966D0A90}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35775C99-782F-4502-B31D-B13E966D0A90}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35775C99-782F-4502-B31D-B13E966D0A90}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F98E8D5E-DB3C-4716-A440-392457A5784B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F98E8D5E-DB3C-4716-A440-392457A5784B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F98E8D5E-DB3C-4716-A440-392457A5784B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F98E8D5E-DB3C-4716-A440-392457A5784B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -122,6 +128,7 @@ Global
{9E34C625-CE58-4630-AF42-C5237660AE27} = {EF050CD3-59F8-432E-9E77-7CF8A5F5CD91}
{BB89E35B-F73F-426F-8E66-456D6A49FDF3} = {EF050CD3-59F8-432E-9E77-7CF8A5F5CD91}
{35775C99-782F-4502-B31D-B13E966D0A90} = {EF050CD3-59F8-432E-9E77-7CF8A5F5CD91}
+ {F98E8D5E-DB3C-4716-A440-392457A5784B} = {EF050CD3-59F8-432E-9E77-7CF8A5F5CD91}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C211A196-2432-4E8E-88F4-EBF50079001C}
diff --git a/README.md b/README.md
index 146b42ba..7e73fe20 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
# GSS.Authentication.CAS
-[![Build Status][build-badge]][build] [![Code Coverage][codecov-badge]][codecov] [![Lint][lint-badge]][lint]
+[![Build Status][build-badge]][build] [![Lint][lint-badge]][lint] [![Code Coverage][codecov-badge]][codecov]
[build]: https://github.com/akunzai/GSS.Authentication.CAS/actions/workflows/build.yml
[build-badge]: https://github.com/akunzai/GSS.Authentication.CAS/actions/workflows/build.yml/badge.svg
-[codecov]: https://codecov.io/gh/akunzai/GSS.Authentication.CAS
-[codecov-badge]: https://codecov.io/gh/akunzai/GSS.Authentication.CAS/branch/main/graph/badge.svg?token=JGG7Y07SR0
[lint]: https://github.com/akunzai/GSS.Authentication.CAS/actions/workflows/lint.yml
[lint-badge]: https://github.com/akunzai/GSS.Authentication.CAS/actions/workflows/lint.yml/badge.svg
+[codecov]: https://codecov.io/gh/akunzai/GSS.Authentication.CAS
+[codecov-badge]: https://codecov.io/gh/akunzai/GSS.Authentication.CAS/branch/main/graph/badge.svg?token=JGG7Y07SR0
CAS Authentication Middleware for OWIN & ASP.NET Core
@@ -40,6 +40,7 @@ Check out these [samples](./samples/) to learn the basics and key features.
- [ASP.NET Core with React.js](./samples/AspNetCoreReactSample/)
- [ASP.NET Core Identity](./samples/AspNetCoreIdentitySample/)
- [ASP.NET Core Blazor](./samples/BlazorSample/)
+- [ASP.NET Core MVC](./samples/AspNetCoreMvcSample/)
- [OWIN](./samples/OwinSample/)
## FAQ
diff --git a/samples/AspNetCoreMvcSample/AspNetCoreMvcSample.csproj b/samples/AspNetCoreMvcSample/AspNetCoreMvcSample.csproj
new file mode 100644
index 00000000..64abf851
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/AspNetCoreMvcSample.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+ PreserveNewest
+
+
+ appsettings.json
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AspNetCoreMvcSample/Controllers/AccountController.cs b/samples/AspNetCoreMvcSample/Controllers/AccountController.cs
new file mode 100644
index 00000000..4d4711d6
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Controllers/AccountController.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace AspNetCoreMvcSample.Controllers;
+
+[AllowAnonymous]
+public class AccountController : Controller
+{
+ // GET /Account/Login
+ [HttpGet]
+ public ActionResult Login(string scheme)
+ {
+ if (string.IsNullOrWhiteSpace(scheme))
+ {
+ return View();
+ }
+
+ return Challenge(new AuthenticationProperties { RedirectUri = "/" }, scheme);
+ }
+
+ // GET /Account/Logout
+ [HttpGet]
+ public async Task Logout(string redirectUrl)
+ {
+ 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/AspNetCoreMvcSample/Controllers/HomeController.cs b/samples/AspNetCoreMvcSample/Controllers/HomeController.cs
new file mode 100644
index 00000000..6d653a78
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Controllers/HomeController.cs
@@ -0,0 +1,21 @@
+using System.Diagnostics;
+using Microsoft.AspNetCore.Mvc;
+using AspNetCoreMvcSample.Models;
+using Microsoft.AspNetCore.Authorization;
+
+namespace AspNetCoreMvcSample.Controllers;
+
+[AllowAnonymous]
+public class HomeController : Controller
+{
+ public IActionResult Index()
+ {
+ return View();
+ }
+
+ [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
+ public IActionResult Error()
+ {
+ return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
+ }
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMvcSample/Models/ErrorViewModel.cs b/samples/AspNetCoreMvcSample/Models/ErrorViewModel.cs
new file mode 100644
index 00000000..d2fcc6bb
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Models/ErrorViewModel.cs
@@ -0,0 +1,8 @@
+namespace AspNetCoreMvcSample.Models;
+
+public class ErrorViewModel
+{
+ public string RequestId { get; set; }
+
+ public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMvcSample/Program.cs b/samples/AspNetCoreMvcSample/Program.cs
new file mode 100644
index 00000000..31b5bf7b
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Program.cs
@@ -0,0 +1,16 @@
+namespace AspNetCoreMvcSample;
+
+public class Program
+{
+ public static void Main(string[] args)
+ {
+ CreateHostBuilder(args).Build().Run();
+ }
+
+ public static IHostBuilder CreateHostBuilder(string[] args) =>
+ Host.CreateDefaultBuilder(args)
+ .ConfigureWebHostDefaults(webBuilder =>
+ {
+ webBuilder.UseStartup();
+ });
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMvcSample/Properties/launchSettings.json b/samples/AspNetCoreMvcSample/Properties/launchSettings.json
new file mode 100644
index 00000000..e670bfe3
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Properties/launchSettings.json
@@ -0,0 +1,13 @@
+{
+ "profiles": {
+ "AspNetCoreMvcSample": {
+ "commandName": "Project",
+ "dotnetRunMessages": "true",
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:5001;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/samples/AspNetCoreMvcSample/Startup.cs b/samples/AspNetCoreMvcSample/Startup.cs
new file mode 100644
index 00000000..f92a2010
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Startup.cs
@@ -0,0 +1,143 @@
+using System.Security.Claims;
+using GSS.Authentication.CAS;
+using GSS.Authentication.CAS.AspNetCore;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+
+namespace AspNetCoreMvcSample;
+
+public class Startup
+{
+ public Startup(IConfiguration configuration, IWebHostEnvironment hostEnvironment)
+ {
+ Configuration = configuration;
+ HostingEnvironment = hostEnvironment;
+ }
+
+ public IConfiguration Configuration { get; }
+
+ public IWebHostEnvironment HostingEnvironment { get; }
+
+ // This method gets called by the runtime. Use this method to add services to the container.
+ public void ConfigureServices(IServiceCollection services)
+ {
+ var singleLogout = Configuration.GetValue("CAS:SingleLogout", false);
+ if (singleLogout)
+ {
+ services.AddDistributedMemoryCache();
+ var redisConfiguration = Configuration.GetConnectionString("Redis");
+ if (!string.IsNullOrWhiteSpace(redisConfiguration))
+ {
+ services.AddStackExchangeRedisCache(options => options.Configuration = redisConfiguration);
+ }
+
+ services.AddSingleton();
+ services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme)
+ .Configure((o, t) => o.SessionStore = t);
+ }
+ services.AddControllersWithViews();
+ services.AddAuthorization(options =>
+ {
+ // Globally Require Authenticated Users
+ options.FallbackPolicy = options.DefaultPolicy;
+ });
+ services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+ .AddCookie()
+ .AddCAS(options =>
+ {
+ options.CasServerUrlBase = Configuration["CAS:ServerUrlBase"]!;
+ // required for CasSingleLogoutMiddleware
+ options.SaveTokens = singleLogout || Configuration.GetValue("CAS:SaveTokens", false);
+ options.Events.OnCreatingTicket = context =>
+ {
+ if (context.Identity == null)
+ return Task.CompletedTask;
+ // Map claims from assertion
+ var assertion = context.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))
+ {
+ context.Identity.AddClaim(new Claim(ClaimTypes.Name, displayName!));
+ }
+
+ if (assertion.Attributes.TryGetValue("cn", out var fullName) &&
+ !string.IsNullOrWhiteSpace(fullName))
+ {
+ context.Identity.AddClaim(new Claim(ClaimTypes.Name, fullName!));
+ }
+
+ if (assertion.Attributes.TryGetValue("email", out var email) &&
+ !string.IsNullOrWhiteSpace(email))
+ {
+ context.Identity.AddClaim(new Claim(ClaimTypes.Email, email!));
+ }
+
+ return Task.CompletedTask;
+ };
+ })
+ .AddOpenIdConnect(options =>
+ {
+ options.ClientId = Configuration["OIDC:ClientId"];
+ options.ClientSecret = Configuration["OIDC:ClientSecret"];
+ options.Authority = Configuration["OIDC:Authority"];
+ options.RequireHttpsMetadata = !HostingEnvironment.IsDevelopment();
+ // required for single logout
+ options.SaveTokens = Configuration.GetValue("OIDC:SaveTokens", false);
+ options.ResponseType = OpenIdConnectResponseType.Code;
+ var scope = Configuration["OIDC:Scope"];
+ if (!string.IsNullOrWhiteSpace(scope))
+ {
+ scope.Split(" ", StringSplitOptions.RemoveEmptyEntries).ToList().ForEach(s => options.Scope.Add(s));
+ }
+ options.TokenValidationParameters.NameClaimType =
+ 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;
+ };
+ });
+ }
+
+ // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+ {
+ if (env.IsDevelopment())
+ {
+ app.UseDeveloperExceptionPage();
+ }
+ else
+ {
+ app.UseExceptionHandler("/Home/Error");
+ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
+ app.UseHsts();
+ }
+
+ app.UseHttpsRedirection();
+ app.UseStaticFiles();
+
+ app.UseRouting();
+
+ var singleLogout = Configuration.GetValue("CAS:SingleLogout", false);
+ if (singleLogout)
+ {
+ app.UseCasSingleLogout();
+ }
+
+ app.UseAuthentication();
+ app.UseAuthorization();
+
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllerRoute(
+ name: "default",
+ pattern: "{controller=Home}/{action=Index}/{id?}");
+ });
+ }
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMvcSample/Views/Account/Login.cshtml b/samples/AspNetCoreMvcSample/Views/Account/Login.cshtml
new file mode 100644
index 00000000..5d4226a5
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Views/Account/Login.cshtml
@@ -0,0 +1,18 @@
+@using Microsoft.Extensions.Options;
+@using Microsoft.AspNetCore.Authentication;
+@inject IOptions AuthOptions;
+@{
+ ViewData["Title"] = "Login";
+}
+
+
Choose an authentication scheme
+
+@foreach (var type in AuthOptions.Value.Schemes)
+{
+ if (string.IsNullOrEmpty(type.DisplayName))
+ {
+ continue;
+ }
+
+ @type.DisplayName
+}
\ No newline at end of file
diff --git a/samples/AspNetCoreMvcSample/Views/Home/Error.cshtml b/samples/AspNetCoreMvcSample/Views/Home/Error.cshtml
new file mode 100644
index 00000000..4d96f6a7
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Views/Home/Error.cshtml
@@ -0,0 +1,25 @@
+@model ErrorViewModel
+@{
+ ViewData["Title"] = "Error";
+}
+
+
Error.
+
An error occurred while processing your request.
+
+@if (Model.ShowRequestId)
+{
+
+ Request ID:@Model.RequestId
+
+}
+
+
Development Mode
+
+ Swapping to Development environment will display more detailed information about the error that occurred.
+
+
+ The Development environment shouldn't be enabled for deployed applications.
+ It can result in displaying sensitive information from exceptions to end users.
+ For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
+ and restarting the app.
+
\ No newline at end of file
diff --git a/samples/AspNetCoreMvcSample/Views/Home/Index.cshtml b/samples/AspNetCoreMvcSample/Views/Home/Index.cshtml
new file mode 100644
index 00000000..16e74e6c
--- /dev/null
+++ b/samples/AspNetCoreMvcSample/Views/Home/Index.cshtml
@@ -0,0 +1,43 @@
+@using Microsoft.AspNetCore.Authentication
+@inject IAuthenticationService AuthenticationService
+@{
+ ViewData["Title"] = "Home";
+}
+@if (User.Identity?.IsAuthenticated == true)
+{
+